switchroom 0.5.0 → 0.7.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (89) hide show
  1. package/README.md +142 -121
  2. package/bin/autoaccept.exp +29 -6
  3. package/dist/agent-scheduler/index.js +12261 -0
  4. package/dist/cli/autoaccept-poll.js +10 -0
  5. package/dist/cli/switchroom.js +27250 -25324
  6. package/dist/vault/approvals/kernel-server.js +12709 -0
  7. package/dist/vault/broker/server.js +15724 -0
  8. package/package.json +4 -3
  9. package/profiles/_base/start.sh.hbs +133 -0
  10. package/profiles/_shared/telegram-style.md.hbs +3 -3
  11. package/profiles/default/CLAUDE.md +3 -3
  12. package/profiles/default/CLAUDE.md.hbs +2 -2
  13. package/profiles/default/workspace/CLAUDE.md.hbs +9 -0
  14. package/skills/docx/VENDORED.md +1 -1
  15. package/skills/mcp-builder/VENDORED.md +1 -1
  16. package/skills/pdf/VENDORED.md +1 -1
  17. package/skills/pptx/VENDORED.md +1 -1
  18. package/skills/skill-creator/VENDORED.md +1 -1
  19. package/skills/switchroom-architecture/SKILL.md +8 -7
  20. package/skills/switchroom-cli/SKILL.md +23 -15
  21. package/skills/switchroom-health/SKILL.md +7 -7
  22. package/skills/switchroom-install/SKILL.md +36 -39
  23. package/skills/switchroom-manage/SKILL.md +4 -4
  24. package/skills/switchroom-status/SKILL.md +1 -1
  25. package/skills/webapp-testing/VENDORED.md +1 -1
  26. package/skills/xlsx/VENDORED.md +1 -1
  27. package/telegram-plugin/admin-commands/dispatch.test.ts +119 -1
  28. package/telegram-plugin/admin-commands/index.ts +71 -0
  29. package/telegram-plugin/ask-user.ts +1 -0
  30. package/telegram-plugin/card-event-log.ts +138 -0
  31. package/telegram-plugin/dist/bridge/bridge.js +178 -31
  32. package/telegram-plugin/dist/foreman/foreman.js +6875 -6526
  33. package/telegram-plugin/dist/gateway/gateway.js +13862 -11834
  34. package/telegram-plugin/dist/server.js +202 -40
  35. package/telegram-plugin/fleet-state.ts +25 -10
  36. package/telegram-plugin/foreman/foreman.ts +38 -3
  37. package/telegram-plugin/gateway/approval-callback.ts +126 -0
  38. package/telegram-plugin/gateway/approval-card.test.ts +90 -0
  39. package/telegram-plugin/gateway/approval-card.ts +127 -0
  40. package/telegram-plugin/gateway/approvals-commands.ts +126 -0
  41. package/telegram-plugin/gateway/boot-card.ts +31 -6
  42. package/telegram-plugin/gateway/boot-probes.ts +503 -72
  43. package/telegram-plugin/gateway/gateway.ts +822 -94
  44. package/telegram-plugin/gateway/ipc-protocol.ts +34 -1
  45. package/telegram-plugin/gateway/ipc-server.ts +35 -0
  46. package/telegram-plugin/gateway/startup-mutex.ts +110 -2
  47. package/telegram-plugin/hooks/hooks.json +19 -0
  48. package/telegram-plugin/hooks/tool-label-pretool.mjs +216 -0
  49. package/telegram-plugin/hooks/tool-label-stop.mjs +63 -0
  50. package/telegram-plugin/package.json +4 -1
  51. package/telegram-plugin/plugin-logger.ts +20 -1
  52. package/telegram-plugin/progress-card-driver.ts +202 -13
  53. package/telegram-plugin/progress-card.ts +2 -2
  54. package/telegram-plugin/quota-check.ts +1 -0
  55. package/telegram-plugin/registry/subagents-schema.ts +37 -0
  56. package/telegram-plugin/registry/subagents.test.ts +64 -0
  57. package/telegram-plugin/session-tail.ts +58 -5
  58. package/telegram-plugin/shared/bot-runtime.ts +48 -2
  59. package/telegram-plugin/subagent-watcher.ts +139 -7
  60. package/telegram-plugin/tests/_progress-card-harness.ts +4 -0
  61. package/telegram-plugin/tests/bg-agent-progress-card-757.test.ts +201 -0
  62. package/telegram-plugin/tests/boot-card-probe-target.test.ts +10 -34
  63. package/telegram-plugin/tests/boot-card-render.test.ts +6 -5
  64. package/telegram-plugin/tests/boot-probes.test.ts +558 -0
  65. package/telegram-plugin/tests/card-event-log.test.ts +145 -0
  66. package/telegram-plugin/tests/gateway-startup-mutex.test.ts +102 -0
  67. package/telegram-plugin/tests/ipc-server-validate-inject-inbound.test.ts +134 -0
  68. package/telegram-plugin/tests/progress-card-delay-842.test.ts +160 -0
  69. package/telegram-plugin/tests/quota-check.test.ts +37 -1
  70. package/telegram-plugin/tests/subagent-registry-bugs.test.ts +5 -0
  71. package/telegram-plugin/tests/subagent-watcher-stall-notification.test.ts +104 -1
  72. package/telegram-plugin/tests/subagent-watcher.test.ts +5 -0
  73. package/telegram-plugin/tests/tool-label-sidecar.test.ts +114 -0
  74. package/telegram-plugin/tests/two-zone-bg-done-when-all-terminal.test.ts +5 -3
  75. package/telegram-plugin/tests/two-zone-card-header-phases.test.ts +10 -0
  76. package/telegram-plugin/tests/two-zone-snapshot-extras.test.ts +58 -14
  77. package/telegram-plugin/tests/welcome-text.test.ts +57 -0
  78. package/telegram-plugin/tool-label-sidecar.ts +140 -0
  79. package/telegram-plugin/tool-labels.ts +55 -0
  80. package/telegram-plugin/two-zone-card.ts +27 -7
  81. package/telegram-plugin/uat/SETUP.md +160 -0
  82. package/telegram-plugin/uat/assertions.ts +140 -0
  83. package/telegram-plugin/uat/driver.ts +174 -0
  84. package/telegram-plugin/uat/harness.ts +161 -0
  85. package/telegram-plugin/uat/login.ts +134 -0
  86. package/telegram-plugin/uat/port-allocator.ts +71 -0
  87. package/telegram-plugin/uat/scenarios/smoke-clerk-reply.test.ts +61 -0
  88. package/telegram-plugin/welcome-text.ts +44 -2
  89. package/bin/bridge-watchdog.sh +0 -967
