switchroom 0.19.25 → 0.19.27

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (84) hide show
  1. package/bin/git-agent-attribution-hook.sh +144 -0
  2. package/dist/agent-scheduler/index.js +61 -2
  3. package/dist/auth-broker/index.js +125 -8
  4. package/dist/cli/notion-write-pretool.mjs +61 -2
  5. package/dist/cli/switchroom.js +2347 -1104
  6. package/dist/host-control/main.js +126 -9
  7. package/dist/vault/approvals/kernel-server.js +124 -8
  8. package/dist/vault/broker/server.js +124 -8
  9. package/package.json +6 -2
  10. package/profiles/_base/cron-session.sh.hbs +14 -0
  11. package/profiles/_base/start.sh.hbs +145 -4
  12. package/telegram-plugin/card-layout.ts +328 -0
  13. package/telegram-plugin/dist/bridge/bridge.js +93 -1
  14. package/telegram-plugin/dist/gateway/gateway.js +2213 -1204
  15. package/telegram-plugin/dist/server.js +96 -1
  16. package/telegram-plugin/edit-flood-fuse.ts +637 -56
  17. package/telegram-plugin/flood-429-ledger.ts +526 -0
  18. package/telegram-plugin/flood-circuit-breaker.ts +18 -0
  19. package/telegram-plugin/gateway/flood-reply-queue.ts +168 -0
  20. package/telegram-plugin/gateway/gateway.ts +103 -112
  21. package/telegram-plugin/gateway/narrative-lane.ts +14 -0
  22. package/telegram-plugin/gateway/outbound-send-path.ts +36 -0
  23. package/telegram-plugin/gateway/outbox-sweep.ts +183 -6
  24. package/telegram-plugin/gateway/periodic-sweep-guard.ts +86 -0
  25. package/telegram-plugin/gateway/pinned-message-handler.ts +12 -16
  26. package/telegram-plugin/gateway/status-pin-retarget.ts +180 -0
  27. package/telegram-plugin/gateway/status-pin-store.ts +58 -9
  28. package/telegram-plugin/gateway/worker-pin-reaper.ts +56 -7
  29. package/telegram-plugin/llm-error-present.ts +61 -2
  30. package/telegram-plugin/model-unavailable.ts +8 -0
  31. package/telegram-plugin/operator-events.ts +72 -5
  32. package/telegram-plugin/outbound-class.ts +81 -0
  33. package/telegram-plugin/provider-credit.ts +237 -0
  34. package/telegram-plugin/scripts/bun-test-ci.sh +36 -6
  35. package/telegram-plugin/send-gate.ts +24 -2
  36. package/telegram-plugin/status-no-truncate.ts +11 -0
  37. package/telegram-plugin/status-pin-driver.ts +33 -17
  38. package/telegram-plugin/status-pin.ts +51 -5
  39. package/telegram-plugin/tests/card-golden.test.ts +69 -0
  40. package/telegram-plugin/tests/card-lifecycle-render.test.ts +362 -0
  41. package/telegram-plugin/tests/card-type-distinguishability.test.ts +291 -0
  42. package/telegram-plugin/tests/card-variants.golden.txt +211 -0
  43. package/telegram-plugin/tests/card-variants.ts +366 -0
  44. package/telegram-plugin/tests/edit-flood-fuse-ban-awareness.test.ts +316 -0
  45. package/telegram-plugin/tests/edit-flood-fuse-default-deny.test.ts +319 -0
  46. package/telegram-plugin/tests/edit-flood-fuse.test.ts +11 -2
  47. package/telegram-plugin/tests/feed-edit-rate-ceiling.test.ts +462 -0
  48. package/telegram-plugin/tests/fixtures/real-429-stream.ts +220 -0
  49. package/telegram-plugin/tests/flood-429-ledger.test.ts +278 -0
  50. package/telegram-plugin/tests/flood-429-recorder-wiring.test.ts +128 -0
  51. package/telegram-plugin/tests/flood-reply-queue.test.ts +418 -0
  52. package/telegram-plugin/tests/outbox-sweep-flood-breaker.test.ts +221 -0
  53. package/telegram-plugin/tests/periodic-sweep-guard.test.ts +151 -0
  54. package/telegram-plugin/tests/pinned-card-collapse.test.ts +24 -18
  55. package/telegram-plugin/tests/pinned-message-handler.test.ts +15 -15
  56. package/telegram-plugin/tests/provider-credit-402.test.ts +243 -0
  57. package/telegram-plugin/tests/status-pin-api.test.ts +11 -11
  58. package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +36 -37
  59. package/telegram-plugin/tests/status-pin-lifecycle.test.ts +602 -0
  60. package/telegram-plugin/tests/status-pin-retarget.test.ts +244 -0
  61. package/telegram-plugin/tests/status-pin-service-message-suppression.test.ts +7 -3
  62. package/telegram-plugin/tests/status-pin-shutdown-wiring.test.ts +94 -0
  63. package/telegram-plugin/tests/status-pin-store.test.ts +179 -64
  64. package/telegram-plugin/tests/status-pin.test.ts +184 -7
  65. package/telegram-plugin/tests/test-runner-coverage.test.ts +133 -0
  66. package/telegram-plugin/tests/worker-activity-feed.test.ts +12 -10
  67. package/telegram-plugin/tests/worker-feed-coalesce.test.ts +29 -19
  68. package/telegram-plugin/tests/worker-feed-pin-persistence.test.ts +56 -59
  69. package/telegram-plugin/tests/worker-feed-terminal-edit-class.test.ts +335 -0
  70. package/telegram-plugin/tests/worker-visibility-prose-silent-harness.test.ts +1 -1
  71. package/telegram-plugin/tier-downgrade.ts +3 -2
  72. package/telegram-plugin/tool-activity-summary.ts +239 -322
  73. package/telegram-plugin/uat/assertions.ts +33 -3
  74. package/telegram-plugin/uat/feed-matcher.test.ts +36 -0
  75. package/telegram-plugin/uat/scenarios/jtbd-liveness-narration-channel.test.ts +9 -2
  76. package/telegram-plugin/uat/scenarios/jtbd-liveness-narration-dm.test.ts +9 -2
  77. package/telegram-plugin/worker-activity-feed.ts +109 -30
  78. package/vendor/hindsight-memory/CLAUDE.md +45 -0
  79. package/vendor/hindsight-memory/scripts/lib/config.py +33 -0
  80. package/vendor/hindsight-memory/scripts/recall.py +176 -7
  81. package/vendor/hindsight-memory/scripts/tests/test_config_recall_passthrough_env.py +170 -0
  82. package/vendor/hindsight-memory/scripts/tests/test_recall_min_score.py +464 -0
  83. package/vendor/hindsight-memory/scripts/tests/test_recall_request_timeout.py +241 -0
  84. package/vendor/hindsight-memory/settings.json +1 -1
