switchroom 0.16.38 → 0.16.47
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/dist/agent-scheduler/index.js +8 -2
- package/dist/auth-broker/index.js +7 -1
- package/dist/cli/notion-write-pretool.mjs +7 -1
- package/dist/cli/switchroom.js +1259 -375
- package/dist/cli/ui/index.html +877 -214
- package/dist/host-control/main.js +116 -84
- package/dist/vault/approvals/kernel-server.js +8 -2
- package/dist/vault/broker/server.js +8 -2
- package/examples/minimal.yaml +1 -1
- package/examples/switchroom.yaml +1 -1
- package/package.json +2 -2
- package/profiles/_shared/reply-discipline.md.hbs +9 -0
- package/skills/switchroom-status/SKILL.md +1 -1
- package/telegram-plugin/bridge/bridge.ts +2 -1
- package/telegram-plugin/card-format.ts +7 -1
- package/telegram-plugin/dist/bridge/bridge.js +20 -2
- package/telegram-plugin/dist/gateway/gateway.js +2197 -964
- package/telegram-plugin/dist/server.js +20 -2
- package/telegram-plugin/format.ts +305 -31
- package/telegram-plugin/gateway/gateway.ts +310 -70
- package/telegram-plugin/gateway/model-command.ts +173 -19
- package/telegram-plugin/hooks/tool-label-pretool.d.mts +12 -0
- package/telegram-plugin/hooks/tool-label-pretool.mjs +54 -16
- package/telegram-plugin/package.json +1 -1
- package/telegram-plugin/session-tail.ts +47 -1
- package/telegram-plugin/stream-reply-handler.ts +19 -1
- package/telegram-plugin/tests/always-allow-grant.test.ts +34 -2
- package/telegram-plugin/tests/card-format.test.ts +28 -0
- package/telegram-plugin/tests/claude-code-event-contract.test.ts +151 -0
- package/telegram-plugin/tests/format-consistency.test.ts +223 -0
- package/telegram-plugin/tests/formatting-parse-regression.test.ts +272 -0
- package/telegram-plugin/tests/formatting-torture-set.ts +218 -0
- package/telegram-plugin/tests/model-command.test.ts +213 -47
- package/telegram-plugin/tests/paragraph-normalizer.test.ts +203 -21
- package/telegram-plugin/tests/rich-markdown-oracle.ts +469 -0
- package/telegram-plugin/tests/session-tail.test.ts +91 -0
- package/telegram-plugin/tests/status-vocabulary-unification.test.ts +125 -0
- package/telegram-plugin/tests/telegram-format.test.ts +33 -8
- package/telegram-plugin/tests/text-voice-scrub.test.ts +142 -22
- package/telegram-plugin/tests/tool-activity-summary.test.ts +6 -1
- package/telegram-plugin/tests/tts-normalize.test.ts +242 -0
- package/telegram-plugin/tests/vault-request-access-tool.test.ts +24 -0
- package/telegram-plugin/tests/vault-request-access-unlock-resume.test.ts +46 -0
- package/telegram-plugin/tests/voice-ondemand.test.ts +99 -2
- package/telegram-plugin/tests/voice-presynth.test.ts +437 -0
- package/telegram-plugin/tests/worker-activity-feed.test.ts +49 -0
- package/telegram-plugin/text-voice-scrub.ts +68 -18
- package/telegram-plugin/tool-activity-summary.ts +20 -108
- package/telegram-plugin/tts-normalize.ts +377 -0
- package/telegram-plugin/uat/driver.ts +472 -22
- package/telegram-plugin/uat/scenarios/jtbd-model-litellm-sr-dm.test.ts +34 -14
- package/telegram-plugin/uat/scenarios/jtbd-multipart-render-dm.test.ts +169 -0
- package/telegram-plugin/uat/scenarios/jtbd-narration-intent-dm.test.ts +134 -0
- package/telegram-plugin/uat/scenarios/jtbd-rich-formatting-render-dm.test.ts +254 -0
- package/telegram-plugin/uat/scenarios/jtbd-status-phase-transitions-dm.test.ts +109 -0
- package/telegram-plugin/uat/uat-driver.test.ts +297 -0
- package/telegram-plugin/voice-ondemand.ts +161 -10
- package/telegram-plugin/voice-presynth.ts +242 -0
- package/telegram-plugin/worker-activity-feed.ts +9 -1
|
@@ -308,6 +308,32 @@ export const MODEL_CALLBACK_REFRESH = 'mdl:r'
|
|
|
308
308
|
export const MODEL_CALLBACK_SR = 'mdl:sr:'
|
|
309
309
|
/** Callback for section-header rows — shows an informational toast, no action. */
|
|
310
310
|
export const MODEL_CALLBACK_HEADER = 'mdl:h'
|
|
311
|
+
/**
|
|
312
|
+
* Callback prefix for Claude aliases that the CLI picker doesn't render but
|
|
313
|
+
* the CLI resolves natively (e.g. `fable`). Carries the alias verbatim; its
|
|
314
|
+
* handler INJECTS `/model <alias>` — the same mechanism MODEL_CALLBACK_SR
|
|
315
|
+
* uses — because the cursor-nav select path can only pick rows claude's own
|
|
316
|
+
* picker actually renders.
|
|
317
|
+
*/
|
|
318
|
+
export const MODEL_CALLBACK_ALIAS = 'mdl:alias:'
|
|
319
|
+
/** Callback: open the nested "External models" keyboard page. */
|
|
320
|
+
export const MODEL_CALLBACK_PAGE_EXTERNAL = 'mdl:page:ext'
|
|
321
|
+
/** Callback: return from the External page to the main keyboard page. */
|
|
322
|
+
export const MODEL_CALLBACK_PAGE_MAIN = 'mdl:page:main'
|
|
323
|
+
|
|
324
|
+
/** Which keyboard page the model menu is currently rendering. */
|
|
325
|
+
export type ModelMenuPage = 'main' | 'external'
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* Static Claude aliases appended to the scraped Claude group. The claude CLI's
|
|
329
|
+
* own `/model` picker (deps.discover) does NOT list `fable`, but the CLI
|
|
330
|
+
* resolves the alias natively, so we render it as an extra button that selects
|
|
331
|
+
* by injecting `/model fable` (MODEL_CALLBACK_ALIAS). Extend this list to
|
|
332
|
+
* surface further CLI-resolvable aliases the picker omits.
|
|
333
|
+
*/
|
|
334
|
+
export const EXTRA_CLAUDE_ALIASES: ReadonlyArray<{ alias: string; label: string }> = [
|
|
335
|
+
{ alias: 'fable', label: 'Fable' },
|
|
336
|
+
]
|
|
311
337
|
|
|
312
338
|
/**
|
|
313
339
|
* Friendly display names for sr-* synthetic model names. An sr-* model in
|
|
@@ -394,14 +420,42 @@ function headerRow(label: string): ModelMenuKeyboardButton[] {
|
|
|
394
420
|
return [{ text: label, callback_data: MODEL_CALLBACK_HEADER }]
|
|
395
421
|
}
|
|
396
422
|
|
|
397
|
-
|
|
423
|
+
/**
|
|
424
|
+
* The external (🌐 non-Anthropic) model list, sourced from the static
|
|
425
|
+
* SR_MODEL_ALIASES values UNION-ed with any live discoverSrModels() results,
|
|
426
|
+
* deduped and sorted.
|
|
427
|
+
*
|
|
428
|
+
* Why the static union: discoverSrModels() reads LiteLLM's /model/info, which
|
|
429
|
+
* requires ANTHROPIC_CUSTOM_HEADERS (a litellm key) to be set on the gateway
|
|
430
|
+
* process. switchroom never sets that env on the gateway, so in production
|
|
431
|
+
* discoverSrModels() always returns [] and the external group was silently
|
|
432
|
+
* empty. The six SR_MODEL_ALIASES targets are the sr-* names the litellm
|
|
433
|
+
* config actually exposes, so seeding from them makes the group reliable
|
|
434
|
+
* without the missing env — while still merging any live results on hosts
|
|
435
|
+
* that do configure discovery.
|
|
436
|
+
*
|
|
437
|
+
* Subscription-honest: ONLY the curated sr-* aliases surface as buttons. Raw
|
|
438
|
+
* gpt-4o / openrouter/* dupes / voyage-* embeddings never do.
|
|
439
|
+
*/
|
|
440
|
+
export function externalModelNames(discovered: string[]): string[] {
|
|
441
|
+
const set = new Set<string>(Object.values(SR_MODEL_ALIASES))
|
|
442
|
+
for (const n of discovered) {
|
|
443
|
+
if (isSrModel(n)) set.add(n)
|
|
444
|
+
}
|
|
445
|
+
return [...set].sort()
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
/**
|
|
449
|
+
* Main keyboard page: scraped Claude buttons + static Fable alias, then (only
|
|
450
|
+
* when the external list is non-empty) a single "🌐 External models ▸" row that
|
|
451
|
+
* opens the nested page, then Refresh.
|
|
452
|
+
*/
|
|
453
|
+
function mainPageKeyboard(
|
|
398
454
|
claudeOptions: ModelPickerOption[],
|
|
399
|
-
|
|
455
|
+
hasExternal: boolean,
|
|
400
456
|
): ModelMenuKeyboardButton[][] {
|
|
401
|
-
const hasBothGroups = claudeOptions.length > 0 && srOptions.length > 0
|
|
402
457
|
const rows: ModelMenuKeyboardButton[][] = []
|
|
403
458
|
|
|
404
|
-
if (hasBothGroups) rows.push(headerRow('── Claude (Max / Pro subscription) ──'))
|
|
405
459
|
for (const o of claudeOptions) {
|
|
406
460
|
rows.push([{
|
|
407
461
|
text: o.current ? `✅ ${o.label}` : o.label,
|
|
@@ -409,23 +463,44 @@ function menuKeyboard(
|
|
|
409
463
|
}])
|
|
410
464
|
}
|
|
411
465
|
|
|
412
|
-
//
|
|
413
|
-
//
|
|
414
|
-
//
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
466
|
+
// Static Claude aliases the CLI picker omits (e.g. Fable). Deduped: if the
|
|
467
|
+
// scraped Claude options already include a matching row, don't render the
|
|
468
|
+
// static one too.
|
|
469
|
+
for (const { alias, label } of EXTRA_CLAUDE_ALIASES) {
|
|
470
|
+
const already = claudeOptions.some(
|
|
471
|
+
(o) => o.label.toLowerCase() === label.toLowerCase() ||
|
|
472
|
+
o.label.toLowerCase() === alias.toLowerCase(),
|
|
473
|
+
)
|
|
474
|
+
if (already) continue
|
|
475
|
+
rows.push([{ text: label, callback_data: `${MODEL_CALLBACK_ALIAS}${alias}` }])
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
if (hasExternal) {
|
|
479
|
+
rows.push([{ text: '🌐 External models ▸', callback_data: MODEL_CALLBACK_PAGE_EXTERNAL }])
|
|
423
480
|
}
|
|
424
481
|
|
|
425
482
|
rows.push([{ text: '🔄 Refresh', callback_data: MODEL_CALLBACK_REFRESH }])
|
|
426
483
|
return rows
|
|
427
484
|
}
|
|
428
485
|
|
|
486
|
+
/**
|
|
487
|
+
* External keyboard page: a labelled header, one 🌐 button per external model
|
|
488
|
+
* (reusing the existing MODEL_CALLBACK_SR select handler), then Back + Refresh.
|
|
489
|
+
*/
|
|
490
|
+
function externalPageKeyboard(externalNames: string[]): ModelMenuKeyboardButton[][] {
|
|
491
|
+
const rows: ModelMenuKeyboardButton[][] = []
|
|
492
|
+
rows.push(headerRow('── External (billed separately) ──'))
|
|
493
|
+
for (const name of externalNames) {
|
|
494
|
+
rows.push([{
|
|
495
|
+
text: `🌐 ${srFriendlyLabel(name)}`,
|
|
496
|
+
callback_data: `${MODEL_CALLBACK_SR}${name}`,
|
|
497
|
+
}])
|
|
498
|
+
}
|
|
499
|
+
rows.push([{ text: '◂ Back', callback_data: MODEL_CALLBACK_PAGE_MAIN }])
|
|
500
|
+
rows.push([{ text: '🔄 Refresh', callback_data: MODEL_CALLBACK_REFRESH }])
|
|
501
|
+
return rows
|
|
502
|
+
}
|
|
503
|
+
|
|
429
504
|
/**
|
|
430
505
|
* Build the `/model` dashboard: live model + quota brief + tap menu.
|
|
431
506
|
* Returns a keyboard-less fallback (v1-shaped static text) when the
|
|
@@ -433,6 +508,7 @@ function menuKeyboard(
|
|
|
433
508
|
*/
|
|
434
509
|
export async function buildModelMenu(
|
|
435
510
|
deps: ModelMenuDeps & ModelCommandDeps,
|
|
511
|
+
page: ModelMenuPage = 'main',
|
|
436
512
|
): Promise<ModelMenuReply> {
|
|
437
513
|
if (deps.isBusy()) return busyReply(deps)
|
|
438
514
|
|
|
@@ -460,7 +536,26 @@ export async function buildModelMenu(
|
|
|
460
536
|
// sr-* models come from LiteLLM (/model/info via discoverSrModels), not the
|
|
461
537
|
// claude picker — the CLI only knows Anthropic models.
|
|
462
538
|
const { claude: claudeOptions } = classifyDiscoveredOptions(discovered.options)
|
|
463
|
-
const
|
|
539
|
+
const externalNames = externalModelNames(srNames)
|
|
540
|
+
|
|
541
|
+
// External page: a focused list of the 🌐 (billed-separately) models with a
|
|
542
|
+
// Back button. It never switches the model itself — the page callbacks just
|
|
543
|
+
// re-render with the other page's keyboard.
|
|
544
|
+
if (page === 'external') {
|
|
545
|
+
const lines: string[] = [`**Model — ${deps.escapeHtml(deps.getAgentName())}** · 🌐 External`]
|
|
546
|
+
lines.push(
|
|
547
|
+
'',
|
|
548
|
+
'These models are **billed separately** via OpenRouter — they do NOT use your Claude Max/Pro subscription. Tap one to switch the **live session**:',
|
|
549
|
+
PERSIST_NOTE,
|
|
550
|
+
)
|
|
551
|
+
return { text: lines.join('\n'), html: true, keyboard: externalPageKeyboard(externalNames) }
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
// claude's ✔ marks the DEFAULT FOR NEW SESSIONS, which is a different axis
|
|
555
|
+
// from the model the agent is running right now (set via --model at launch
|
|
556
|
+
// or a prior session switch). Labelling the ✔ row "Now:" was misleading —
|
|
557
|
+
// it could read "Opus 4.8" while the live session is on Fable. Call it what
|
|
558
|
+
// it is, and tell the operator a switch applies to the live session.
|
|
464
559
|
const current = claudeOptions.find((o) => o.current)
|
|
465
560
|
const lines: string[] = [`**Model — ${deps.escapeHtml(deps.getAgentName())}**`]
|
|
466
561
|
if (discovered.dismissFailed) {
|
|
@@ -474,12 +569,16 @@ export async function buildModelMenu(
|
|
|
474
569
|
}
|
|
475
570
|
if (quota) lines.push(`Quota: ${deps.escapeHtml(quota)}`)
|
|
476
571
|
lines.push('', 'Tap a model to switch the **live session**:')
|
|
477
|
-
if (
|
|
478
|
-
lines.push('Claude models use your Max/Pro subscription. 🌐 models
|
|
572
|
+
if (externalNames.length > 0) {
|
|
573
|
+
lines.push('Claude models use your Max/Pro subscription. Tap 🌐 External models for models billed separately via OpenRouter.')
|
|
479
574
|
}
|
|
480
575
|
lines.push(PERSIST_NOTE)
|
|
481
576
|
|
|
482
|
-
return {
|
|
577
|
+
return {
|
|
578
|
+
text: lines.join('\n'),
|
|
579
|
+
html: true,
|
|
580
|
+
keyboard: mainPageKeyboard(claudeOptions, externalNames.length > 0),
|
|
581
|
+
}
|
|
483
582
|
}
|
|
484
583
|
|
|
485
584
|
export interface ModelCallbackOutcome {
|
|
@@ -519,6 +618,61 @@ export async function handleModelMenuCallback(
|
|
|
519
618
|
return { answer: 'Refreshed', reply: await buildModelMenu(deps) }
|
|
520
619
|
}
|
|
521
620
|
|
|
621
|
+
// Page navigation — these DO NOT switch the model. They just re-render the
|
|
622
|
+
// menu with the other page's keyboard + body text (mirrors the REFRESH shape).
|
|
623
|
+
if (data === MODEL_CALLBACK_PAGE_EXTERNAL) {
|
|
624
|
+
return { answer: 'External models', reply: await buildModelMenu(deps, 'external') }
|
|
625
|
+
}
|
|
626
|
+
if (data === MODEL_CALLBACK_PAGE_MAIN) {
|
|
627
|
+
return { answer: 'Back', reply: await buildModelMenu(deps, 'main') }
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
// Claude-alias tap (e.g. Fable): the CLI resolves the alias but its picker
|
|
631
|
+
// doesn't render it, so select by injecting `/model <alias>` — same path as
|
|
632
|
+
// the sr-* handler below, no cursor-nav.
|
|
633
|
+
if (data.startsWith(MODEL_CALLBACK_ALIAS)) {
|
|
634
|
+
const alias = data.slice(MODEL_CALLBACK_ALIAS.length)
|
|
635
|
+
if (!isValidModelArg(alias)) {
|
|
636
|
+
return { answer: 'Invalid model name', reply: await buildModelMenu(deps) }
|
|
637
|
+
}
|
|
638
|
+
if (deps.isBusy()) {
|
|
639
|
+
return {
|
|
640
|
+
answer: '⏳ Agent is mid-turn — tap again when it’s idle',
|
|
641
|
+
reply: busyReply(deps),
|
|
642
|
+
toastOnly: true,
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
let aliasResult: InjectResult
|
|
646
|
+
try {
|
|
647
|
+
aliasResult = await deps.inject(deps.getAgentName(), `/model ${alias}`)
|
|
648
|
+
} catch (err) {
|
|
649
|
+
const msg = err instanceof Error ? err.message : String(err)
|
|
650
|
+
return {
|
|
651
|
+
answer: 'Switch failed',
|
|
652
|
+
reply: await menuWithBanner(deps, `❌ Switch to **${deps.escapeHtml(alias)}** failed: ${deps.escapeHtml(msg)}`),
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
if (aliasResult.outcome === 'ok') {
|
|
656
|
+
const confirmation =
|
|
657
|
+
aliasResult.output
|
|
658
|
+
.split('\n')
|
|
659
|
+
.map((l) => l.trim())
|
|
660
|
+
.find((l) => /set model|switched/i.test(l)) ?? `Switched to ${alias} (session)`
|
|
661
|
+
return {
|
|
662
|
+
answer: confirmation,
|
|
663
|
+
reply: await menuWithBannerStatic(deps, `✅ ${deps.escapeHtml(confirmation)}`),
|
|
664
|
+
selectedModel: sessionModelFromConfirmation(confirmation) ?? alias,
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
return {
|
|
668
|
+
answer: 'Switch failed',
|
|
669
|
+
reply: await menuWithBanner(
|
|
670
|
+
deps,
|
|
671
|
+
`❌ Switch to **${deps.escapeHtml(alias)}** failed — agent may be mid-turn`,
|
|
672
|
+
),
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
|
|
522
676
|
if (data === MODEL_CALLBACK_HEADER) {
|
|
523
677
|
// Section-header row — the gateway handles this with a direct answerCallbackQuery
|
|
524
678
|
// before calling this function, so this branch is dead in practice. Guard
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Type declarations for the PreToolUse hook module so TypeScript surfaces
|
|
3
|
+
* (tool-activity-summary.ts's `describeToolUse`) can delegate to
|
|
4
|
+
* `computeLabel` — the ONE status-vocabulary composer — without tsc
|
|
5
|
+
* resolution errors. The runtime module is plain ESM (hooks are executed
|
|
6
|
+
* directly by Claude Code, so they cannot be TS); main() is guarded behind
|
|
7
|
+
* an is-main check, making the import side-effect-free.
|
|
8
|
+
*/
|
|
9
|
+
export function computeLabel(
|
|
10
|
+
toolName: string,
|
|
11
|
+
input: Record<string, unknown> | undefined | null,
|
|
12
|
+
): string | null
|
|
@@ -76,6 +76,16 @@ function safeBasename(p) {
|
|
|
76
76
|
}
|
|
77
77
|
}
|
|
78
78
|
|
|
79
|
+
/** Hostname only, `www.` stripped — the WebFetch label form ("Reading example.com"). */
|
|
80
|
+
function urlHost(u) {
|
|
81
|
+
if (!u || typeof u !== 'string') return ''
|
|
82
|
+
try {
|
|
83
|
+
return new URL(u).hostname.replace(/^www\./, '')
|
|
84
|
+
} catch {
|
|
85
|
+
return u
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
79
89
|
function urlHostPath(u) {
|
|
80
90
|
if (!u || typeof u !== 'string') return ''
|
|
81
91
|
try {
|
|
@@ -115,26 +125,53 @@ export function computeLabel(toolName, input) {
|
|
|
115
125
|
}
|
|
116
126
|
|
|
117
127
|
// Built-in rule table.
|
|
128
|
+
//
|
|
129
|
+
// THE single status vocabulary. This function is the ONE composer for the
|
|
130
|
+
// per-tool activity wording on EVERY surface: the real-time sidecar drives
|
|
131
|
+
// the live feed from here, and the gateway/watcher flush paths delegate via
|
|
132
|
+
// `describeToolUse` (tool-activity-summary.ts). Do NOT fork a second
|
|
133
|
+
// wording table — status-vocabulary-unification.test.ts pins the
|
|
134
|
+
// delegation, so drift fails CI.
|
|
118
135
|
switch (toolName) {
|
|
119
|
-
case 'Read':
|
|
120
|
-
|
|
136
|
+
case 'Read': {
|
|
137
|
+
const f = clip(safeBasename(i.file_path))
|
|
138
|
+
return f ? `Reading ${f}` : 'Reading a file'
|
|
139
|
+
}
|
|
121
140
|
case 'Edit':
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
return `
|
|
141
|
+
case 'MultiEdit': {
|
|
142
|
+
const f = clip(safeBasename(i.file_path))
|
|
143
|
+
return f ? `Editing ${f}` : 'Editing a file'
|
|
144
|
+
}
|
|
145
|
+
case 'Write': {
|
|
146
|
+
const f = clip(safeBasename(i.file_path))
|
|
147
|
+
return f ? `Writing ${f}` : 'Writing a file'
|
|
148
|
+
}
|
|
125
149
|
case 'Grep': {
|
|
126
|
-
const path = i.path ? clip(asText(i.path), 40) : '.'
|
|
127
150
|
const pat = clip(asText(i.pattern), 40)
|
|
128
|
-
return
|
|
151
|
+
if (!pat) return 'Searching files'
|
|
152
|
+
const path = i.path ? clip(asText(i.path), 40) : ''
|
|
153
|
+
return path ? `Searching ${path} for ${pat}` : `Searching for ${pat}`
|
|
154
|
+
}
|
|
155
|
+
case 'Glob': {
|
|
156
|
+
const pat = clip(asText(i.pattern), 60)
|
|
157
|
+
return pat ? `Finding files matching ${pat}` : 'Searching files'
|
|
158
|
+
}
|
|
159
|
+
case 'WebFetch': {
|
|
160
|
+
const h = clip(urlHost(i.url), 60)
|
|
161
|
+
return h ? `Reading ${h}` : 'Reading a web page'
|
|
129
162
|
}
|
|
130
|
-
case '
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
case '
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
163
|
+
case 'WebSearch': {
|
|
164
|
+
const q = clip(asText(i.query), 60)
|
|
165
|
+
return q ? `Searching the web for ${q}` : 'Searching the web'
|
|
166
|
+
}
|
|
167
|
+
case 'NotebookEdit': {
|
|
168
|
+
const f = clip(safeBasename(i.notebook_path))
|
|
169
|
+
return f ? `Editing ${f}` : 'Editing a notebook'
|
|
170
|
+
}
|
|
171
|
+
case 'TaskCreate':
|
|
172
|
+
case 'TaskUpdate':
|
|
173
|
+
case 'TaskList':
|
|
174
|
+
return 'Updating the plan'
|
|
138
175
|
case 'BashOutput':
|
|
139
176
|
return 'Reading background output'
|
|
140
177
|
case 'KillBash':
|
|
@@ -183,7 +220,8 @@ export function computeLabel(toolName, input) {
|
|
|
183
220
|
case 'mcp__hindsight__reflect':
|
|
184
221
|
return 'Searching memory'
|
|
185
222
|
case 'mcp__hindsight__retain':
|
|
186
|
-
|
|
223
|
+
case 'mcp__hindsight__update_memory':
|
|
224
|
+
return 'Saving to memory'
|
|
187
225
|
// Explicit suppressions — return null so we don't emit a sidecar line.
|
|
188
226
|
case 'mcp__hindsight__sync_retain':
|
|
189
227
|
return null
|
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
"dependencies": {
|
|
28
28
|
"@grammyjs/runner": "^2.0.3",
|
|
29
29
|
"@modelcontextprotocol/sdk": "^1.0.0",
|
|
30
|
-
"@mtcute/node": "^0.
|
|
30
|
+
"@mtcute/node": "^0.30.1",
|
|
31
31
|
"@secretlint/core": "^12.2.0",
|
|
32
32
|
"@secretlint/secretlint-rule-preset-recommend": "^12.2.0",
|
|
33
33
|
"@secretlint/types": "^12.2.0",
|
|
@@ -239,6 +239,34 @@ export function projectAssistantTextBlocks(
|
|
|
239
239
|
return out
|
|
240
240
|
}
|
|
241
241
|
|
|
242
|
+
/**
|
|
243
|
+
* True iff this assistant message's `content` carries the "answer surface"
|
|
244
|
+
* — a `text` block, or a real (non-`Agent`/`Task`) `tool_use`. Used to gate
|
|
245
|
+
* the `stop_reason === 'end_turn'` sub-agent terminal so it never fires on a
|
|
246
|
+
* split-off thinking-only line (see the terminal comment in
|
|
247
|
+
* projectSubagentLine). A thinking-only or empty line returns false; the real
|
|
248
|
+
* terminal rides the following content line, which also carries `end_turn`.
|
|
249
|
+
*/
|
|
250
|
+
export function assistantLineCarriesAnswerSurface(
|
|
251
|
+
content: Array<Record<string, unknown>> | undefined,
|
|
252
|
+
): boolean {
|
|
253
|
+
if (!Array.isArray(content)) return false
|
|
254
|
+
for (const c of content) {
|
|
255
|
+
const ct = (c?.type as string | undefined) ?? ''
|
|
256
|
+
if (ct === 'text') {
|
|
257
|
+
// A non-empty text block is the answer surface.
|
|
258
|
+
const t = c.text as string | undefined
|
|
259
|
+
if (typeof t === 'string' && t.trim().length > 0) return true
|
|
260
|
+
} else if (ct === 'tool_use') {
|
|
261
|
+
// A real tool_use is content too. (An end_turn message rarely contains a
|
|
262
|
+
// tool_use, but if it does it is content-final, not a bare thinking split.)
|
|
263
|
+
const name = (c.name as string | undefined) ?? ''
|
|
264
|
+
if (name !== 'Agent' && name !== 'Task') return true
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
return false
|
|
268
|
+
}
|
|
269
|
+
|
|
242
270
|
/**
|
|
243
271
|
* Project a single transcript line into a SessionEvent (or null if it's
|
|
244
272
|
* uninteresting noise). Caller is responsible for the JSON parse — if a
|
|
@@ -486,8 +514,26 @@ export function projectSubagentLine(
|
|
|
486
514
|
// events so the final text/preamble still renders; the watcher's turn_end
|
|
487
515
|
// handler is guarded on `state === 'running'`, so a later real
|
|
488
516
|
// turn_duration line is a no-op.
|
|
517
|
+
//
|
|
518
|
+
// UPSTREAM-SHAPE HARDENING (Claude Code ≥2.1.x): one logical assistant
|
|
519
|
+
// message is now persisted as MULTIPLE JSONL lines sharing one
|
|
520
|
+
// `message.id`, one content-block per line, and the terminal `stop_reason`
|
|
521
|
+
// (`end_turn`) is stamped on EVERY split line — including the leading
|
|
522
|
+
// `[thinking]` line that precedes the `[text: final answer]` line. Firing
|
|
523
|
+
// the terminal on the thinking-only line marks the sub-agent `done` and
|
|
524
|
+
// hands back stale/empty text BEFORE the real handback `[text]` line is
|
|
525
|
+
// projected (the watcher's onProgress is `state==='running'`-gated, so the
|
|
526
|
+
// late text is dropped). Guard: only treat `end_turn` as terminal on a line
|
|
527
|
+
// that actually carries the message's answer surface (a `text` block, or a
|
|
528
|
+
// non-`Agent`/`Task` tool_use). A thinking-only `end_turn` line is a split
|
|
529
|
+
// preamble; its terminal + handback ride the following content line, which
|
|
530
|
+
// still carries `end_turn` and fires correctly AFTER the text event. The
|
|
531
|
+
// old single-line `[thinking, text](end_turn)` shape has a `text` block, so
|
|
532
|
+
// it fires exactly as before — graceful degradation on both shapes. A
|
|
533
|
+
// genuine thinking-only end with no answer still terminates via the
|
|
534
|
+
// `turn_duration` / capped-reaper / watcher stall nets.
|
|
489
535
|
const stopReason = message?.stop_reason as string | undefined
|
|
490
|
-
if (stopReason === 'end_turn') {
|
|
536
|
+
if (stopReason === 'end_turn' && assistantLineCarriesAnswerSurface(content)) {
|
|
491
537
|
events.push({ kind: 'sub_agent_turn_end', agentId })
|
|
492
538
|
}
|
|
493
539
|
return events
|
|
@@ -158,6 +158,19 @@ export interface StreamReplyDeps {
|
|
|
158
158
|
* the raw (repaired) text is sent unchanged.
|
|
159
159
|
*/
|
|
160
160
|
normalizeParagraphBreaks?: (text: string) => string
|
|
161
|
+
/**
|
|
162
|
+
* Punctuation/bullet normalization (fleet-wide consistent formatting):
|
|
163
|
+
* em/en dashes → comma/hyphen, leading unicode bullets → `- `. Applied on
|
|
164
|
+
* code-masked text right after normalizeParagraphBreaks. Optional for
|
|
165
|
+
* backward compat; omitted → no normalization.
|
|
166
|
+
*/
|
|
167
|
+
normalizePunctuation?: (text: string) => string
|
|
168
|
+
/**
|
|
169
|
+
* Over-bold tripwire: strips `**bold**` markers when a message is clearly
|
|
170
|
+
* over-bolded (>30% bold, or whole paragraphs/lists fully bolded). Applied
|
|
171
|
+
* after normalizePunctuation. Optional for backward compat.
|
|
172
|
+
*/
|
|
173
|
+
stripExcessBold?: (text: string) => string
|
|
161
174
|
/**
|
|
162
175
|
* Insert a visible blank-line spacer into each prose `\n\n` gap so the rich
|
|
163
176
|
* GFM renderer shows a real empty line between paragraphs (the rich engine
|
|
@@ -316,9 +329,14 @@ export async function handleStreamReply(
|
|
|
316
329
|
deps: StreamReplyDeps,
|
|
317
330
|
): Promise<StreamReplyResult> {
|
|
318
331
|
const chat_id = args.chat_id
|
|
319
|
-
|
|
332
|
+
let rawText = deps.normalizeParagraphBreaks
|
|
320
333
|
? deps.normalizeParagraphBreaks(deps.repairEscapedWhitespace(args.text))
|
|
321
334
|
: deps.repairEscapedWhitespace(args.text)
|
|
335
|
+
// Fleet-wide consistent formatting: dash/bullet normalization + over-bold
|
|
336
|
+
// tripwire, same order as the reply/edit paths (after paragraph
|
|
337
|
+
// normalization, before spacers). Both run on code-masked text internally.
|
|
338
|
+
if (deps.normalizePunctuation) rawText = deps.normalizePunctuation(rawText)
|
|
339
|
+
if (deps.stripExcessBold) rawText = deps.stripExcessBold(rawText)
|
|
322
340
|
const done = Boolean(args.done)
|
|
323
341
|
const format = args.format ?? deps.defaultFormat
|
|
324
342
|
if (done) {
|
|
@@ -121,8 +121,40 @@ describe('scope-commit — durable hostd persistence', () => {
|
|
|
121
121
|
expect(commitBlock).toContain('readFileSync(')
|
|
122
122
|
})
|
|
123
123
|
|
|
124
|
-
it('passes a
|
|
125
|
-
expect(commitBlock).toContain('await tryHostdDispatch(agentName, req,
|
|
124
|
+
it('passes a 12-min timeout to tryHostdDispatch (apply+reconcile can take 5-10 min)', () => {
|
|
125
|
+
expect(commitBlock).toContain('await tryHostdDispatch(agentName, req, 720_000)')
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
it('acks the tap BEFORE the hostd await (interim status, background persist)', () => {
|
|
129
|
+
const interimAckIdx = commitBlock.indexOf('saving durably in background')
|
|
130
|
+
const bgIdx = commitBlock.indexOf('void (async () => {')
|
|
131
|
+
const hostdAwaitIdx = commitBlock.indexOf('await tryHostdDispatch(')
|
|
132
|
+
expect(interimAckIdx).toBeGreaterThan(-1)
|
|
133
|
+
expect(bgIdx).toBeGreaterThan(-1)
|
|
134
|
+
expect(hostdAwaitIdx).toBeGreaterThan(-1)
|
|
135
|
+
// interim ack fires before the background continuation opens, and the
|
|
136
|
+
// slow hostd await lives INSIDE the background continuation.
|
|
137
|
+
expect(interimAckIdx).toBeLessThan(bgIdx)
|
|
138
|
+
expect(bgIdx).toBeLessThan(hostdAwaitIdx)
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
it('background continuation body is wrapped in try/catch (a throw must not become an unhandledRejection → shutdown)', () => {
|
|
142
|
+
const bgIdx = commitBlock.indexOf('void (async () => {')
|
|
143
|
+
const tryIdx = commitBlock.indexOf('try {', bgIdx)
|
|
144
|
+
const hostdAwaitIdx = commitBlock.indexOf('await tryHostdDispatch(')
|
|
145
|
+
expect(tryIdx).toBeGreaterThan(bgIdx)
|
|
146
|
+
// The top-level try opens before any work (including the hostd await
|
|
147
|
+
// and scheduleGrantRestart's sync fs writes) runs inside the IIFE.
|
|
148
|
+
expect(tryIdx).toBeLessThan(hostdAwaitIdx)
|
|
149
|
+
expect(commitBlock).toContain('always-allow background persist threw')
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
it('edits the card with the real outcome after the background persist', () => {
|
|
153
|
+
const bgIdx = commitBlock.indexOf('void (async () => {')
|
|
154
|
+
const outcomeEditIdx = commitBlock.indexOf('await ctx.editMessageText(', bgIdx)
|
|
155
|
+
expect(outcomeEditIdx).toBeGreaterThan(bgIdx)
|
|
156
|
+
// Outcome edit failure is logged, never thrown into the void continuation.
|
|
157
|
+
expect(commitBlock).toContain('always-allow outcome card edit failed')
|
|
126
158
|
})
|
|
127
159
|
|
|
128
160
|
it('registers + cleans up the single-tap correlation entry', () => {
|
|
@@ -56,6 +56,21 @@ describe('stripMarkdown', () => {
|
|
|
56
56
|
it('does not touch HTML-significant characters (escaping stays separate)', () => {
|
|
57
57
|
expect(stripMarkdown('a < b & c > d')).toBe('a < b & c > d')
|
|
58
58
|
})
|
|
59
|
+
|
|
60
|
+
it('F1: normalizes an em-dash on this card/narration prose surface', () => {
|
|
61
|
+
// The reply path scrubs em-dashes but cards/narration/PTY previews did not,
|
|
62
|
+
// so em-dashes leaked there. stripMarkdown now runs the same dash logic.
|
|
63
|
+
// A clause-joining dash becomes a full stop (never a comma — that produces
|
|
64
|
+
// a comma splice), with the following word recapitalized into a sentence.
|
|
65
|
+
expect(stripMarkdown('build done — shipping now')).toBe('build done. Shipping now')
|
|
66
|
+
expect(stripMarkdown('build done — Shipping now')).toBe('build done. Shipping now')
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it('F1: preserves an em-dash inside an inline code span', () => {
|
|
70
|
+
// The dash normalizer masks code spans, and it runs BEFORE the backticks
|
|
71
|
+
// are stripped, so a dash inside `` `…` `` survives verbatim.
|
|
72
|
+
expect(stripMarkdown('see `a — b` token')).toBe('see a — b token')
|
|
73
|
+
})
|
|
59
74
|
})
|
|
60
75
|
|
|
61
76
|
describe('cleanWorkerResultParagraph', () => {
|
|
@@ -76,6 +91,19 @@ describe('cleanWorkerResultParagraph', () => {
|
|
|
76
91
|
it('returns empty for whitespace/markup-only input', () => {
|
|
77
92
|
expect(cleanWorkerResultParagraph(' \n---\n')).toBe('')
|
|
78
93
|
})
|
|
94
|
+
|
|
95
|
+
it('F1: normalizes em-dashes in worker narration prose', () => {
|
|
96
|
+
const input = '## Result\n\nTests pass — ready to merge'
|
|
97
|
+
expect(cleanWorkerResultParagraph(input)).toBe('Result Tests pass. Ready to merge')
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
it('F1: an em-dash inside a fenced block is preserved (the block is dropped, never normalized)', () => {
|
|
101
|
+
// Fenced blocks are excised whole, so a dash inside one is preserved by
|
|
102
|
+
// removal — it never reaches (nor is mutated by) the dash normalizer, and
|
|
103
|
+
// the surrounding prose dash IS normalized.
|
|
104
|
+
const input = 'before — after\n```\nlet a = b — c\n```\ntail'
|
|
105
|
+
expect(cleanWorkerResultParagraph(input)).toBe('before. After tail')
|
|
106
|
+
})
|
|
79
107
|
})
|
|
80
108
|
|
|
81
109
|
describe('formatDuration', () => {
|