SLOPSHOPPER

hyperspell-activity

Logs what this Claude session did into the Hyperspell company brain as one reviewable activity entry per session, written through the Hyperspell connector…

newguardcommandtoastmodeltimer
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · hyperspell-activity
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /hyperspell-activity ⎿ hyperspell-activity: Hyperspell activity capture: on (entries none yet; written as claude-code). ⎿ hyperspell-activity: A capture is pending for the last turn. ⎿ hyperspell-activity: /hyperspell-activity pause | resume | now ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

Hyperspell Activity

Logs what each Claude session did into your Hyperspell company brain as one reviewable activity entry, so the team can find what people's agents worked on, what they decided, and what is next, without anyone writing it up.

What it does

After a turn in which Claude did real work (used a tool, or gave a substantial answer), the plugin waits a second, then asks the session's own model for a short structured summary of the session so far: a title, a summary, the decisions made, the next steps, the links involved, and the workstream it belongs to. Trivial turns are skipped. It then writes that summary through the Hyperspell connector's log_activity tool as one entry per session, revised in place as the session goes on, rather than a new entry each turn.

What this plugin runs, reads and sends

The plugin is one hook module, hooks/activity.ts, with no commands, agents, skills or MCP servers of its own. It runs no shell commands, starts no processes, reads and writes no files, and makes no network requests of its own. Everything it does goes through Claude Code's plugin interface, as follows.

Hooks and what each one does

  • session.start: registers the /hyperspell-activity command and reads one environment variable, CLAUDE_CODE_ENTRYPOINT, to label entries as coming from Claude Code or from Cowork. Nothing is sent.
  • tool.call: observes only. It reads the call's agentId, to ignore calls made by subagents, records a single yes/no, "this turn used a tool", and passes every call through unchanged. It never reads, stores, rewrites, blocks or copies a tool's name, inputs or outputs, and it never runs a tool.
  • turn.complete: after a turn of the main conversation that ended with an answer, it checks whether that turn used a tool or produced an answer of 400 or more characters. If so, it schedules one capture one second later (a run of quick turns costs one capture). It reads the turn's agentId (to ignore subagent turns), its end reason and the answer's length, and nothing else from the turn.
  • session.end: resets the plugin's per-session state. Nothing is sent.
  • command.run for /hyperspell-activity: answers status, pause, resume and now (see Controls). now runs a capture at once.

The two calls a capture makes

  1. A model request over the session's own transcript ($.model.fork). The plugin asks the model the session already uses, under the session's own account, to reply with one JSON object: skip, title, summary, decisions, next_steps, links, workstream. The prompt is fixed text in the module; it tells the model never to include secrets. The plugin does not copy or send the transcript anywhere; it only receives the reply.
  2. One MCP tool call ($.mcp.call) to the tool log_activity on an MCP server that is already connected in the session. To find it, the plugin lists the session's tools once and picks, in order: the hosted Hyperspell connector, the hyperspell-context server the Hyperspell sync daemon registers, another connected server named for Hyperspell, or, failing all of those, any connected server that offers a log_activity tool. It calls no tool other than log_activity, and only on the server it picked. The first call asks the session's tool permission once; if the session refuses, capture pauses instead of asking again. Normally one call per capture: if the server answers that it cannot revise an entry (an older Hyperspell adapter), the same payload is sent once more without entry_id, and captures on that server then create one entry each.

