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.
- package/README.md +142 -121
- package/bin/autoaccept.exp +29 -6
- package/dist/agent-scheduler/index.js +12261 -0
- package/dist/cli/autoaccept-poll.js +10 -0
- package/dist/cli/switchroom.js +27250 -25324
- package/dist/vault/approvals/kernel-server.js +12709 -0
- package/dist/vault/broker/server.js +15724 -0
- package/package.json +4 -3
- package/profiles/_base/start.sh.hbs +133 -0
- package/profiles/_shared/telegram-style.md.hbs +3 -3
- package/profiles/default/CLAUDE.md +3 -3
- package/profiles/default/CLAUDE.md.hbs +2 -2
- package/profiles/default/workspace/CLAUDE.md.hbs +9 -0
- package/skills/docx/VENDORED.md +1 -1
- package/skills/mcp-builder/VENDORED.md +1 -1
- package/skills/pdf/VENDORED.md +1 -1
- package/skills/pptx/VENDORED.md +1 -1
- package/skills/skill-creator/VENDORED.md +1 -1
- package/skills/switchroom-architecture/SKILL.md +8 -7
- package/skills/switchroom-cli/SKILL.md +23 -15
- package/skills/switchroom-health/SKILL.md +7 -7
- package/skills/switchroom-install/SKILL.md +36 -39
- package/skills/switchroom-manage/SKILL.md +4 -4
- package/skills/switchroom-status/SKILL.md +1 -1
- package/skills/webapp-testing/VENDORED.md +1 -1
- package/skills/xlsx/VENDORED.md +1 -1
- package/telegram-plugin/admin-commands/dispatch.test.ts +119 -1
- package/telegram-plugin/admin-commands/index.ts +71 -0
- package/telegram-plugin/ask-user.ts +1 -0
- package/telegram-plugin/card-event-log.ts +138 -0
- package/telegram-plugin/dist/bridge/bridge.js +178 -31
- package/telegram-plugin/dist/foreman/foreman.js +6875 -6526
- package/telegram-plugin/dist/gateway/gateway.js +13862 -11834
- package/telegram-plugin/dist/server.js +202 -40
- package/telegram-plugin/fleet-state.ts +25 -10
- package/telegram-plugin/foreman/foreman.ts +38 -3
- package/telegram-plugin/gateway/approval-callback.ts +126 -0
- package/telegram-plugin/gateway/approval-card.test.ts +90 -0
- package/telegram-plugin/gateway/approval-card.ts +127 -0
- package/telegram-plugin/gateway/approvals-commands.ts +126 -0
- package/telegram-plugin/gateway/boot-card.ts +31 -6
- package/telegram-plugin/gateway/boot-probes.ts +503 -72
- package/telegram-plugin/gateway/gateway.ts +822 -94
- package/telegram-plugin/gateway/ipc-protocol.ts +34 -1
- package/telegram-plugin/gateway/ipc-server.ts +35 -0
- package/telegram-plugin/gateway/startup-mutex.ts +110 -2
- package/telegram-plugin/hooks/hooks.json +19 -0
- package/telegram-plugin/hooks/tool-label-pretool.mjs +216 -0
- package/telegram-plugin/hooks/tool-label-stop.mjs +63 -0
- package/telegram-plugin/package.json +4 -1
- package/telegram-plugin/plugin-logger.ts +20 -1
- package/telegram-plugin/progress-card-driver.ts +202 -13
- package/telegram-plugin/progress-card.ts +2 -2
- package/telegram-plugin/quota-check.ts +1 -0
- package/telegram-plugin/registry/subagents-schema.ts +37 -0
- package/telegram-plugin/registry/subagents.test.ts +64 -0
- package/telegram-plugin/session-tail.ts +58 -5
- package/telegram-plugin/shared/bot-runtime.ts +48 -2
- package/telegram-plugin/subagent-watcher.ts +139 -7
- package/telegram-plugin/tests/_progress-card-harness.ts +4 -0
- package/telegram-plugin/tests/bg-agent-progress-card-757.test.ts +201 -0
- package/telegram-plugin/tests/boot-card-probe-target.test.ts +10 -34
- package/telegram-plugin/tests/boot-card-render.test.ts +6 -5
- package/telegram-plugin/tests/boot-probes.test.ts +558 -0
- package/telegram-plugin/tests/card-event-log.test.ts +145 -0
- package/telegram-plugin/tests/gateway-startup-mutex.test.ts +102 -0
- package/telegram-plugin/tests/ipc-server-validate-inject-inbound.test.ts +134 -0
- package/telegram-plugin/tests/progress-card-delay-842.test.ts +160 -0
- package/telegram-plugin/tests/quota-check.test.ts +37 -1
- package/telegram-plugin/tests/subagent-registry-bugs.test.ts +5 -0
- package/telegram-plugin/tests/subagent-watcher-stall-notification.test.ts +104 -1
- package/telegram-plugin/tests/subagent-watcher.test.ts +5 -0
- package/telegram-plugin/tests/tool-label-sidecar.test.ts +114 -0
- package/telegram-plugin/tests/two-zone-bg-done-when-all-terminal.test.ts +5 -3
- package/telegram-plugin/tests/two-zone-card-header-phases.test.ts +10 -0
- package/telegram-plugin/tests/two-zone-snapshot-extras.test.ts +58 -14
- package/telegram-plugin/tests/welcome-text.test.ts +57 -0
- package/telegram-plugin/tool-label-sidecar.ts +140 -0
- package/telegram-plugin/tool-labels.ts +55 -0
- package/telegram-plugin/two-zone-card.ts +27 -7
- package/telegram-plugin/uat/SETUP.md +160 -0
- package/telegram-plugin/uat/assertions.ts +140 -0
- package/telegram-plugin/uat/driver.ts +174 -0
- package/telegram-plugin/uat/harness.ts +161 -0
- package/telegram-plugin/uat/login.ts +134 -0
- package/telegram-plugin/uat/port-allocator.ts +71 -0
- package/telegram-plugin/uat/scenarios/smoke-clerk-reply.test.ts +61 -0
- package/telegram-plugin/welcome-text.ts +44 -2
- 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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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:
|
package/skills/xlsx/VENDORED.md
CHANGED
|
@@ -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
|
|
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 {
|
|
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
|
*
|
|
@@ -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
|
+
}
|