@@ -19,7 +19,7 @@ When the user invokes `/switchroom` or asks to add, create, remove, reinstall, r
19
19
  | `/switchroom start <name>` | `switchroom agent start <name>` |
20
20
  | `/switchroom stop <name>` | `switchroom agent stop <name>` |
21
21
  | `/switchroom restart <name>` | `switchroom restart <name>` |
22
- | `/switchroom reinstall <name>` or "reinstall my agents" | `switchroom update` |
22
+ | `/switchroom reinstall <name>` or "reinstall my agents" | `switchroom apply && docker compose -p switchroom -f ~/.switchroom/compose/docker-compose.yml up -d` |
23
23
  | `/switchroom status` | `switchroom auth status` |
24
24
  | `/switchroom memory <query>` | `switchroom memory search "<query>"` |
25
25
  | `/switchroom memory <query> --agent <name>` | `switchroom memory search "<query>" --agent <name>` |
@@ -30,11 +30,11 @@ When the user invokes `/switchroom` or asks to add, create, remove, reinstall, r
30
30
 
31
31
  ### Add / create a new agent
32
32
 
33
- When the user says "add a new agent", "add an agent to my switchroom setup", or "create a new agent", ask for a name (if not provided) and run `switchroom agent create <name>`. This scaffolds the agent directory, installs systemd timers, and wires it into the config cascade.
33
+ When the user says "add a new agent", "add an agent to my switchroom setup", or "create a new agent", ask for a name (if not provided) and run `switchroom agent create <name>`. This scaffolds the agent directory and wires it into the config cascade. Follow up with `switchroom apply` and `docker compose -p switchroom -f ~/.switchroom/compose/docker-compose.yml up -d` to materialise the new agent + scheduler containers.
34
34
 