@@ -2,8 +2,59 @@ import { describe, it, expect } from 'vitest'
2
2
  import { GrammyError } from 'grammy'
3
3
  import { decidePinAction, isPinRightsError, isUnpinTerminalError, PinRightsCache } from '../status-pin.js'
4
4
  import { makeFloodWaitActiveError, GIVE_UP_MESSAGE } from '../retry-api-call.js'
5
- import { reconcilePin, type PinBotApi } from '../status-pin-driver.js'
6
- import type { PinState } from '../status-pin.js'
5
+ import { executePinLeg, type PinBotApi } from '../status-pin-driver.js'
6
+ import type { DesiredPin, PinState, PinRightsCache as _RightsCache } from '../status-pin.js'
7
+ import {
8
+ runStatusPinReconcile,
9
+ type StatusPinClaim,
10
+ } from '../gateway/status-pin-retarget.js'
11
+
12
+ /**
13
+ * Drive a whole desired-state reconcile through the REAL unified path — the
14
+ * orchestrator (`runStatusPinReconcile`, which owns the single retarget
15
+ * expansion) composed with the driver (`executePinLeg`), exactly as gateway.ts
16
+ * wires them. `persist: null` mirrors the STATIC / feature-off binding.
17
+ *
18
+ * These cases used to call `reconcilePin(prev, desired)` directly, back when the
19
+ * driver re-decided the action itself and carried its OWN copy of the retarget
20
+ * expansion (#3831). The driver can no longer express a retarget, so exercising
21
+ * the same behaviours now means exercising the composition — which is what
22
+ * production actually runs.
23
+ */
24
+ async function reconcilePin(args: {
25
+ api: PinBotApi
26
+ chatId: string
27
+ prevState: PinState | null
28
+ desired: DesiredPin
29
+ rightsCache?: _RightsCache
30
+ onError?: (phase: 'pin' | 'unpin', err: unknown) => void
31
+ onPinRightsDisabled?: (chatId: string) => void
32
+ }): Promise<PinState | null> {
33
+ const claims = new Map<string, StatusPinClaim>()
34
+ if (args.prevState != null) {
35
+ claims.set('k', { messageId: args.prevState.messageId, chatId: args.chatId, pinnedAt: 1 })
36
+ }
37
+ await runStatusPinReconcile({
38
+ pinKey: 'k',
39
+ chatId: args.chatId,
40
+ prev: args.prevState,
41
+ desired: args.desired,
42
+ persist: null,
43
+ runPin: (action, from) =>
44
+ executePinLeg({
45
+ api: args.api,
46
+ chatId: args.chatId,
47
+ prevState: from,
48
+ action,
49
+ rightsCache: args.rightsCache,
50
+ onError: args.onError,
51
+ onPinRightsDisabled: args.onPinRightsDisabled,
52
+ }),
53
+ claims,
54
+ })
55
+ const claim = claims.get('k')
56
+ return claim == null ? null : { messageId: claim.messageId }
57
+ }
7
58
 
