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

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.
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.
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.
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.$.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.$.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.
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.
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.
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.
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
hooks/activity.ts 464 lines1// @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