35
35
  ### Reinstall / reprovision agents
36
36
 
37
- "Reinstall my agents" is a fleet-level reprovisioning operation, **not** a fresh switchroom install. It means: pull the latest code, re-apply `switchroom.yaml`, and restart the agents. Run `switchroom update` for the full fleet. Ask the user to confirm before running if the scope is ambiguous.
37
+ "Reinstall my agents" is a fleet-level reprovisioning operation, **not** a fresh switchroom install. It means: pull the latest code, re-apply `switchroom.yaml`, and restart the agents. Run `switchroom apply` (scaffold + write compose), then `docker compose -p switchroom -f ~/.switchroom/compose/docker-compose.yml up -d` to bring the fleet back up. Ask the user to confirm before running if the scope is ambiguous.
38
38
 
39
39
  ### Anthropic accounts (one OAuth, many agents)
40
40
 
@@ -84,7 +84,7 @@ Switchroom commands:
84
84
  /switchroom topics List Telegram topics
85
85
 
86
86
  Fleet operations (run directly, not via /switchroom <sub>):
87
- switchroom update Pull latest + reconcile + restart everything
87
+ switchroom apply Reconcile + (re)write compose; bring up via `docker compose ... up -d`
88
88
  switchroom version Show versions + running agent health summary
89
89
  switchroom auth refresh-accounts Refresh OAuth tokens + fan out (cron entrypoint)