8
59
  /** A real GrammyError with the given code + description, mirroring the wire
9
60
  * shape Telegram returns for the pin-rights failure that crashed marko. */
@@ -37,9 +88,14 @@ describe('decidePinAction (pure)', () => {
37
88
  expect(action.kind).toBe('noop')
38
89
  })
39
90
 
40
- it('unpins the stale claim when the wanted message id changed (feed re-posted)', () => {
91
+ it('RETARGETS (unpin old + pin new) when the wanted message id changed (surface re-posted)', () => {
92
+ // Regression: this used to report a bare `unpin`, on the assumption that a
93
+ // later reconcile would pin the new message. Single-shot callers (the
94
+ // foreground activity card, which reconciles only when a card OPENs) have
95
+ // no later reconcile, so the new card was left unpinned for the rest of
96
+ // the turn. The decision must carry BOTH message ids.
41
97
  const action = decidePinAction({ messageId: 42 }, { pinned: true, messageId: 99 })
42
- expect(action).toEqual({ kind: 'unpin', messageId: 42 })
98
+ expect(action).toEqual({ kind: 'repin', unpinMessageId: 42, pinMessageId: 99 })
43
99
  })
44
100
  })
45
101
 
@@ -59,7 +115,7 @@ function fakeApi(opts: { pinThrows?: boolean; unpinThrows?: boolean } = {}) {
59
115
  return { api, calls }
60
116
  }
61
117
 