What that call sends, and nothing else: title, summary, decisions, next_steps, links, workstream (all from the model's reply, after redaction, see below), agent (claude-code or claude-cowork), share (the setting below), and, except in the fallback above, entry_id (claude-session- plus the session id), so later captures revise the same entry. Where it goes is wherever the chosen server sends it: for the Hyperspell connector, Hyperspell's API for your workspace, under the connector's own credential and permissions. The connector's privacy policy is at https://hyperspell.com/privacy.

Before anything is sent, text shaped like a credential is blanked to [redacted]: private keys, API keys, bearer tokens and JWTs, key= / secret= / token= / password= assignments, logins inside URLs, and long opaque tokens. Links are kept only when they are http(s) URLs or paths relative to a project; local absolute paths are dropped.

Other interface use

The clock ($.clock) for the one-second settle timer and to read the time of each successful capture, shown by status; toasts ($.ui.toast) to announce the first capture, any pause, and the fallback from shared to suggest when the workspace refuses direct sharing; the session id ($.session.id) for the entry id; and the tool listing ($.tool.list) to find the server. That is the complete list.

Who sees an entry

The share option in the plugin's settings decides, and defaults to the most private choice that still reaches the team:

  • suggest (default): the entry stays yours, and your Hyperspell Inbox asks whether to share it with the company.
  • shared: shared with the company at once, where your workspace allows direct sharing; otherwise it falls back to suggest.
  • personal: never asks, never shares.

With a workspace (user-less) credential every entry is the company's; personal is refused there and the plugin pauses rather than write.

Controls

Inside a session: /hyperspell-activity status (on or paused, entries written, the server used, the last problem), pause, resume, and now (write the entry at once). Removing the plugin stops capture entirely.

Requirements

The Hyperspell connector connected in your Claude account or organization, on a Hyperspell server that offers log_activity. Without one the plugin writes nothing and says so in /hyperspell-activity status.

Support: support@hyperspell.com

Source 1 files
hooks/activity.ts 464 lines
1// @implements specs/components/activity-capture-mod.md
2import type { EngineInterface, Register, Timer } from 'claude-code'
3
4// Hyperspell activity capture (ENG-4403).
5//
6// After each main-loop turn that did real work, ask the session's own model for a
7// short structured summary of the session so far (a fork over the main thread's
8// transcript: served from the prompt cache, and the transcript never leaves the
9// session), then write it through the Hyperspell MCP server this session already
10// has, as ONE entry per session that each capture revises (`log_activity` with
11// `entry_id`). The entry stays the user's and asks them to share it; the Inbox, the
12// Slack digest and the changelog page block do the rest. Nothing here depends on the
13// agent remembering to log, and nothing reaches the network directly: inside Cowork
14// only the connector is reachable, and `$.mcp.call` goes through it.
15
16const TOOL = 'log_activity'
17const COMMAND = 'hyperspell-activity'
18// A turn with no tool use and a short reply is conversation, not work: no fork.
19const SHORT_ANSWER_CHARS = 400
20// A capture waits this long after the turn, so a run of quick turns costs one fork.
21// Short on purpose: the session's end allows no fork (its hooks share a 1.5 s bound),
22// so a turn followed by an exit inside this window is the one turn never captured.
23const SETTLE_MS = 1000
24
25type Share = 'suggest' | 'shared' | 'personal'
26
27// What the first capture of a session says, per share mode. `suggest` and `personal`
28// hold for a user-tied credential; a user-less workspace key makes every write the
29// company's already (api/add-memory), which the Inbox then has nothing to ask about.
30const ANNOUNCEMENTS: Record<Share, string> = {
31  suggest: "Hyperspell is logging this session's activity as one entry; with your own credential it waits in your Hyperspell Inbox for you to share.",
32  shared: "Hyperspell is logging this session's activity as one entry shared with the company.",
33  personal: "Hyperspell is logging this session's activity as one entry kept to you.",
34}
35
36const PROMPT = `Summarize this session so far for your team's activity changelog. Reply with ONE JSON object and nothing else, no code fence:
37{"skip": boolean, "title": string, "summary": string, "decisions": string[], "next_steps": string[], "links": string[], "workstream": string | null}
38- "skip": true when the session so far produced nothing worth a changelog row (small talk, a question answered from general knowledge, reading around with no outcome); the other fields may then be empty.
39- Otherwise cover the WHOLE session so far, not only the last turn. "title": at most ten words naming the outcome. "summary": two to five plain past-tense sentences on what was done and why, no preamble. "decisions": choices made, each with its reason. "next_steps": what remains. "links": URLs of the work itself (pull requests, documents, tickets) that appeared in the session; never local file paths. "workstream": the project, workstream or customer this belongs to when one was named, else null.
40- Never include secrets, credentials or tokens.`
41
42type Entry = {
43  title: string
44  summary: string
45  decisions: string[]
46  next_steps: string[]
47  links: string[]
48  workstream: string | null
49}
50
51type Outcome = 'logged' | 'skipped' | 'failed' | 'busy' | 'paused'
52
53const NOW_REPLIES: Record<Exclude<Outcome, 'failed'>, string> = {
54  logged: 'Logged this session so far to Hyperspell.',
55  skipped: 'Nothing worth logging yet.',
56  busy: 'A capture is already running; it will pick this up.',
57  paused: `Capture is paused. /${COMMAND} resume first.`,
58}
59
60// Session state. A reload starts it over, as it does the session's hooks; a session's
61// end resets what belongs to the conversation, since after a /clear the process goes
62// on under a new session id with no session.start.
63let agent = 'claude-code'
64let configuredShare: Share = 'suggest'
65let share: Share = 'suggest'
66let server: string | undefined
67// False once the server said it cannot revise an entry (an adapter older than
68// hyperspell-mcp 0.22): one entry per capture there, rather than none at all.
69let reviseByEntryId = true
70let paused = false
71let pending = false
72let usedTool = false
73let inFlight: Promise<Outcome> | undefined
74let timer: Timer | undefined
75let generation = 0
76let captures = 0
77let lastCaptureAt: number | undefined
78let lastError: string | undefined
79let announced = false
80
81const strings = (value: unknown): string[] =>
82  Array.isArray(value)
83    ? value.filter((item): item is string => typeof item === 'string' && item.trim() !== '')
84    : []
85
86// The prompt tells the model to leave secrets out; the mod does not take its word for
87// it. Credential shapes that appear in transcripts (private keys, API keys, bearer
88// tokens and bare JWTs, any `...key=` / `...secret=` / `...token=` / `password=`
89// assignment, long opaque tokens) are blanked before the write, so a leaked value in
90// the session never reaches the brain through this path. Over-blanking a harmless
91// `key=` in a summary costs a few words; under-blanking costs a credential.
92const SECRET_PATTERNS: readonly RegExp[] = [
93  /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g,
94  /\b(?:hs2|sk|rk|pk|ghp|gho|ghu|ghs|ghr|xox[abprs])[-_][A-Za-z0-9_-]{8,}\b/g,
95  /\bAKIA[0-9A-Z]{16}\b/g,
96  /\bBearer\s+[A-Za-z0-9._~+/=-]{16,}/gi,
97  // A credential in a URL's userinfo: `https://alice:hunter2@host/...`. Stops at `?`
98  // and `#` too, so `?email=alice@example.org` is a query, not a login.
99  /(?<=:\/\/)[^\s\/@?#]+(?=@)/g,
100  // A JWT on its own: base64url `{"` is `eyJ` (`eyI` before an empty key), and the
101  // segments are dot-joined. Header and payload may both be short (`eyIiOjB9.e30`),
102  // so neither has a floor beyond that prefix.
103  /\bey[IJ][A-Za-z0-9_-]+\.[A-Za-z0-9_-]+(?:\.[A-Za-z0-9_-]+)?/g,
104  // Any value, however short (`password=x` is still a password), and a quoted value
105  // whole: `password="correct horse battery staple"` has spaces in it.
106  /\b(?:[a-z_-]*(?:key|secret|token)|password|passwd|pwd)\b\s*[:=]\s*(?:"[^"]*"|'[^']*'|[^\s"',;]+)/gi,
107]
108
109// A long opaque run is a secret in free text, where nothing else is that shape.
110const OPAQUE_TOKEN_PATTERN = /\b[A-Za-z0-9_-]{48,}\b/g
111
112// In a link's path such a run is usually a slug or a branch name (`/` and `.` bound
113// it), and blanking it leaves a dead link. A slug reads as words: at least two chunks
114// between separators are words of three or more letters (`Kestrel-canary-rollout-…`,
115// `eng-4403-the-longest-…`), and no chunk is longer than a Notion id (32). A word has
116// a letter past `f`, so a dash-grouped hex token (`abcdef-fedcba-…`) is no slug. A
117// token fails one of those: `reset-<48 random>` has one word and one long chunk,
118// `verify-token-<48 random>` the long chunk, a separator-split base64url run no
119// words. The rest of the path is kept so the link opens; a query string or fragment
120// gets no benefit of the doubt at all.
121const isWord = (chunk: string): boolean => /^[A-Za-z]{3,}$/.test(chunk) && /[g-z]/i.test(chunk)
122const isSlug = (run: string): boolean => {
123  const chunks = run.split(/[-_]+/)
124  return chunks.filter(isWord).length >= 2 && chunks.every((chunk) => chunk.length <= 32)
125}
126
127const blank = (text: string, patterns: readonly RegExp[]): string =>
128  patterns.reduce((out, pattern) => out.replace(pattern, '[redacted]'), text)
129
130export function redact(text: string): string {
131  return blank(text, [...SECRET_PATTERNS, OPAQUE_TOKEN_PATTERN])
132}
133
134export function redactLink(link: string): string {
135  const named = blank(link, SECRET_PATTERNS)
136  const cut = named.search(/[?#]/)
137  const path = cut === -1 ? named : named.slice(0, cut)
138  const tail = cut === -1 ? '' : named.slice(cut)
139  return (
140    path.replace(OPAQUE_TOKEN_PATTERN, (run) => (isSlug(run) ? run : '[redacted]')) +
141    tail.replace(OPAQUE_TOKEN_PATTERN, '[redacted]')
142  )
143}
144
145// A link the team can open: an http(s) URL, or a path relative to a project. Anything
146// else is dropped: a local absolute path (`/Users/...`, `~/...`, `C:\...`, `\\server\...`)
147// names one machine's disk, and another scheme or a protocol-relative `//host` is not a
148// link a shared entry should carry from model output.
149const isShareableLink = (link: string): boolean => {
150  const text = link.trim()
151  return /^https?:\/\//i.test(text) || !/^([\\/]|~|[a-zA-Z]:[\\/]|[a-z][a-z0-9+.-]*:)/i.test(text)
152}
153
154// The first balanced {...} in the text, string contents included, so prose after
155// the object (even prose with braces) does not break it.
156function firstObject(text: string): string | null {
157  const start = text.indexOf('{')
158  if (start < 0) return null
159  let depth = 0
160  let inString = false
161  let escaped = false
162  for (let i = start; i < text.length; i++) {
163    const ch = text[i]
164    if (inString) {
165      if (escaped) escaped = false
166      else if (ch === '\\') escaped = true
167      else if (ch === '"') inString = false
168      continue
169    }
170    if (ch === '"') inString = true
171    else if (ch === '{') depth += 1
172    else if (ch === '}') {
173      depth -= 1
174      if (depth === 0) return text.slice(start, i + 1)
175    }
176  }
177  return null
178}
179
180export function parseEntry(text: string): Entry | 'skip' | null {
181  const object = firstObject(text)
182  if (object === null) return null
183  let raw: Record<string, unknown>
184  try {
185    raw = JSON.parse(object) as Record<string, unknown>
186  } catch {
187    return null
188  }
189  if (raw.skip === true) return 'skip'
190  const title = typeof raw.title === 'string' ? raw.title.trim() : ''
191  const summary = typeof raw.summary === 'string' ? raw.summary.trim() : ''
192  if (!summary) return null
193  const clean = (value: string) => redact(value).trim()
194  const cleanSummary = clean(summary)
195  return {
196    // Blanked before it is cut: a secret straddling the cut would otherwise lose the
197    // length that marks it as one.
198    title: clean(title || (summary.split('\n')[0] ?? summary)).slice(0, 120),
199    summary: cleanSummary,
200    decisions: strings(raw.decisions).map(clean),
201    next_steps: strings(raw.next_steps).map(clean),
202    links: strings(raw.links).filter(isShareableLink).map((link) => redactLink(link).trim()),
203    workstream: typeof raw.workstream === 'string' && raw.workstream.trim() ? clean(raw.workstream) : null,
204  }
205}
206
207// Which server this session writes through: one offering `log_activity`, preferring
208// the hosted connector (cloud-brokered, so it works inside Cowork, and the user's own
209// identity), then the server the sync daemon registers, then any other named for
210// Hyperspell, then the first by name. A stable choice: a probe or test server never
211// outranks the real one by sorting earlier.
212export function chooseServer(toolNames: readonly string[]): string | undefined {
213  const rank = (name: string) =>
214    /^(claude_ai_)?hyperspell$/i.test(name)
215      ? 0
216      : name === 'hyperspell-context'
217        ? 1
218        : /hyperspell/i.test(name)
219          ? 2
220          : 3
221  return toolNames
222    .filter(name => name.startsWith('mcp__') && name.endsWith(`__${TOOL}`))
223    .map(name => name.slice('mcp__'.length, -(TOOL.length + 2)))
224    .sort((a, b) => rank(a) - rank(b) || a.localeCompare(b))[0]
225}
226
227async function findServer($: EngineInterface): Promise<string | undefined> {
228  return chooseServer((await $.tool.list()).map(tool => tool.name))
229}
230
231function schedule($: EngineInterface): void {
232  if (timer || paused) return
233  timer = $.clock.after(SETTLE_MS, () => {
234    timer = undefined
235    void capture($)
236  })
237}
238
239function cancelTimer(): void {
240  timer?.cancel()
241  timer = undefined
242}
243
244// The engine refusing the mod's own call, as opposed to the server answering an
245// error (which comes back as a result): the permission layer says
246// `$.mcp.call(<server>, <tool>) refused: <reason>`; a hook's deny beneath the call
247// (the test kit's spelling) says `$.mcp.call: <reason>`. A transport failure says
248// `failed:` and is not a refusal.
249const isEngineRefusal = (message: string): boolean =>
250  /\$\.mcp\.call(\([^)]*\) refused|): /.test(message) && !/\$\.mcp\.call\([^)]*\) failed: /.test(message)
251
252const errorText = (content: readonly { type: string; text?: string }[]): string =>
253  content
254    .map(block => (block.type === 'text' ? (block.text ?? '') : ''))
255    .join(' ')
256    .trim() || 'the server reported an error'
257
258// The HTTP status a server error carries, however each path words it: the hosted
259// connector says `403: this app does not allow ...`, a local server relays the
260// route's `403: ...` detail or httpx's `Client error '403 Forbidden' ...`.
261const statusOf = (text: string): number | undefined => {
262  const match = /\b(4\d\d|5\d\d)\b/.exec(text)
263  return match ? Number(match[1]) : undefined
264}
265
266async function capture($: EngineInterface): Promise<Outcome> {
267  if (paused) return 'paused'
268  if (inFlight) {
269    pending = true
270    return 'busy'
271  }
272  inFlight = captureNow($).finally(() => {
273    inFlight = undefined
274  })
275  return inFlight
276}
277
278async function captureNow($: EngineInterface): Promise<Outcome> {
279  // The session this capture belongs to, read before any wait: a /clear during the
280  // fork must not file the old conversation's summary under the new session's id.
281  const gen = generation
282  const sessionId = await $.session.id()
283  // After every wait, before any shared state is touched: a capture that resumes
284  // once the session has ended must leave the next session's state alone.
285  if (gen !== generation) return 'skipped'
286  try {
287    pending = false
288    if (server === undefined) {
289      const found = await findServer($)
290      if (gen !== generation) return 'skipped'
291      server = found
292    }
293    if (!server) {
294      lastError = 'no Hyperspell MCP server in this session offers log_activity'
295      return 'failed'
296    }
297    const reply = await $.model.fork({ prompt: PROMPT })
298    if (gen !== generation) return 'skipped' // the session ended meanwhile
299    if (paused) return 'paused' // the person paused while the summary was being made
300    if (!reply.isAnswered) {
301      lastError = `the summary was not produced (${reply.reason})`
302      return 'failed'
303    }
304    const entry = parseEntry(reply.text)
305    if (entry === null) {
306      lastError = 'the summary was not the expected JSON'
307      return 'failed'
308    }
309    if (entry === 'skip') return 'skipped'
310    const via = server
311    const args = { ...entry, agent, share }
312    let result = await $.mcp.call(
313      via,
314      TOOL,
315      reviseByEntryId ? { ...args, entry_id: `claude-session-${sessionId}` } : args,
316    )
317    // A server that no longer offers the tool is looked up again next time, whichever
318    // session the answer reaches; everything else below belongs to this session only.
319    if (result.isError && /unknown tool|not found/i.test(errorText(result.content))) {
320      server = undefined
321      reviseByEntryId = true // the next server is probed with the id afresh
322    }
323    if (gen !== generation) return 'skipped' // the session ended while the write was out
324    if (result.isError && reviseByEntryId && /cannot revise an activity entry/i.test(errorText(result.content))) {
325      reviseByEntryId = false
326      result = await $.mcp.call(via, TOOL, args)
327      if (gen !== generation) return 'skipped'
328    }
329    if (result.isError) {
330      lastError = errorText(result.content)
331      const status = statusOf(lastError)
332      // The app's policy beats the option. `shared` needs allow_direct_share (403): fall
333      // back to asking for the rest of the session rather than failing every turn.
334      if (status === 403 && share === 'shared') {
335        share = 'suggest'
336        $.ui.toast('Hyperspell: this workspace does not allow direct sharing; entries will ask you to share instead.')
337        pending = true
338      }
339      // `personal` needs a user-tied credential (422). A user-less key would make every
340      // entry the company's, which is the one thing this option refuses: pause instead.
341      if (status === 422 && share === 'personal') {
342        paused = true
343        $.ui.toast(
344          `Hyperspell activity capture paused: this credential cannot keep entries to you. Choose suggest or shared, or /${COMMAND} resume.`,
345        )
346      }
347      return 'failed'
348    }
349    captures += 1
350    lastCaptureAt = await $.clock.now()
351    if (gen !== generation) return 'logged' // written; the announcement is not this session's to make
352    lastError = undefined
353    if (!announced) {
354      announced = true
355      $.ui.toast(`${ANNOUNCEMENTS[share]} (through ${via}) /${COMMAND} to pause.`)
356    }
357    return 'logged'
358  } catch (err) {
359    if (gen !== generation) return 'skipped' // nothing of the next session is touched
360    lastError = err instanceof Error ? err.message : String(err)
361    // The engine refused the call itself (its permission layer; headless, the auto-mode
362    // classifier with no one to ask). Pause rather than ask again after every turn;
363    // /hyperspell-activity resume turns it back on. A server-side error never reads
364    // this way: it comes back as a result, above.
365    if (isEngineRefusal(lastError)) {
366      paused = true
367      $.ui.toast(
368        `Hyperspell activity capture paused: this session did not allow the write through ${server}. /${COMMAND} resume to try again.`,
369      )
370    }
371    return 'failed'
372  } finally {
373    // A turn of this session, or of the next one after a /clear, may have ended meanwhile.
374    if (pending) schedule($)
375  }
376}
377
378function status(): string {
379  const lines = [
380    `Hyperspell activity capture: ${paused ? 'paused' : 'on'} (entries ${
381      captures === 0 ? 'none yet' : `${captures}, last ${new Date(lastCaptureAt ?? 0).toLocaleTimeString()}`
382    }; written as ${agent}${server ? ` through ${server}` : ''}).`,
383  ]
384  if (pending) lines.push('A capture is pending for the last turn.')
385  if (lastError) lines.push(`Last problem: ${lastError}.`)
386  lines.push(`/${COMMAND} pause | resume | now`)
387  return lines.join('\n')
388}
389
390export const register: Register = (on, options) => {
391  configuredShare = options.share === 'personal' || options.share === 'shared' ? options.share : 'suggest'
392  share = configuredShare
393
394  on('session.start', async ($, e, next) => {
395    const result = await next(e)
396    const entrypoint = await $.env.get('CLAUDE_CODE_ENTRYPOINT')
397    agent = entrypoint === 'remote_cowork' ? 'claude-cowork' : 'claude-code'
398    await $.command.register({
399      name: COMMAND,
400      description: 'Hyperspell activity capture for this session: status, pause, resume, or log now',
401      argumentHint: '[pause|resume|now]',
402    })
403    return result
404  })
405
406  // Whether the turn in progress used a tool, without copying the transcript.
407  on('tool.call', (_$, e, next) => {
408    if (e.agentId === undefined) usedTool = true
409    return next(e)
410  }).catch((_$, e, next) => next(e))
411
412  on('turn.complete', async ($, e, next) => {
413    const result = await next(e)
414    if (e.agentId !== undefined) return result
415    const worked = usedTool || e.answer.length >= SHORT_ANSWER_CHARS
416    usedTool = false
417    if (e.reason !== 'answer' || paused || !worked) return result
418    pending = true
419    schedule($)
420    return result
421  })
422
423  // The session's end allows no fork (one 1.5 s bound for every hook), so nothing is
424  // flushed here: what the settle timer captured stands. The conversation's state is
425  // reset, since a /clear goes on under a new session id with no session.start, and a
426  // capture still in its fork is told not to write.
427  on('session.end', async (_$, e, next) => {
428    generation += 1
429    cancelTimer()
430    server = undefined // resolved again for the next conversation: one tool listing
431    reviseByEntryId = true
432    usedTool = false // a turn interrupted mid-tool must not count for the next session
433    pending = false
434    paused = false
435    share = configuredShare
436    captures = 0
437    announced = false
438    lastError = undefined
439    return next(e)
440  })
441
442  on('command.run', { command: COMMAND }, async ($, e) => {
443    const action = e.args.trim().toLowerCase()
444    if (action === 'pause') {
445      paused = true
446      cancelTimer()
447      return { text: `Hyperspell activity capture paused for this session. /${COMMAND} resume to continue.` }
448    }
449    if (action === 'resume') {
450      paused = false
451      if (pending) schedule($)
452      return { text: 'Hyperspell activity capture resumed.' }
453    }
454    if (action === 'now') {
455      cancelTimer()
456      const outcome = await capture($)
457      return {
458        text: outcome === 'failed' ? `Could not log: ${lastError ?? 'unknown error'}.` : NOW_REPLIES[outcome],
459      }
460    }
461    return { text: status() }
462  }).catch(() => ({ text: `Hyperspell activity capture: ${lastError ?? 'the command failed'}.` }))
463}
464