90
90
  ```
@@ -29,7 +29,7 @@ If that succeeds, parse the output and present the running agent list with full
29
29
 
30
30
  When you have real output, for each agent show:
31
31
  - **Name** and topic
32
- - **Status**: running / stopped / error (from systemd unit state)
32
+ - **Status**: running / stopped / error (from the docker-compose container state)
33
33
  - **Uptime**: how long it's been running (for running agents, always include the word "uptime" and the duration)
34
34
  - **Model**: which Claude model it's using
35
35
  - **Memory**: Hindsight collection name (if configured)
@@ -7,7 +7,7 @@ Pinned to commit: 5128e1865d670f5d6c9cef000e6dfc4e951fb5b9
7
7
  ## Why vendored
8
8
 
9
9
  Switchroom ships this skill as a built-in default so every agent gets it
10
- on scaffold (and on `switchroom update` for pre-existing agents).
10
+ on scaffold (and on `switchroom apply` for pre-existing agents).
11
11
  Vendoring keeps the skill content available offline and version-pinned.
12
12
 
13
13
  Opt out with:
@@ -7,7 +7,7 @@ Pinned to commit: 5128e1865d670f5d6c9cef000e6dfc4e951fb5b9
7
7
  ## Why vendored
8
8
 
9
9
  Switchroom ships this skill as a built-in default so every agent gets it
10
- on scaffold (and on `switchroom update` for pre-existing agents).
10
+ on scaffold (and on `switchroom apply` for pre-existing agents).
11
11
  Vendoring keeps the skill content available offline and version-pinned.
12
12
 
13
13
  Opt out with:
@@ -1,5 +1,11 @@
1
1
  import { describe, it, expect } from 'vitest'
2
- import { dispatchAdminCommand, parseCommandName, ADMIN_COMMAND_NAMES } from './index.js'
2
+ import {
3
+ dispatchAdminCommand,
4
+ parseCommandName,
5
+ parseCommandArg,
6
+ classifyAdminGate,
7
+ ADMIN_COMMAND_NAMES,
8
+ } from './index.js'
3
9
 
4
10
  // ─── parseCommandName ────────────────────────────────────────────────────────
5
11
 
@@ -147,3 +153,115 @@ describe('dispatchAdminCommand', () => {
147
153
  })
148
154
  })
149
155
  })
156
+
157
+ // ─── parseCommandArg ─────────────────────────────────────────────────────────
158
+
159
+ describe('parseCommandArg', () => {
160
+ it('returns empty string when no arg', () => {
161
+ expect(parseCommandArg('/restart')).toBe('')
162
+ })
163
+ it('returns empty string when only whitespace', () => {
164
+ expect(parseCommandArg('/restart ')).toBe('')
165
+ })
166
+ it('returns single arg', () => {
167
+ expect(parseCommandArg('/restart foo')).toBe('foo')
168
+ })
169
+ it('returns multi-word arg trimmed', () => {
170
+ expect(parseCommandArg('/restart foo bar ')).toBe('foo bar')
171
+ })
172
+ it('works with @botname suffix', () => {
173
+ expect(parseCommandArg('/restart@bot foo')).toBe('foo')
174
+ })
175
+ it('returns empty string for non-slash text', () => {
176
+ expect(parseCommandArg('hello world')).toBe('')
177
+ })
178
+ it('returns empty string for @botname suffix with no arg', () => {
179
+ expect(parseCommandArg('/restart@bot')).toBe('')
180
+ })
181
+ })
182
+
183
+ // ─── classifyAdminGate ───────────────────────────────────────────────────────
184
+
185
+ describe('classifyAdminGate', () => {
186
+ const me = 'clerk'
187
+
188
+ it('passes through plain text', () => {
189
+ expect(classifyAdminGate('hello there', me)).toEqual({ action: 'pass-through' })
190
+ })
191
+
192
+ it('passes through unknown slash commands', () => {
193
+ expect(classifyAdminGate('/whatever', me)).toEqual({ action: 'pass-through' })
194
+ })
195
+
196
+ it('passes through non-admin commands', () => {
197
+ expect(classifyAdminGate('/version', me)).toEqual({ action: 'pass-through' })
198
+ expect(classifyAdminGate('/auth', me)).toEqual({ action: 'pass-through' })
199
+ expect(classifyAdminGate('/new', me)).toEqual({ action: 'pass-through' })
200
+ })
201
+
202
+ describe('/restart', () => {
203
+ it('passes through with no arg (self-restart)', () => {
204
+ expect(classifyAdminGate('/restart', me)).toEqual({ action: 'pass-through' })
205
+ })
206
+ it('passes through with whitespace-only arg', () => {
207
+ expect(classifyAdminGate('/restart ', me)).toEqual({ action: 'pass-through' })
208
+ })
209
+ it('passes through when arg matches my agent name', () => {
210
+ expect(classifyAdminGate('/restart clerk', me)).toEqual({ action: 'pass-through' })
211
+ })
212
+ it('passes through when arg matches my agent name case-insensitively', () => {
213
+ expect(classifyAdminGate('/restart Clerk', me)).toEqual({ action: 'pass-through' })
214
+ expect(classifyAdminGate('/restart CLERK', me)).toEqual({ action: 'pass-through' })
215
+ expect(classifyAdminGate('/restart clerk', 'Clerk')).toEqual({ action: 'pass-through' })
216
+ })
217
+ it('passes through with @botname suffix and self target', () => {
218
+ expect(classifyAdminGate('/restart@switchroombot clerk', me)).toEqual({
219
+ action: 'pass-through',
220
+ })
221
+ })
222
+ it('blocks when targeting a different agent', () => {
223
+ expect(classifyAdminGate('/restart finn', me)).toEqual({
224
+ action: 'block',
225
+ reason: 'other-agent',
226
+ cmd: 'restart',
227
+ })
228
+ })
229
+ it('blocks when targeting `all`', () => {
230
+ expect(classifyAdminGate('/restart all', me)).toEqual({
231
+ action: 'block',
232
+ reason: 'other-agent',
233
+ cmd: 'restart',
234
+ })
235
+ })
236
+ })
237
+
238
+ describe('other admin commands', () => {
239
+ it('blocks /logs with admin-required reason', () => {
240
+ expect(classifyAdminGate('/logs', me)).toEqual({
241
+ action: 'block',
242
+ reason: 'admin-required',
243
+ cmd: 'logs',
244
+ })
245
+ })
246
+ it('blocks /grant with admin-required reason regardless of args', () => {
247
+ expect(classifyAdminGate('/grant clerk telegram', me)).toEqual({
248
+ action: 'block',
249
+ reason: 'admin-required',
250
+ cmd: 'grant',
251
+ })
252
+ })
253
+ it('blocks /agents, /update, /vault, /permissions', () => {
254
+ for (const c of ['agents', 'update', 'vault', 'permissions', 'stop', 'agentstart', 'reconcile', 'dangerous', 'memory', 'topics']) {
255
+ const r = classifyAdminGate(`/${c}`, me)
256
+ expect(r).toEqual({ action: 'block', reason: 'admin-required', cmd: c })
257
+ }
258
+ })
259
+ it('handles @botname suffix', () => {
260
+ expect(classifyAdminGate('/logs@switchroombot 50', me)).toEqual({
261
+ action: 'block',
262
+ reason: 'admin-required',
263
+ cmd: 'logs',
264
+ })
265
+ })
266
+ })
267
+ })
@@ -74,6 +74,77 @@ export function parseCommandName(text: string): string | null {
74
74
  return atIdx === -1 ? raw.toLowerCase() : raw.slice(0, atIdx).toLowerCase()
75
75
  }
76
76
 
77
+ /**
78
+ * Parse the argument portion of a slash command (everything after the command
79
+ * token, trimmed). Returns '' when no argument is present.
80
+ *
81
+ * parseCommandArg('/restart') === ''
82
+ * parseCommandArg('/restart ') === ''
83
+ * parseCommandArg('/restart foo') === 'foo'
84
+ * parseCommandArg('/restart@bot foo') === 'foo'
85
+ * parseCommandArg('/restart foo bar') === 'foo bar'
86
+ */
87
+ export function parseCommandArg(text: string): string {
88
+ if (!text.startsWith('/')) return ''
89
+ const spaceIdx = text.indexOf(' ')
90
+ if (spaceIdx === -1) return ''
91
+ return text.slice(spaceIdx + 1).trim()
92
+ }
93
+
94
+ /**
95
+ * Result of admin-gate classification used by the gateway middleware to decide
96
+ * how to handle an inbound slash command when admin gating is OFF.
97
+ *
98
+ * - `pass-through` — let the command fall through to the gateway's local
99
+ * bot.command() handler. Used for non-admin commands AND for `/restart`
100
+ * targeting the current agent (self-restart is always allowed).
101
+ * - `block` — the gateway should reply with an "admin required" warning and
102
+ * NOT forward the message to Claude.
103
+ *
104
+ * `reason` distinguishes the two block cases for the audit log:
105
+ * - `other-agent` — `/restart` aimed at a different agent
106
+ * - `admin-required` — any other ADMIN_COMMAND_NAMES verb
107
+ */
108
+ export type AdminGateDecision =
109
+ | { action: 'pass-through' }
110
+ | { action: 'block'; reason: 'other-agent' | 'admin-required'; cmd: string }
111
+
112
+ /**
113
+ * Decide what the gateway middleware should do with an inbound text message
114
+ * when SWITCHROOM_AGENT_ADMIN=false.
115
+ *
116
+ * Rules:
117
+ * - Non-slash text → pass-through.
118
+ * - Unknown / non-admin slash command → pass-through.
119
+ * - `/restart` with no arg, or arg matching `myAgentName` → pass-through
120
+ * (gateway's local bot.command('restart', …) handles self-restart).
121
+ * - `/restart <other-agent>` → block (reason='other-agent').
122
+ * - Any other ADMIN_COMMAND_NAMES verb → block (reason='admin-required').
123
+ *
124
+ * This function is pure and synchronous so it can be unit-tested without a
125
+ * Grammy context. The middleware in gateway.ts does the side effects.
126
+ */
127
+ export function classifyAdminGate(
128
+ text: string,
129
+ myAgentName: string,
130
+ ): AdminGateDecision {
131
+ if (!text.startsWith('/')) return { action: 'pass-through' }
132
+ const cmd = parseCommandName(text)
133
+ if (cmd === null || !ADMIN_COMMAND_NAMES.has(cmd)) {
134
+ return { action: 'pass-through' }
135
+ }
136
+ if (cmd === 'restart') {
137
+ const arg = parseCommandArg(text)
138
+ // Case-insensitive: assertSafeAgentName allows mixed case, so `/restart Clerk`
139
+ // must still self-target an agent named `clerk`.
140
+ if (arg === '' || arg.toLowerCase() === myAgentName.toLowerCase()) {
141
+ return { action: 'pass-through' }
142
+ }
143
+ return { action: 'block', reason: 'other-agent', cmd }
144
+ }
145
+ return { action: 'block', reason: 'admin-required', cmd }
146
+ }
147
+
77
148
  /**
78
149
  * Decide whether an inbound message should be intercepted as an admin command.
79
150
  *
@@ -1,3 +1,4 @@
1
+ // Ask-user uses pendingAskUser, not the approval kernel — see #769 for rationale.
1
2
  /**
2
3
  * Pure helpers for the `ask_user` MCP tool.
3
4
  *
@@ -0,0 +1,138 @@
1
+ /**
2
+ * Structured logger for the pinned progress-card lifecycle.
3
+ *
4
+ * Mirrors `pin-event-log.ts` in shape: an append-only JSON-line writer with
5
+ * a stable schema. Every meaningful card-driver state transition emits one
6
+ * line so operators can grep / replay days-old sessions and answer "did the
7
+ * card render? when did it finalize? was a sub-agent row ever attached?"
8
+ * without parsing free-form `progress-card:` traces.
9
+ *
10
+ * Output target:
11
+ * - If `$STATE_DIR` is set, `<STATE_DIR>/card-events.jsonl` (append-only).
12
+ * - Otherwise the line is forwarded to stderr (which the plugin-logger
13
+ * captures into `~/.switchroom/logs/telegram-plugin.log`).
14
+ *
15
+ * No rotation in this PR — the file is the durable audit trail and a
16
+ * follow-up can add retention once the size envelope is understood.
17
+ *
18
+ * Pure helper. No globals. The write target is injectable for tests.
19
+ */
20
+
21
+ import { appendFileSync, mkdirSync } from 'fs'
22
+ import { dirname, join } from 'path'
23
+
24
+ export type CardEventName =
25
+ | 'rendered'
26
+ | 'edited'
27
+ | 'finalized'
28
+ | 'suppressed'
29
+ | 'deferred'
30
+ | 'force-completed'
31
+ | 'deleted'
32
+
33
+ export interface CardEvent {
34
+ /** Unix-ms wall clock. */
35
+ ts: number
36
+ /** Agent slug (e.g. SWITCHROOM_AGENT_NAME). Empty string if unknown. */
37
+ agent: string
38
+ /** Telegram chat id as string (matches the rest of the plugin). */
39
+ chatId: string
40
+ /** Driver-assigned per-turn key (chatId:threadId:seq). */
41
+ turnKey: string
42
+ /** The pinned card message_id once known. Optional pre-render. */
43
+ cardMessageId?: number
44
+ event: CardEventName
45
+ /**
46
+ * Free-text qualifier — e.g. the reason a turn was deferred
47
+ * ("in-flight-sub-agents"), the API class for a 4xx abandon, the
48
+ * synthetic kind for a force-complete. Single-line, ≤200 chars.
49
+ */
50
+ reason?: string
51
+ /** sha1-12 of the rendered HTML, when relevant. Lets us spot edit storms. */
52
+ htmlHash?: string
53
+ /** Sub-agent ids attached to the card at the time of the event. */
54
+ subagents?: string[]
55
+ /** Elapsed ms since turn start, when the call site has it cheaply. */
56
+ durationMs?: number
57
+ }
58
+
59
+ export type CardEventWriter = (line: string) => void
60
+
61
+ let resolvedPath: string | null | undefined
62
+
63
+ /**
64
+ * Compute the target path once and memoize. `$STATE_DIR` set → write to
65
+ * `<STATE_DIR>/card-events.jsonl`; otherwise return null (the default
66
+ * writer falls back to stderr in that case).
67
+ *
68
+ * Exposed so tests can assert resolution without actually writing.
69
+ */
70
+ export function resolveCardEventPath(env: NodeJS.ProcessEnv = process.env): string | null {
71
+ const dir = env.STATE_DIR
72
+ if (!dir || dir.length === 0) return null
73
+ return join(dir, 'card-events.jsonl')
74
+ }
75
+
76
+ /**
77
+ * Reset the memoized path. Tests only.
78
+ */
79
+ export function _resetForTests(): void {
80
+ resolvedPath = undefined
81
+ }
82
+
83
+ const defaultWriter: CardEventWriter = (line) => {
84
+ if (resolvedPath === undefined) {
85
+ resolvedPath = resolveCardEventPath()
86
+ }
87
+ const target = resolvedPath
88
+ if (target == null) {
89
+ // Fall back to stderr (the plugin-logger captures stderr into the
90
+ // freeform log). Prefix lets operators grep just like pin-event:.
91
+ try {
92
+ process.stderr.write(`card-event: ${line}`)
93
+ } catch {
94
+ // Never throw from a logger.
95
+ }
96
+ return
97
+ }
98
+ try {
99
+ mkdirSync(dirname(target), { recursive: true })
100
+ appendFileSync(target, line)
101
+ } catch {
102
+ // Best-effort: if the structured sink fails, surface to stderr so the
103
+ // event is at least in the freeform log.
104
+ try {
105
+ process.stderr.write(`card-event: ${line}`)
106
+ } catch {
107
+ // ignore
108
+ }
109
+ }
110
+ }
111
+
112
+ export function logCardEvent(event: CardEvent, write: CardEventWriter = defaultWriter): void {
113
+ // Drop undefined fields so the JSON output stays compact and grep-friendly.
114
+ const cleaned: Record<string, unknown> = {}
115
+ for (const [k, v] of Object.entries(event)) {
116
+ if (v !== undefined) cleaned[k] = v
117
+ }
118
+ const payload = JSON.stringify(cleaned)
119
+ write(`${payload}\n`)
120
+ }
121
+
122
+ /**
123
+ * Convenience constructor — fills `ts` automatically. Most call sites only
124
+ * have agent / chatId / turnKey / event / a few qualifiers; this keeps the
125
+ * boilerplate low.
126
+ */
127
+ export function emitCardEvent(
128
+ partial: Omit<CardEvent, 'ts'> & { ts?: number },
129
+ write: CardEventWriter = defaultWriter,
130
+ ): void {
131
+ logCardEvent(
132
+ {
133
+ ts: partial.ts ?? Date.now(),
134
+ ...partial,
135
+ } as CardEvent,
136
+ write,
137
+ )
138
+ }