62
- describe('reconcilePin (driver)', () => {
118
+ describe('unified reconcile path (orchestrator + driver)', () => {
63
119
  it('pin on in-progress: pins the EXISTING message SILENTLY and claims it', async () => {
64
120
  const { api, calls } = fakeApi()
65
121
  const next = await reconcilePin({
@@ -177,6 +233,127 @@ describe('reconcilePin (driver)', () => {
177
233
  expect(attempts).toBe(2)
178
234
  })
179
235
 
236
+ // ── Retarget (`repin`) — the single-shot-caller leak ──────────────────────
237
+ // The foreground activity card reconciles its `fg:` pin EXACTLY ONCE, when a
238
+ // card OPENs (narrative-lane.ts; the edit branch never touches the pin). When
239
+ // the ack-first reopen path (feed-reopen-gate.ts) nulls `activityMessageId`
240
+ // and a FRESH card opens mid-turn, that single reconcile must leave the NEW
241
+ // card pinned. Previously it emitted only an unpin and the turn ran on with
242
+ // nothing pinned.
243
+ it('RETARGET: one reconcile unpins the stale message AND pins the new one', async () => {
244
+ const { api, calls } = fakeApi()
245
+ const next = await reconcilePin({
246
+ api,
247
+ chatId: '123',
248
+ prevState: { messageId: 42 },
249
+ desired: { pinned: true, messageId: 99 },
250
+ })
251
+ expect(next).toEqual({ messageId: 99 })
252
+ expect(calls.map((c) => [c.verb, c.messageId])).toEqual([
253
+ ['unpin', 42],
254
+ ['pin', 99],
255
+ ])
256
+ // The new pin must still be silent.
257
+ expect((calls[1].opts as Record<string, unknown>).disable_notification).toBe(true)
258
+ })
259
+
260
+ it('RETARGET: a TERMINAL unpin failure still pins the new message', async () => {
261
+ // "message to unpin not found" means the old surface is already gone — the
262
+ // retarget must complete, not abort.
263
+ const calls: { verb: string; messageId: number }[] = []
264
+ const next = await reconcilePin({
265
+ api: {
266
+ pinChatMessage: async (_c, message_id) => {
267
+ calls.push({ verb: 'pin', messageId: message_id })
268
+ },
269
+ unpinChatMessage: async (_c, message_id) => {
270
+ calls.push({ verb: 'unpin', messageId: message_id })
271
+ throw grammyError(400, 'Bad Request: message to unpin not found')
272
+ },
273
+ },
274
+ chatId: '123',
275
+ prevState: { messageId: 42 },
276
+ desired: { pinned: true, messageId: 99 },
277
+ })
278
+ expect(next).toEqual({ messageId: 99 })
279
+ expect(calls).toEqual([
280
+ { verb: 'unpin', messageId: 42 },
281
+ { verb: 'pin', messageId: 99 },
282
+ ])
283
+ })
284
+
285
+ it('RETARGET: a NEVER-CONFIRMED unpin retains the OLD claim and does NOT pin the new one', async () => {
286
+ // The old message is provably still pinned (#3664 Defect B). Pinning the
287
+ // new one now would leave two pins in the chat with a durable record of
288
+ // only one — the exact orphan class the retain rule exists to prevent.
289
+ const calls: { verb: string; messageId: number }[] = []
290
+ const next = await reconcilePin({
291
+ api: {
292
+ pinChatMessage: async (_c, message_id) => {
293
+ calls.push({ verb: 'pin', messageId: message_id })
294
+ },
295
+ unpinChatMessage: async (_c, message_id) => {
296
+ calls.push({ verb: 'unpin', messageId: message_id })
297
+ throw makeFloodWaitActiveError(300, Date.now() + 300_000, null)
298
+ },
299
+ },
300
+ chatId: '123',
301
+ prevState: { messageId: 42 },
302
+ desired: { pinned: true, messageId: 99 },
303
+ })
304
+ expect(next).toEqual({ messageId: 42 })
305
+ expect(calls).toEqual([{ verb: 'unpin', messageId: 42 }])
306
+ })
307
+
308
+ it('RETARGET: a failed pin leg drops the claim (nothing is pinned, nothing is tracked)', async () => {
309
+ const calls: { verb: string; messageId: number }[] = []
310
+ const next = await reconcilePin({
311
+ api: {
312
+ pinChatMessage: async (_c, message_id) => {
313
+ calls.push({ verb: 'pin', messageId: message_id })
314
+ throw new Error('fetch failed')
315
+ },
316
+ unpinChatMessage: async (_c, message_id) => {
317
+ calls.push({ verb: 'unpin', messageId: message_id })
318
+ },
319
+ },
320
+ chatId: '123',
321
+ prevState: { messageId: 42 },
322
+ desired: { pinned: true, messageId: 99 },
323
+ })
324
+ // prevState for the pin leg is null (the unpin landed), so a failed pin
325
+ // returns null — no phantom claim on a message we never pinned.
326
+ expect(next).toBeNull()
327
+ expect(calls).toEqual([
328
+ { verb: 'unpin', messageId: 42 },
329
+ { verb: 'pin', messageId: 99 },
330
+ ])
331
+ })
332
+
333
+ it('RETARGET: a pin-rights-blocked chat makes NO API calls and drops the claim', async () => {
334
+ const calls: string[] = []
335
+ const cache = new PinRightsCache()
336
+ cache.block('123')
337
+ const next = await reconcilePin({
338
+ api: {
339
+ pinChatMessage: async () => {
340
+ calls.push('pin')
341
+ },
342
+ unpinChatMessage: async () => {
343
+ calls.push('unpin')
344
+ },
345
+ },
346
+ chatId: '123',
347
+ prevState: { messageId: 42 },
348
+ desired: { pinned: true, messageId: 99 },
349
+ rightsCache: cache,
350
+ })
351
+ expect(calls).toEqual([])
352
+ // unpin leg returns null (claim dropped, nothing worth tracking); the pin
353
+ // leg is skipped from a null prev and returns null too.
354
+ expect(next).toBeNull()
355
+ })
356
+
180
357
  it('does NOT claim a message whose pin failed (retries next reconcile)', async () => {
181
358
  const errors: string[] = []
182
359
  const { api } = fakeApi({ pinThrows: true })
@@ -207,7 +384,7 @@ describe('reconcilePin (driver)', () => {
207
384
  // the whole gateway down. Auto status-pin is best-effort/cosmetic — a
208
385
  // pin-rights failure must be absorbed, never fatal, and never reach the
209
386
  // process-level unhandledRejection handler.
210
- describe('reconcilePin — pin-rights 400 must never crash (marko 2026-07-01)', () => {
387
+ describe('unified reconcile path — pin-rights 400 must never crash (marko 2026-07-01)', () => {
211
388
  it('absorbs the "not enough rights" 400 via onError and never rejects', async () => {
212
389
  const rightsErr = grammyError(
213
390
  400,
@@ -342,7 +519,7 @@ function countingApi(pinError?: unknown) {
342
519
  }
343
520
  }
344
521
 
345
- describe('reconcilePin — rights-aware negative cache (#3024)', () => {
522
+ describe('unified reconcile path — rights-aware negative cache (#3024)', () => {
346
523
  const rightsErr = () =>
347
524
  grammyError(400, 'Bad Request: not enough rights to manage pinned messages in the chat')
348
525
 
@@ -0,0 +1,133 @@
1
+ /**
2
+ * Fence for the "test file runs in NEITHER CI runner" class (#3756).
3
+ *
4
+ * The repo has two runners wired independently — vitest (everything minus
5
+ * `vitest.config.ts`'s `exclude`) and `bun test` with an explicit target list
6
+ * in `telegram-plugin/scripts/bun-test-ci.sh`. A file excluded from vitest and
7
+ * never named in the bun list is executed by nothing, and nothing goes red.
8
+ * That is how `telegram-plugin/uat/feed-matcher.test.ts` — a file whose own
9
+ * docblock calls it "the CI-verifiable floor" for the worker-feed matcher, and
10
+ * which #3821 extended with `stripCardNesting` assertions — sat unexecuted.
11
+ *
12
+ * The regression assertions below read the REAL config off disk, so they fail
13
+ * on `origin/main` at f9314b89e and can only be made green by actually giving
14
+ * those files a runner.
15
+ */
16
+ import { describe, it, expect } from "vitest";
17
+ // @ts-expect-error — plain .mjs lint script, no type declarations by design.
18
+ import { findOrphanTestFiles, loadRepoInputs } from "../../scripts/check-test-runner-coverage.mjs";
19
+
20
+ type Inputs = {
21
+ files: string[];
22
+ excludeGlobs: string[];
23
+ bunTargets: string[];
24
+ allowGlobs: string[];
25
+ };
26
+
27
+ const findOrphans = findOrphanTestFiles as (i: Inputs) => string[];
28
+ const realInputs = loadRepoInputs as () => Inputs;
29
+
30
+ describe("findOrphanTestFiles (detection logic)", () => {
31
+ const base: Inputs = { files: [], excludeGlobs: [], bunTargets: [], allowGlobs: [] };
32
+
33
+ it("flags a vitest-excluded file that no bun target names", () => {
34
+ expect(
35
+ findOrphans({
36
+ ...base,
37
+ files: ["telegram-plugin/uat/feed-matcher.test.ts"],
38
+ excludeGlobs: ["**/telegram-plugin/uat/**"],
39
+ }),
40
+ ).toEqual(["telegram-plugin/uat/feed-matcher.test.ts"]);
41
+ });
42
+
43
+ it("does not flag a file vitest still runs", () => {
44
+ expect(
45
+ findOrphans({ ...base, files: ["telegram-plugin/tests/status-pin.test.ts"] }),
46
+ ).toEqual([]);
47
+ });
48
+
49
+ it("does not flag a vitest-excluded file the bun job names by path", () => {
50
+ expect(
51
+ findOrphans({
52
+ ...base,
53
+ files: ["telegram-plugin/uat/feed-matcher.test.ts"],
54
+ excludeGlobs: ["**/telegram-plugin/uat/**"],
55
+ bunTargets: ["uat/feed-matcher.test.ts"],
56
+ }),
57
+ ).toEqual([]);
58
+ });
59
+
60
+ it("does not flag a vitest-excluded file the bun job covers by directory", () => {
61
+ expect(
62
+ findOrphans({
63
+ ...base,
64
+ files: ["telegram-plugin/tests/history.test.ts"],
65
+ excludeGlobs: ["**/telegram-plugin/tests/history.test.ts"],
66
+ bunTargets: ["tests"],
67
+ }),
68
+ ).toEqual([]);
69
+ });
70
+
71
+ it("a bun target only covers files UNDER telegram-plugin/ (that is the job's cwd)", () => {
72
+ // The bun job runs `working-directory: telegram-plugin`, so a repo-root
73
+ // path can never be matched by its filters — the exact reason the whole
74
+ // src/vault bun suite is orphaned.
75
+ expect(
76
+ findOrphans({
77
+ ...base,
78
+ files: ["src/vault/broker/server.test.ts"],
79
+ excludeGlobs: ["**/src/vault/broker/server.test.ts"],
80
+ bunTargets: ["src/vault/broker/server.test.ts"],
81
+ }),
82
+ ).toEqual(["src/vault/broker/server.test.ts"]);
83
+ });
84
+
85
+ it("honours the allowlist", () => {
86
+ expect(
87
+ findOrphans({
88
+ ...base,
89
+ files: ["telegram-plugin/uat/scenarios/smoke-dm-reply.test.ts"],
90
+ excludeGlobs: ["**/telegram-plugin/uat/**"],
91
+ allowGlobs: ["**/telegram-plugin/uat/scenarios/**"],
92
+ }),
93
+ ).toEqual([]);
94
+ });
95
+ });
96
+
97
+ describe("real repo configuration", () => {
98
+ it("has no un-allowlisted orphans", () => {
99
+ expect(findOrphans(realInputs())).toEqual([]);
100
+ });
101
+
102
+ // The specific regression. All five are hosted (no creds, no live Telegram)
103
+ // and were swallowed wholesale by vitest's `**/telegram-plugin/uat/**`
104
+ // exclude with nothing on the bun side naming them.
105
+ it.each([
106
+ "telegram-plugin/uat/feed-matcher.test.ts",
107
+ "telegram-plugin/uat/load-env.test.ts",
108
+ "telegram-plugin/uat/uat-driver.test.ts",
109
+ "telegram-plugin/uat/runners/scorer.test.ts",
110
+ "telegram-plugin/uat/runners/skill-coverage.test.ts",
111
+ ])("%s is executed by a CI runner", (file) => {
112
+ const inputs = realInputs();
113
+ expect(inputs.files).toContain(file);
114
+ // Not merely allowlisted away — genuinely covered by a runner.
115
+ expect(findOrphans({ ...inputs, allowGlobs: [] })).not.toContain(file);
116
+ });
117
+
118
+ it("no bun target is broad enough to drag in the live-Telegram scenarios", () => {
119
+ // The other half of the contract. `uat/scenarios/**` drives a real MTProto
120
+ // session; a bare `uat` filter in BUN_TEST_ARGS would pull every scenario
121
+ // into the ordinary bun-test job, where it would fail (or worse, message a
122
+ // real chat). Assert no scenario file is reachable from the bun list.
123
+ const inputs = realInputs();
124
+ const scenarios = inputs.files.filter((f) =>
125
+ f.startsWith("telegram-plugin/uat/scenarios/"),
126
+ );
127
+ expect(scenarios.length).toBeGreaterThan(50);
128
+ const reachable = scenarios.filter((f) =>
129
+ inputs.bunTargets.some((t) => f.slice("telegram-plugin/".length).includes(t)),
130
+ );
131
+ expect(reachable).toEqual([]);
132
+ });
133
+ });
@@ -76,7 +76,8 @@ describe('renderWorkerActivity', () => {
76
76
 
77
77
  it('renders the native header + running status + step feed', () => {
78
78
  const out = renderWorkerActivity(view())
79
- expect(out).toContain('🛠 **Worker** · _research competitors_')
79
+ // #3842: flush at the left margin — no `└─ ` card-level prefix.
80
+ expect(out.startsWith('🛠 **WORKER** · _research competitors_')).toBe(true)
80
81
  // Unified header: running shows "<elapsed> · N tools" (no "running ·" word).
81
82
  expect(out).toContain('_10s · 3 tools_')
82
83
  expect(out).toContain('3 tools')
@@ -100,7 +101,8 @@ describe('renderWorkerActivity', () => {
100
101
 
101
102
  it('shows a "starting…" line when no step has run yet', () => {
102
103
  const out = renderWorkerActivity(view({ lastTool: null, latestSummary: '' }))
103
- expect(out).toContain('🛠 **Worker**')
104
+ // #3842: flush at the left margin — no `└─ ` card-level prefix.
105
+ expect(out.startsWith('🛠 **WORKER**')).toBe(true)
104
106
  expect(out).toContain('starting…')
105
107
  expect(out).not.toContain('→')
106
108
  })
@@ -121,7 +123,7 @@ describe('renderWorkerActivity', () => {
121
123
  const out = renderWorkerActivity(
122
124
  view({ state: 'done', toolCount: 5, latestSummary: 'PR #21 opened' }),
123
125
  )
124
- expect(out).toContain('🛠 **Worker** · _research competitors_')
126
+ expect(out).toContain('🛠 **WORKER** · _research competitors_')
125
127
  // Unified done header: "done · N tools · <elapsed>".
126
128
  expect(out).toContain('_done · 5 tools · ')
127
129
  expect(out).toContain('─────')
@@ -227,7 +229,7 @@ describe('renderWorkerActivity', () => {
227
229
  narrativeLines: ['- ran the **full** suite', '`git push`'],
228
230
  }),
229
231
  )
230
- expect(running).toContain('🛠 **Worker** · _Build the sync_')
232
+ expect(running).toContain('🛠 **WORKER** · _Build the sync_')
231
233
  expect(running).toContain('ran the full suite')
232
234
  expect(running).toContain('git push')
233
235
  // The CONTENT bold/code markers were stripped — no `**full**`, no backticks.
@@ -298,7 +300,7 @@ describe('createWorkerActivityFeed', () => {
298
300
  // #2669: the worker feed body is raw GFM markdown sent through the rich
299
301
  // path (the gateway wires sendMessage → sendRichMessage). No parse_mode.
300
302
  expect(bot.sent[0].opts?.parse_mode).toBeUndefined()
301
- expect(bot.sent[0].text).toContain('🛠 **Worker**')
303
+ expect(bot.sent[0].text).toContain('🛠 **WORKER**')
302
304
  expect(feed.has('w1')).toBe(true)
303
305
  })
304
306
 
@@ -932,7 +934,7 @@ describe('createWorkerActivityFeed — heartbeat', () => {
932
934
  await drain()
933
935
  expect(bot.sent).toHaveLength(1)
934
936
  expect(feed.messageIdOf('w1')).toBe(1000)
935
- expect(bot.sent[0].text).toContain('🛠 **Worker**')
937
+ expect(bot.sent[0].text).toContain('🛠 **WORKER**')
936
938
 
937
939
  // And it keeps updating: a later heartbeat edits the message with a
938
940
  // climbing `· Ns` suffix so the still-alive worker visibly advances.
@@ -1328,7 +1330,7 @@ describe('header row + rolling overflow survive in the unified worker render', (
1328
1330
  )
1329
1331
  const out = renderWorkerActivity(view({ narrativeLines, toolCount: 7 }))
1330
1332
 
1331
- expect(out).toContain('🛠 **Worker**')
1333
+ expect(out).toContain('🛠 **WORKER**')
1332
1334
  // Unified running status line.
1333
1335
  expect(out).toContain('_10s · 7 tools_')
1334
1336
  expect(out).toContain('7 tools')
@@ -1344,7 +1346,7 @@ describe('header row + rolling overflow survive in the unified worker render', (
1344
1346
 
1345
1347
  expect(out.length).toBeLessThanOrEqual(4096)
1346
1348
  expect(out).toContain('earlier…')
1347
- expect(out).toContain('🛠 **Worker**')
1349
+ expect(out).toContain('🛠 **WORKER**')
1348
1350
  expect(out).toContain('_10s · ')
1349
1351
  })
1350
1352
 
@@ -1359,7 +1361,7 @@ describe('header row + rolling overflow survive in the unified worker render', (
1359
1361
  expect(out.length).toBeLessThanOrEqual(4096)
1360
1362
  const hasBullet = out.includes('→') || out.includes('✓')
1361
1363
  expect(hasBullet).toBe(true)
1362
- expect(out).toContain('🛠 **Worker**')
1364
+ expect(out).toContain('🛠 **WORKER**')
1363
1365
  expect(out).toContain('_10s · ')
1364
1366
  expect(isValidWorkerMarkdown(out)).toBe(true)
1365
1367
  })
@@ -1525,7 +1527,7 @@ describe('worker-feed send-gate shed contract', () => {
1525
1527
  await feed.update('w1', 'chat', view({ elapsedMs: clock }))
1526
1528
  expect(bot.sendCalls).toBe(1)
1527
1529
  expect(bot.sent).toHaveLength(1)
1528
- expect(bot.sent[0].text).toContain('🛠 **Worker**')
1530
+ expect(bot.sent[0].text).toContain('🛠 **WORKER**')
1529
1531
  expect(feed.has('w1')).toBe(true)
1530
1532
  })
1531
1533
 
@@ -661,7 +661,7 @@ describe('renderCombinedWorkerFeed (pure)', () => {
661
661
 
662
662
  it('renders one row-block per worker with a running count header', () => {
663
663
  const body = renderCombinedWorkerFeed([row(1, 'alpha step'), row(2, 'beta step')], { maxRows: 8 })!
664
- expect(body).toContain('Workers')
664
+ expect(body).toContain('WORKERS')
665
665
  expect(body).toContain('2 running')
666
666
  expect(body).toContain('task number 1')
667
667
  expect(body).toContain('task number 2')
@@ -756,7 +756,7 @@ describe('renderCombinedWorkerFeed (pure)', () => {
756
756
  .map((l) => l.trim())
757
757
  .filter((l) => l.length > 0)
758
758
  const headerAndHistory = bodyLines.filter(
759
- (l) => !l.startsWith('🛠') && !l.includes('more working'),
759
+ (l) => !l.includes('**WORKERS**') && !l.includes('more working'),
760
760
  )
761
761
  expect(headerAndHistory.length).toBeLessThanOrEqual(24)
762
762
  })
@@ -775,7 +775,7 @@ describe('renderCombinedWorkerFeed (pure)', () => {
775
775
  .map((l) => l.trim())
776
776
  .filter((l) => l.length > 0)
777
777
  const headerAndHistory = bodyLines.filter(
778
- (l) => !l.startsWith('🛠') && !l.includes('more working'),
778
+ (l) => !l.includes('**WORKERS**') && !l.includes('more working'),
779
779
  )
780
780
  expect(headerAndHistory.length).toBeLessThanOrEqual(24)
781
781
  expect(body).toContain('+2 more working')
@@ -1287,14 +1287,21 @@ describe('combined worker card — steps indent under their worker (U+2800)', ()
1287
1287
  // Load-bearing: the exact indent bytes. `WORKER_STEP_INDENT` is U+2800 ×3;
1288
1288
  // a plain-ASCII `' '` indent fails this assertion, so does the U+00A0 run
1289
1289
  // #3662 shipped, and so does flat output with no prefix at all.
1290
+ // #3842 removed the whole-card nesting #3820 added, so a step sits at
1291
+ // EXACTLY one WORKER_STEP_INDENT — not two — and its worker header sits
1292
+ // flush at the left margin. The RELATIVE one-level nesting this test guards
1293
+ // is unchanged; the whole block shifted back left.
1290
1294
  for (const l of stepLines) {
1291
1295
  expect(l.startsWith(WORKER_STEP_INDENT)).toBe(true)
1296
+ expect(l.startsWith(WORKER_STEP_INDENT + WORKER_STEP_INDENT)).toBe(false)
1292
1297
  expect(l.startsWith(BLANK)).toBe(true)
1293
1298
  expect(l.startsWith(' ')).toBe(false)
1294
1299
  expect(l.startsWith(NBSP)).toBe(false)
1295
1300
  }
1296
- // Workers stay at the left margin so the nesting reads as nesting.
1297
- for (const l of headerLines) expect(l.startsWith(BLANK)).toBe(false)
1301
+ // Workers stay one level ABOVE their steps so the nesting reads as nesting.
1302
+ for (const l of headerLines) {
1303
+ expect(l.startsWith(WORKER_STEP_INDENT)).toBe(false)
1304
+ }
1298
1305
  expect(lines[0].startsWith('🛠')).toBe(true)
1299
1306
  })
1300
1307
 
@@ -1306,6 +1313,7 @@ describe('combined worker card — steps indent under their worker (U+2800)', ()
1306
1313
  // is the actual thing the fix is for: a step must sit under its own worker.
1307
1314
  const body = renderCombinedWorkerFeed(rowsFor(3), { maxRows: 8 })!
1308
1315
  const I = WORKER_STEP_INDENT
1316
+ const N = ''
1309
1317
  // `S` is the pinned-bar collapse separator (#3666): every line that is
1310
1318
  // followed by a hard break carries one trailing U+00A0 so the pinned-message
1311
1319
  // bar (which drops the newline and substitutes nothing) does not mash the
@@ -1314,14 +1322,14 @@ describe('combined worker card — steps indent under their worker (U+2800)', ()
1314
1322
  // `WORKER_STEP_INDENT`, which is what this golden is really pinning.
1315
1323
  const S = COLLAPSE_SAFE_SEPARATOR
1316
1324
  expect(linesOf(body)).toEqual([
1317
- `🛠 **Workers** · _3 running · oldest 3m00s · 9 tools_${S}`,
1318
- `**1. worker task 1** _· 1m00s · 3 tools_${S}`,
1325
+ `🛠 **WORKERS** · _3 running · oldest 3m00s · 9 tools_${S}`,
1326
+ `${N}**1. worker task 1** _· 1m00s · 3 tools_${S}`,
1319
1327
  `${I}~~_✓ w1 step a_~~${S}`,
1320
1328
  `${I}**→ w1 step b**${S}`,
1321
- `**2. worker task 2** _· 2m00s · 3 tools_${S}`,
1329
+ `${N}**2. worker task 2** _· 2m00s · 3 tools_${S}`,
1322
1330
  `${I}~~_✓ w2 step a_~~${S}`,
1323
1331
  `${I}**→ w2 step b**${S}`,
1324
- `**3. worker task 3** _· 3m00s · 3 tools_${S}`,
1332
+ `${N}**3. worker task 3** _· 3m00s · 3 tools_${S}`,
1325
1333
  `${I}~~_✓ w3 step a_~~${S}`,
1326
1334
  `${I}**→ w3 step b**`, // last line: nothing follows it to collide with
1327
1335
  ])
@@ -1442,7 +1450,7 @@ describe('combined worker card — steps indent under their worker (U+2800)', ()
1442
1450
  expect(ents.every((e) => !e.text.includes(NBSP))).toBe(true)
1443
1451
  })
1444
1452
 
1445
- it('the SINGLE-worker 🛠 card is untouched (no indent on its step lines)', () => {
1453
+ it('the SINGLE-worker 🛠 card is entirely FLUSH — no card indent, no step indent (#3842)', () => {
1446
1454
  const single = renderWorkerActivity({
1447
1455
  workerId: 'w1',
1448
1456
  description: 'lone worker',
@@ -1454,16 +1462,18 @@ describe('combined worker card — steps indent under their worker (U+2800)', ()
1454
1462
  })
1455
1463
  expect(single).toContain('~~_✓ step a_~~')
1456
1464
  expect(single).toContain('**→ step b**')
1457
- // No LEADING indent: the single-worker card has nothing to nest under, so
1458
- // its step lines must sit flush at the left margin. Asserted per line on the
1459
- // leading edge rather than as "no U+00A0 anywhere in the card", because
1460
- // #3666 puts one TRAILING U+00A0 on every hard-broken line of every pinned
1461
- // card (this one included) as the collapse separator. A regression that
1462
- // leaked WORKER_STEP_INDENT onto these lines still fails here.
1463
- for (const l of single.split('\n')) {
1464
- expect(l.startsWith(BLANK)).toBe(false)
1465
- expect(l.startsWith(NBSP)).toBe(false)
1465
+ // The lone-worker card has no worker HEADER to nest steps under, so there is
1466
+ // nothing for an indent to distinguish — and #3842 removed the whole-card
1467
+ // nesting #3820 added, so EVERY line sits at the left margin. Asserted per
1468
+ // line on the leading edge rather than as "no U+2800 anywhere in the card",
1469
+ // because #3666 puts one TRAILING U+00A0 on every hard-broken line of every
1470
+ // pinned card (this one included) as the collapse separator.
1471
+ const ls = single.split('\n')
1472
+ expect(ls[0].startsWith('🛠 **WORKER**')).toBe(true)
1473
+ for (const l of ls) {
1466
1474
  expect(l.startsWith(WORKER_STEP_INDENT)).toBe(false)
1475
+ expect(l.startsWith('└─')).toBe(false)
1476
+ expect(l.startsWith(NBSP)).toBe(false)
1467
1477
  }
1468
1478
  })
1469
1479
  })