switchroom 0.16.29 → 0.16.46
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 +101 -84
- package/dist/auth-broker/index.js +100 -83
- package/dist/cli/autoaccept-poll.js +8 -8
- package/dist/cli/drive-write-pretool.mjs +10 -10
- package/dist/cli/notion-write-pretool.mjs +102 -85
- package/dist/cli/skill-validate-pretool.mjs +91 -91
- package/dist/cli/switchroom.js +62124 -60406
- package/dist/cli/ui/index.html +877 -214
- package/dist/host-control/main.js +1000 -256
- package/dist/vault/approvals/kernel-server.js +141 -121
- package/dist/vault/broker/server.js +163 -143
- package/examples/minimal.yaml +1 -1
- package/examples/switchroom.yaml +1 -1
- package/package.json +3 -2
- package/profiles/_shared/agent-self-service.md.hbs +7 -2
- package/profiles/_shared/reply-discipline.md.hbs +9 -0
- package/skills/switchroom-status/SKILL.md +1 -1
- package/telegram-plugin/auth-snapshot-format.ts +173 -67
- package/telegram-plugin/auto-fallback-fleet.ts +3 -6
- package/telegram-plugin/bridge/bridge.ts +2 -1
- package/telegram-plugin/card-format.ts +59 -3
- package/telegram-plugin/credits-watch.ts +4 -7
- package/telegram-plugin/dist/bridge/bridge.js +132 -114
- package/telegram-plugin/dist/gateway/gateway.js +4456 -1591
- package/telegram-plugin/dist/server.js +180 -163
- package/telegram-plugin/format.ts +551 -20
- package/telegram-plugin/gateway/approval-card.ts +7 -14
- package/telegram-plugin/gateway/approvals-commands.ts +6 -9
- package/telegram-plugin/gateway/auth-command.ts +35 -38
- package/telegram-plugin/gateway/boot-card.ts +7 -2
- package/telegram-plugin/gateway/chat-id-fallback.ts +46 -0
- package/telegram-plugin/gateway/config-approval-handler.ts +6 -9
- package/telegram-plugin/gateway/diff-preview-card.ts +3 -6
- package/telegram-plugin/gateway/gateway.ts +1524 -129
- package/telegram-plugin/gateway/ipc-protocol.ts +66 -2
- package/telegram-plugin/gateway/ipc-server.ts +91 -1
- package/telegram-plugin/gateway/linear-activity.ts +2 -5
- package/telegram-plugin/gateway/model-command.ts +173 -19
- package/telegram-plugin/gateway/obligation-turn-end.ts +27 -0
- package/telegram-plugin/gateway/permission-card-store.ts +104 -0
- package/telegram-plugin/gateway/permission-timeout.ts +25 -6
- package/telegram-plugin/gateway/status-pin-store.ts +302 -0
- package/telegram-plugin/gateway/turns-jsonl-rotate.ts +30 -0
- package/telegram-plugin/gateway/unhandled-rejection-policy.ts +12 -1
- package/telegram-plugin/gateway/vault-grant-inbound-builders.ts +35 -0
- package/telegram-plugin/gateway/vault-request-access-card.ts +61 -0
- package/telegram-plugin/history.ts +17 -7
- package/telegram-plugin/hooks/tool-label-pretool.d.mts +12 -0
- package/telegram-plugin/hooks/tool-label-pretool.mjs +54 -16
- package/telegram-plugin/idle-footer.ts +2 -2
- package/telegram-plugin/issues-card.ts +12 -7
- package/telegram-plugin/model-unavailable.ts +3 -6
- package/telegram-plugin/operator-events.ts +4 -6
- package/telegram-plugin/package.json +1 -1
- package/telegram-plugin/quota-check.ts +2 -2
- package/telegram-plugin/quota-watch.ts +7 -10
- package/telegram-plugin/server.ts +3 -1
- package/telegram-plugin/session-tail.ts +47 -1
- package/telegram-plugin/status-pin-driver.ts +102 -0
- package/telegram-plugin/status-pin.ts +76 -0
- package/telegram-plugin/stream-reply-handler.ts +33 -2
- package/telegram-plugin/subagent-watcher.ts +6 -3
- package/telegram-plugin/tests/always-allow-grant.test.ts +34 -2
- package/telegram-plugin/tests/auth-command-format2.test.ts +6 -2
- package/telegram-plugin/tests/auth-command-vernacular.test.ts +18 -0
- package/telegram-plugin/tests/auth-snapshot-format.test.ts +204 -45
- package/telegram-plugin/tests/card-format.test.ts +79 -0
- package/telegram-plugin/tests/chat-id-fallback.test.ts +74 -0
- package/telegram-plugin/tests/claude-code-event-contract.test.ts +151 -0
- package/telegram-plugin/tests/codespan-escaping-golden.test.ts +166 -0
- package/telegram-plugin/tests/credits-watch.test.ts +18 -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/history.test.ts +38 -2
- package/telegram-plugin/tests/idle-footer.test.ts +53 -9
- package/telegram-plugin/tests/ipc-server-validate-rollout-status.test.ts +66 -0
- package/telegram-plugin/tests/issues-card.test.ts +24 -0
- package/telegram-plugin/tests/model-command.test.ts +213 -47
- package/telegram-plugin/tests/obligation-turn-end.test.ts +76 -0
- package/telegram-plugin/tests/paragraph-normalizer.test.ts +384 -3
- package/telegram-plugin/tests/permission-card-single-edit.test.ts +66 -0
- package/telegram-plugin/tests/permission-card-store.test.ts +83 -0
- package/telegram-plugin/tests/permission-timeout.test.ts +37 -6
- package/telegram-plugin/tests/permission-verdict-resume-guard.test.ts +23 -6
- package/telegram-plugin/tests/quota-check.test.ts +9 -2
- package/telegram-plugin/tests/quota-watch.test.ts +7 -7
- package/telegram-plugin/tests/rich-markdown-oracle.ts +469 -0
- package/telegram-plugin/tests/rollout-status-wiring.test.ts +81 -0
- package/telegram-plugin/tests/session-tail.test.ts +91 -0
- package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +294 -0
- package/telegram-plugin/tests/status-pin-service-message-suppression.test.ts +210 -0
- package/telegram-plugin/tests/status-pin-store.test.ts +450 -0
- package/telegram-plugin/tests/status-pin.test.ts +202 -0
- package/telegram-plugin/tests/status-vocabulary-unification.test.ts +125 -0
- package/telegram-plugin/tests/stream-reply-handler.test.ts +39 -0
- package/telegram-plugin/tests/telegram-format.test.ts +121 -8
- package/telegram-plugin/tests/text-voice-scrub.test.ts +142 -22
- package/telegram-plugin/tests/tool-activity-summary.test.ts +88 -25
- package/telegram-plugin/tests/tts-normalize.test.ts +242 -0
- package/telegram-plugin/tests/turns-jsonl-rotate.test.ts +39 -0
- package/telegram-plugin/tests/unhandled-rejection-policy.test.ts +12 -0
- package/telegram-plugin/tests/vault-grant-inbound-builders.test.ts +54 -0
- package/telegram-plugin/tests/vault-request-access-card.test.ts +94 -0
- package/telegram-plugin/tests/vault-request-access-tool.test.ts +24 -0
- package/telegram-plugin/tests/voice-normalize-text.test.ts +256 -0
- package/telegram-plugin/tests/voice-ondemand.test.ts +299 -0
- package/telegram-plugin/tests/voice-out-one-send.test.ts +153 -0
- package/telegram-plugin/tests/voice-presynth.test.ts +437 -0
- package/telegram-plugin/tests/voice-synthesize-sidecar.test.ts +352 -0
- package/telegram-plugin/tests/voice-transcribe-sidecar.test.ts +332 -0
- package/telegram-plugin/tests/voice-transcribe.test.ts +188 -0
- package/telegram-plugin/tests/worker-activity-feed.test.ts +77 -10
- package/telegram-plugin/text-voice-scrub.ts +68 -18
- package/telegram-plugin/tool-activity-summary.ts +31 -116
- package/telegram-plugin/tts-normalize.ts +377 -0
- package/telegram-plugin/uat/driver.ts +474 -17
- 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-normalize-text.ts +340 -0
- package/telegram-plugin/voice-ondemand.ts +289 -0
- package/telegram-plugin/voice-presynth.ts +242 -0
- package/telegram-plugin/voice-synthesize-sidecar.ts +259 -0
- package/telegram-plugin/voice-synthesize.ts +128 -0
- package/telegram-plugin/voice-transcribe-sidecar.ts +176 -0
- package/telegram-plugin/worker-activity-feed.ts +22 -5
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
*/
|
|
18
18
|
|
|
19
19
|
import { readFileSync, writeFileSync } from "node:fs";
|
|
20
|
-
import { escapeMarkdown } from "./card-format.js";
|
|
20
|
+
import { escapeMarkdown, stackCardLines } from "./card-format.js";
|
|
21
21
|
import type { IssueEvent, IssueSeverity } from "../src/issues/index.js";
|
|
22
22
|
|
|
23
23
|
export interface BotApiForIssuesCard {
|
|
@@ -99,26 +99,31 @@ export function renderIssuesCard(opts: RenderIssuesCardOpts): string | null {
|
|
|
99
99
|
const overflow = sorted.length - visible.length;
|
|
100
100
|
|
|
101
101
|
const now = opts.now ?? Date.now();
|
|
102
|
-
|
|
102
|
+
// Build a flat list of SINGLE lines (a row may contribute a head line plus a
|
|
103
|
+
// remediation line) so stackCardLines can promote every inter-line break to a
|
|
104
|
+
// GFM hard break — otherwise the rows collapse onto one visual line in the
|
|
105
|
+
// rich-message renderer (the same bug stackCardLines fixes for status cards).
|
|
106
|
+
const rowLines: string[] = [];
|
|
107
|
+
for (const e of visible) {
|
|
103
108
|
const emoji = SEVERITY_EMOJI[e.severity];
|
|
104
109
|
const occ = e.occurrences > 1 ? ` _(×${e.occurrences})_` : "";
|
|
105
110
|
const ago = relTime(now - e.last_seen);
|
|
106
|
-
|
|
111
|
+
rowLines.push(`${emoji} \`${e.fingerprint}\` ${escapeMarkdown(e.summary)}${occ} — _${ago}_`);
|
|
107
112
|
// Render the `detail` line below the summary when present and short
|
|
108
113
|
// enough to be a remediation hint (not a multi-line stderr tail).
|
|
109
114
|
// Convention from the cron prompt template: agents put "Fix: <cmd>"
|
|
110
115
|
// or "→ <cmd>" in detail. Long stderr details are omitted from the
|
|
111
116
|
// card to keep the layout tight; users can run /issues to see them.
|
|
112
117
|
const remediation = formatRemediation(e.detail);
|
|
113
|
-
|
|
114
|
-
}
|
|
118
|
+
if (remediation != null) rowLines.push(` → _${escapeMarkdown(remediation)}_`);
|
|
119
|
+
}
|
|
115
120
|
|
|
116
|
-
const lines = [header, "", ...
|
|
121
|
+
const lines = [header, "", ...rowLines];
|
|
117
122
|
if (overflow > 0) {
|
|
118
123
|
lines.push("");
|
|
119
124
|
lines.push(`_+${overflow} more not shown — run \`/issues\`_`);
|
|
120
125
|
}
|
|
121
|
-
return lines
|
|
126
|
+
return stackCardLines(lines);
|
|
122
127
|
}
|
|
123
128
|
|
|
124
129
|
/**
|
|
@@ -27,6 +27,7 @@
|
|
|
27
27
|
*/
|
|
28
28
|
|
|
29
29
|
import { formatResetRelative } from './quota-check.js'
|
|
30
|
+
import { escapeMarkdown } from './card-format.js'
|
|
30
31
|
|
|
31
32
|
// ─── Public types ────────────────────────────────────────────────────────────
|
|
32
33
|
|
|
@@ -366,10 +367,10 @@ export function formatModelUnavailableCard(
|
|
|
366
367
|
opts: FormatCardOptions = {},
|
|
367
368
|
): string {
|
|
368
369
|
const now = opts.now ?? new Date()
|
|
369
|
-
const slotPart = opts.slot ? ` (slot **${
|
|
370
|
+
const slotPart = opts.slot ? ` (slot **${escapeMarkdown(opts.slot)}**)` : ''
|
|
370
371
|
const reason = formatReason(detection, now)
|
|
371
372
|
const lines = [
|
|
372
|
-
`⚠️ **Model unavailable** on agent **${
|
|
373
|
+
`⚠️ **Model unavailable** on agent **${escapeMarkdown(agent)}**${slotPart}`,
|
|
373
374
|
`Reason: ${reason}`,
|
|
374
375
|
'',
|
|
375
376
|
]
|
|
@@ -469,7 +470,3 @@ export function resolveModelUnavailableFromOperatorEvent(
|
|
|
469
470
|
}
|
|
470
471
|
|
|
471
472
|
// ─── HTML escape (mirrors operator-events.ts) ────────────────────────────────
|
|
472
|
-
|
|
473
|
-
function escHtml(text: string): string {
|
|
474
|
-
return text.replace(/([\\`*_~=\[\]|])/g, '\\$1')
|
|
475
|
-
}
|
|
@@ -12,6 +12,8 @@
|
|
|
12
12
|
* including the quota-exhausted strings migrated from auto-fallback.ts.
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
|
+
import { escapeMarkdown } from './format.js'
|
|
16
|
+
|
|
15
17
|
// ─── Taxonomy ────────────────────────────────────────────────────────────────
|
|
16
18
|
|
|
17
19
|
export type OperatorEventKind =
|
|
@@ -210,8 +212,8 @@ export interface RenderResult {
|
|
|
210
212
|
* `:` or other delimiter characters.
|
|
211
213
|
*/
|
|
212
214
|
export function renderOperatorEvent(ev: OperatorEvent): RenderResult {
|
|
213
|
-
const agent =
|
|
214
|
-
const detail =
|
|
215
|
+
const agent = escapeMarkdown(ev.agent)
|
|
216
|
+
const detail = escapeMarkdown(ev.detail)
|
|
215
217
|
|
|
216
218
|
switch (ev.kind) {
|
|
217
219
|
case 'credentials-expired':
|
|
@@ -412,7 +414,3 @@ export function resetAllCooldowns(): void {
|
|
|
412
414
|
}
|
|
413
415
|
|
|
414
416
|
// ─── Markdown escape (#2669) ──────────────────────────────────────────────────
|
|
415
|
-
|
|
416
|
-
function escHtml(text: string): string {
|
|
417
|
-
return text.replace(/([\\`*_~=\[\]|])/g, '\\$1')
|
|
418
|
-
}
|
|
@@ -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",
|
|
@@ -253,10 +253,10 @@ export function formatQuotaBlock(q: QuotaUtilization, now: Date = new Date()): s
|
|
|
253
253
|
lines.push("**Claude plan quota**");
|
|
254
254
|
lines.push("");
|
|
255
255
|
lines.push(
|
|
256
|
-
`**5h window**
|
|
256
|
+
`**5h window** \`${Math.round(q.fiveHourUtilizationPct)}%\` · \`${formatResetRelative(q.fiveHourResetAt, now)}\``,
|
|
257
257
|
);
|
|
258
258
|
lines.push(
|
|
259
|
-
`**7d window**
|
|
259
|
+
`**7d window** \`${Math.round(q.sevenDayUtilizationPct)}%\` · \`${formatResetRelative(q.sevenDayResetAt, now)}\``,
|
|
260
260
|
);
|
|
261
261
|
if (q.representativeClaim) {
|
|
262
262
|
lines.push("");
|
|
@@ -51,6 +51,7 @@ import {
|
|
|
51
51
|
fmtPct,
|
|
52
52
|
} from "./auth-snapshot-format.js";
|
|
53
53
|
import type { QuotaUtilization } from "./quota-check.js";
|
|
54
|
+
import { escapeMarkdown, codeSpanSafe } from "./card-format.js";
|
|
54
55
|
|
|
55
56
|
const STATE_FILE = "quota-watch.json";
|
|
56
57
|
|
|
@@ -511,7 +512,7 @@ function buildFleetRecoveredMessage(
|
|
|
511
512
|
accounts: Array<{ label: string; exhausted: boolean }>,
|
|
512
513
|
): string {
|
|
513
514
|
const healthy = accounts.filter((a) => !a.exhausted).map((a) => a.label);
|
|
514
|
-
const which = healthy.length > 0 ? ` (\`${
|
|
515
|
+
const which = healthy.length > 0 ? ` (\`${codeSpanSafe(healthy[0]!)}\`)` : "";
|
|
515
516
|
return [
|
|
516
517
|
`🟢 **Fleet recovered** — at least one account is healthy again${which}.`,
|
|
517
518
|
``,
|
|
@@ -521,7 +522,7 @@ function buildFleetRecoveredMessage(
|
|
|
521
522
|
|
|
522
523
|
// ─── Message builders ─────────────────────────────────────────────────────────
|
|
523
524
|
|
|
524
|
-
function buildThrottlingMessage(agentName: string, snap: AccountSnapshot): string {
|
|
525
|
+
export function buildThrottlingMessage(agentName: string, snap: AccountSnapshot): string {
|
|
525
526
|
const q = snap.quota!; // classifyHealth returned throttling, so quota is non-null
|
|
526
527
|
const fiveStr = fmtPct(q.fiveHourUtilizationPct);
|
|
527
528
|
const sevenStr = fmtPct(q.sevenDayUtilizationPct);
|
|
@@ -535,14 +536,14 @@ function buildThrottlingMessage(agentName: string, snap: AccountSnapshot): strin
|
|
|
535
536
|
|
|
536
537
|
const activeNote = snap.isActive
|
|
537
538
|
? ""
|
|
538
|
-
: `\nThis is a non-active account. Consider \`/auth use ${
|
|
539
|
+
: `\nThis is a non-active account. Consider \`/auth use ${codeSpanSafe(snap.label)}\` to switch, or keep it as a fallback reserve.`;
|
|
539
540
|
|
|
540
541
|
const altNote = snap.isActive
|
|
541
542
|
? `\nConsider \`/auth use <other-account>\` if you have a healthier account, or wait for the ${winLabel} window to refill${resetStr}.`
|
|
542
543
|
: "";
|
|
543
544
|
|
|
544
545
|
return [
|
|
545
|
-
`🟡 **Quota approaching limit** — \`${
|
|
546
|
+
`🟡 **Quota approaching limit** — \`${codeSpanSafe(snap.label)}\``,
|
|
546
547
|
``,
|
|
547
548
|
`${fiveStr} of 5h · ${sevenStr} of 7d`,
|
|
548
549
|
`Binding window: ${winLabel}${resetStr}`,
|
|
@@ -556,14 +557,14 @@ function buildThrottlingMessage(agentName: string, snap: AccountSnapshot): strin
|
|
|
556
557
|
.trim();
|
|
557
558
|
}
|
|
558
559
|
|
|
559
|
-
function buildRecoveryMessage(agentName: string, snap: AccountSnapshot): string {
|
|
560
|
+
export function buildRecoveryMessage(agentName: string, snap: AccountSnapshot): string {
|
|
560
561
|
const q = snap.quota;
|
|
561
562
|
const utilLine = q
|
|
562
563
|
? `Current: ${fmtPct(q.fiveHourUtilizationPct)} of 5h · ${fmtPct(q.sevenDayUtilizationPct)} of 7d`
|
|
563
564
|
: "Current quota data unavailable.";
|
|
564
565
|
|
|
565
566
|
return [
|
|
566
|
-
`🟢 **Quota back in healthy range** — \`${
|
|
567
|
+
`🟢 **Quota back in healthy range** — \`${codeSpanSafe(snap.label)}\``,
|
|
567
568
|
``,
|
|
568
569
|
utilLine,
|
|
569
570
|
``,
|
|
@@ -571,10 +572,6 @@ function buildRecoveryMessage(agentName: string, snap: AccountSnapshot): string
|
|
|
571
572
|
].join("\n");
|
|
572
573
|
}
|
|
573
574
|
|
|
574
|
-
function escapeHtml(s: string): string {
|
|
575
|
-
return s.replace(/([\\`*_~=\[\]|])/g, "\\$1");
|
|
576
|
-
}
|
|
577
|
-
|
|
578
575
|
// ─── State persistence ────────────────────────────────────────────────────────
|
|
579
576
|
|
|
580
577
|
export function loadQuotaWatchState(stateDir: string): QuotaWatchState {
|
|
@@ -30,7 +30,9 @@
|
|
|
30
30
|
* - Robust error handling: 429 retry, thread-not-found fallback, network retry
|
|
31
31
|
* - SQLite history buffer (history.ts) for cross-restart recovery
|
|
32
32
|
* - Vault-grant inline-keyboard wizard
|
|
33
|
-
* -
|
|
33
|
+
* - Live in-conversation status feed with sub-agent visibility (the pinned
|
|
34
|
+
* progress card was retired in #1122; the status message is now a regular
|
|
35
|
+
* in-chat message, silently pinned while in-flight — status-pin.ts)
|
|
34
36
|
*
|
|
35
37
|
* State (access.json / pairing / allowlists) lives at
|
|
36
38
|
* ~/.claude/channels/telegram/ — managed by the /telegram:access skill.
|
|
@@ -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
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Status-pin driver — executes a PinAction against a Telegram Bot API.
|
|
3
|
+
*
|
|
4
|
+
* The pure decision lives in `status-pin.ts` (decidePinAction). This
|
|
5
|
+
* module is the side-effecting half: takes the previously-claimed state
|
|
6
|
+
* and the desired state, computes ONE action, executes it, and returns
|
|
7
|
+
* the next state. The state itself stays in the caller (the gateway holds
|
|
8
|
+
* a `Map<pinKey, PinState>` and re-passes the entry on every call).
|
|
9
|
+
*
|
|
10
|
+
* Copied from `slot-banner-driver.ts`. The load-bearing contract (see
|
|
11
|
+
* slot-banner-driver.ts:100-110):
|
|
12
|
+
*
|
|
13
|
+
* - Pins are SILENT (`disable_notification: true`) — a status pin must
|
|
14
|
+
* never buzz the device.
|
|
15
|
+
* - We pin an EXISTING message (the already-rendered per-turn status /
|
|
16
|
+
* `🛠 Worker` message). We NEVER send a new message to pin. This is
|
|
17
|
+
* the boundary that keeps us inside the one sanctioned exception on
|
|
18
|
+
* `chat-is-the-single-source-of-truth`.
|
|
19
|
+
* - On UNPIN we DROP THE CLAIM (clear state) EVEN IF `unpinChatMessage`
|
|
20
|
+
* throws. This is the whole point: pin state must never get stuck.
|
|
21
|
+
* The message may have been unpinned out-of-band (operator, or a
|
|
22
|
+
* crash) — re-claiming it would be more confusing than surfacing it
|
|
23
|
+
* again later, and a stuck claim would leave a permanent pin.
|
|
24
|
+
* - API failures are reported via `onError` but never throw; the caller
|
|
25
|
+
* decides logging cadence.
|
|
26
|
+
*
|
|
27
|
+
* See `reference/invariants.md` § `chat-is-the-single-source-of-truth`
|
|
28
|
+
* (the sanctioned silent-pin exception) and the `know-what-my-agent-is-doing`
|
|
29
|
+
* job spec.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import type { PinState, DesiredPin } from './status-pin.js'
|
|
33
|
+
import { decidePinAction } from './status-pin.js'
|
|
34
|
+
|
|
35
|
+
/** Minimal subset of grammy's `bot.api` the pin driver depends on.
|
|
36
|
+
* Lets tests swap in a fake without dragging in the full Bot type. */
|
|
37
|
+
export interface PinBotApi {
|
|
38
|
+
pinChatMessage(
|
|
39
|
+
chat_id: string | number,
|
|
40
|
+
message_id: number,
|
|
41
|
+
opts?: Record<string, unknown>,
|
|
42
|
+
): Promise<unknown>
|
|
43
|
+
unpinChatMessage(
|
|
44
|
+
chat_id: string | number,
|
|
45
|
+
message_id: number,
|
|
46
|
+
): Promise<unknown>
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface ReconcilePinArgs {
|
|
50
|
+
api: PinBotApi
|
|
51
|
+
chatId: string
|
|
52
|
+
/** State the caller is holding from the last reconcile. `null` on first. */
|
|
53
|
+
prevState: PinState | null
|
|
54
|
+
/** What the caller wants pinned for this key right now. */
|
|
55
|
+
desired: DesiredPin
|
|
56
|
+
/** Optional API-failure observer. Default: silent. */
|
|
57
|
+
onError?: (phase: 'pin' | 'unpin', err: unknown) => void
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Execute the next pin-state transition for one key. Returns the new
|
|
62
|
+
* `PinState` (or `null` when unpinned / nothing pinned). Always resolves;
|
|
63
|
+
* never throws — API errors route through `onError`.
|
|
64
|
+
*
|
|
65
|
+
* - `pin` : pins the existing message SILENTLY; on failure the claim is
|
|
66
|
+
* NOT taken (returns prevState) so the next reconcile retries
|
|
67
|
+
* rather than tracking a message it never pinned.
|
|
68
|
+
* - `unpin`: unpins best-effort and returns `null` — the claim is dropped
|
|
69
|
+
* EVEN IF the unpin throws (never leave state stuck pinned).
|
|
70
|
+
* - `noop` : returns prevState unchanged.
|
|
71
|
+
*/
|
|
72
|
+
export async function reconcilePin(
|
|
73
|
+
args: ReconcilePinArgs,
|
|
74
|
+
): Promise<PinState | null> {
|
|
75
|
+
const action = decidePinAction(args.prevState, args.desired)
|
|
76
|
+
|
|
77
|
+
if (action.kind === 'noop') return args.prevState
|
|
78
|
+
|
|
79
|
+
if (action.kind === 'unpin') {
|
|
80
|
+
try {
|
|
81
|
+
await args.api.unpinChatMessage(args.chatId, action.messageId)
|
|
82
|
+
} catch (err) {
|
|
83
|
+
args.onError?.('unpin', err)
|
|
84
|
+
}
|
|
85
|
+
// Drop the claim regardless of the unpin outcome. A stuck claim would
|
|
86
|
+
// leave a permanent pin on a crash / out-of-band unpin — the exact
|
|
87
|
+
// failure this driver exists to prevent (see slot-banner-driver.ts).
|
|
88
|
+
return null
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// action.kind === 'pin' — pin an EXISTING message, silently.
|
|
92
|
+
try {
|
|
93
|
+
await args.api.pinChatMessage(args.chatId, action.messageId, {
|
|
94
|
+
disable_notification: true,
|
|
95
|
+
})
|
|
96
|
+
} catch (err) {
|
|
97
|
+
args.onError?.('pin', err)
|
|
98
|
+
// Don't claim a message we failed to pin — the next reconcile retries.
|
|
99
|
+
return args.prevState
|
|
100
|
+
}
|
|
101
|
+
return { messageId: action.messageId }
|
|
102
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Status-pin — pure decision logic.
|
|
3
|
+
*
|
|
4
|
+
* Keeps an already-rendered status message pinned while its work is
|
|
5
|
+
* in-flight, and unpins it the moment the work completes. This is the
|
|
6
|
+
* ONE sanctioned pin under the `chat-is-the-single-source-of-truth`
|
|
7
|
+
* invariant (see `reference/invariants.md` §
|
|
8
|
+
* `chat-is-the-single-source-of-truth` and the
|
|
9
|
+
* `know-what-my-agent-is-doing` job spec): it re-surfaces a message the
|
|
10
|
+
* conversation ALREADY owns — the per-turn activity/status message and
|
|
11
|
+
* the `🛠 Worker` background-worker message — and never renders a new
|
|
12
|
+
* parallel surface.
|
|
13
|
+
*
|
|
14
|
+
* Why a pin at all: fast answers, more background workers, and much
|
|
15
|
+
* longer turns mean the conversation moves on quickly in the feed and
|
|
16
|
+
* the user loses sight of in-flight work — work now routinely outlives
|
|
17
|
+
* the visible window. Pinning the message the feed already holds keeps
|
|
18
|
+
* it in view; it carries no new content.
|
|
19
|
+
*
|
|
20
|
+
* This module is dependency-free so it's provable in isolation; the
|
|
21
|
+
* gateway (via `status-pin-driver.ts`) translates a `PinAction` into
|
|
22
|
+
* actual Telegram API calls. The design mirrors `slot-banner.ts` +
|
|
23
|
+
* `slot-banner-driver.ts` — one pure decision, one side-effecting
|
|
24
|
+
* reconcile, the claim dropped on unpin even when the API throws.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
/** The message the framework is currently claiming as pinned for a key. */
|
|
28
|
+
export type PinState = {
|
|
29
|
+
/** Telegram message_id we pinned. */
|
|
30
|
+
messageId: number
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** What the caller wants pinned for a key right now. */
|
|
34
|
+
export type DesiredPin =
|
|
35
|
+
/** Work is in-flight; this already-rendered message should be pinned. */
|
|
36
|
+
| { pinned: true; messageId: number }
|
|
37
|
+
/** Work is done (or never opened a message); nothing should be pinned. */
|
|
38
|
+
| { pinned: false }
|
|
39
|
+
|
|
40
|
+
export type PinAction =
|
|
41
|
+
| { kind: 'noop'; reason: string }
|
|
42
|
+
/** Pin an EXISTING message. Caller pins, then records the message_id
|
|
43
|
+
* back into PinState. No new message is sent — this pins a message the
|
|
44
|
+
* chat already rendered. */
|
|
45
|
+
| { kind: 'pin'; messageId: number }
|
|
46
|
+
/** Unpin + forget. Caller unpins (best-effort) and clears state EVEN IF
|
|
47
|
+
* the unpin call throws. */
|
|
48
|
+
| { kind: 'unpin'; messageId: number }
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Decide the single pin action for a key given the previously-claimed
|
|
52
|
+
* state and what the caller now wants.
|
|
53
|
+
*
|
|
54
|
+
* Exactly one action per (prev, desired) — the whole point is that pin
|
|
55
|
+
* state can never get stuck: an in-flight → done transition always yields
|
|
56
|
+
* an `unpin`, and the driver drops the claim on unpin regardless of API
|
|
57
|
+
* outcome.
|
|
58
|
+
*/
|
|
59
|
+
export function decidePinAction(
|
|
60
|
+
prev: PinState | null,
|
|
61
|
+
desired: DesiredPin,
|
|
62
|
+
): PinAction {
|
|
63
|
+
if (!desired.pinned) {
|
|
64
|
+
if (prev) return { kind: 'unpin', messageId: prev.messageId }
|
|
65
|
+
return { kind: 'noop', reason: 'nothing pinned, nothing wanted' }
|
|
66
|
+
}
|
|
67
|
+
// desired.pinned === true
|
|
68
|
+
if (prev == null) return { kind: 'pin', messageId: desired.messageId }
|
|
69
|
+
if (prev.messageId === desired.messageId) {
|
|
70
|
+
return { kind: 'noop', reason: 'already pinned this message' }
|
|
71
|
+
}
|
|
72
|
+
// The message we want pinned changed (the feed re-posted after a stale
|
|
73
|
+
// edit dropped its id). Unpin the old claim; a subsequent desired-pin
|
|
74
|
+
// re-pins the new one on the next reconcile.
|
|
75
|
+
return { kind: 'unpin', messageId: prev.messageId }
|
|
76
|
+
}
|
|
@@ -158,6 +158,27 @@ 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
|
|
174
|
+
/**
|
|
175
|
+
* Insert a visible blank-line spacer into each prose `\n\n` gap so the rich
|
|
176
|
+
* GFM renderer shows a real empty line between paragraphs (the rich engine
|
|
177
|
+
* otherwise renders `\n\n` tight — the post-#2669 paragraph-spacing
|
|
178
|
+
* regression). Applied only on the rich path (never on `format:'text'`).
|
|
179
|
+
* Optional for backward compat; omitted → no spacers added.
|
|
180
|
+
*/
|
|
181
|
+
addParagraphSpacers?: (text: string) => string
|
|
161
182
|
/** Validates the chat id against the access list. Throws on deny. */
|
|
162
183
|
assertAllowedChat: (chatId: string) => void
|
|
163
184
|
/** Resolves the effective thread id (explicit, last-inbound, or undefined). */
|
|
@@ -308,9 +329,14 @@ export async function handleStreamReply(
|
|
|
308
329
|
deps: StreamReplyDeps,
|
|
309
330
|
): Promise<StreamReplyResult> {
|
|
310
331
|
const chat_id = args.chat_id
|
|
311
|
-
|
|
332
|
+
let rawText = deps.normalizeParagraphBreaks
|
|
312
333
|
? deps.normalizeParagraphBreaks(deps.repairEscapedWhitespace(args.text))
|
|
313
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)
|
|
314
340
|
const done = Boolean(args.done)
|
|
315
341
|
const format = args.format ?? deps.defaultFormat
|
|
316
342
|
if (done) {
|
|
@@ -337,7 +363,12 @@ export async function handleStreamReply(
|
|
|
337
363
|
// markdown→HTML / MarkdownV2 rendering happens here anymore — the raw
|
|
338
364
|
// text IS the wire payload.
|
|
339
365
|
const literalText = format === 'text'
|
|
340
|
-
|
|
366
|
+
// Paragraph-spacing fix (rich-message regression after #2669): inject a
|
|
367
|
+
// visible blank-line spacer into prose `\n\n` gaps on the rich path so
|
|
368
|
+
// multi-paragraph answers don't render jammed together. The literal
|
|
369
|
+
// (`format:'text'`) path must stay byte-exact, so it is left untouched.
|
|
370
|
+
let effectiveText: string =
|
|
371
|
+
!literalText && deps.addParagraphSpacers ? deps.addParagraphSpacers(rawText) : rawText
|
|
341
372
|
|
|
342
373
|
// Inline status-accent header (issue #320 fallback). Prepended so it
|
|
343
374
|
// leads the body. Since stream_reply callers pass the full text snapshot
|
|
@@ -13,9 +13,12 @@
|
|
|
13
13
|
* does not yet exist (Phase 2 Pre hook hasn't fired), the update is a no-op
|
|
14
14
|
* and the event is logged — no INSERT here, identity belongs to Phase 2.
|
|
15
15
|
*
|
|
16
|
-
* Sub-agent state is surfaced to the user
|
|
17
|
-
*
|
|
18
|
-
*
|
|
16
|
+
* Sub-agent state is surfaced to the user in the conversation itself — a
|
|
17
|
+
* background worker via its in-chat `🛠 Worker` message (worker-activity-feed.ts),
|
|
18
|
+
* a foreground sub-agent nested in the parent turn's activity message. The
|
|
19
|
+
* pinned progress card was retired in #1122; the `🛠 Worker` message is now
|
|
20
|
+
* silently pinned while the worker runs (status-pin.ts), not a bespoke card.
|
|
21
|
+
* See issue #142.
|
|
19
22
|
*
|
|
20
23
|
* Architecture notes:
|
|
21
24
|
* - Option B from the spec: filesystem-driven, no IPC contract.
|
|
@@ -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', () => {
|
|
@@ -93,8 +93,12 @@ describe('renderShowText — Format 2 vs legacy', () => {
|
|
|
93
93
|
});
|
|
94
94
|
expect(out).toContain('🔋 **Auth — fleet status**');
|
|
95
95
|
expect(out).toContain('Recommendation:');
|
|
96
|
-
|
|
97
|
-
expect(out).toContain('
|
|
96
|
+
// GFM table card (#2700): State emoji per row, not group headers.
|
|
97
|
+
expect(out).toContain('| State | Account | 5h | 7d | Status |');
|
|
98
|
+
expect(out).toContain('| 🔴 |'); // the blocked account's State cell
|
|
99
|
+
expect(out).toContain('| 🟢 |'); // a healthy account's State cell
|
|
100
|
+
expect(out).not.toContain('**BLOCKED**');
|
|
101
|
+
expect(out).not.toContain('**HEALTHY**');
|
|
98
102
|
// Legacy ASCII column headers should be absent
|
|
99
103
|
expect(out).not.toContain('ACCOUNT STATUS');
|
|
100
104
|
});
|
|
@@ -140,6 +140,24 @@ describe('parseAuthCommand — new verbs', () => {
|
|
|
140
140
|
expect((p as { reason?: string }).reason).toMatch(/confirm/i)
|
|
141
141
|
})
|
|
142
142
|
|
|
143
|
+
// Boundary-escaping (#2695 escaper de-dup): the help/reason strings now
|
|
144
|
+
// render dynamic user input via `codeSpanSafe` — the reason interpolates the
|
|
145
|
+
// value INSIDE a `code span`, where backslash escaping is wrong (#2695). A
|
|
146
|
+
// metacharacter-laden verb must come back LITERAL (no stray backslashes).
|
|
147
|
+
it('renders a metacharacter-laden unknown verb literally in the code span', () => {
|
|
148
|
+
const p = parseAuthCommand('/auth a_b*c')
|
|
149
|
+
expect(p?.kind).toBe('help')
|
|
150
|
+
expect((p as { reason?: string }).reason).toContain('`a_b*c`')
|
|
151
|
+
expect((p as { reason?: string }).reason).not.toContain('a\\_b\\*c')
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
it('renders a metacharacter-laden rm modifier literally in the code span', () => {
|
|
155
|
+
const p = parseAuthCommand('/auth rm spare x_y*z')
|
|
156
|
+
expect(p?.kind).toBe('help')
|
|
157
|
+
expect((p as { reason?: string }).reason).toContain('`x_y*z`')
|
|
158
|
+
expect((p as { reason?: string }).reason).not.toContain('x\\_y\\*z')
|
|
159
|
+
})
|
|
160
|
+
|
|
143
161
|
it('rejects /auth rm with no label', () => {
|
|
144
162
|
const p = parseAuthCommand('/auth rm')
|
|
145
163
|
expect(p?.kind).toBe('help')
|