switchroom 0.19.19 → 0.19.23

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 (76) hide show
  1. package/dist/agent-scheduler/index.js +2 -1
  2. package/dist/auth-broker/index.js +121 -1
  3. package/dist/cli/notion-write-pretool.mjs +2 -1
  4. package/dist/cli/switchroom.js +2995 -1583
  5. package/dist/host-control/main.js +122 -2
  6. package/dist/vault/approvals/kernel-server.js +124 -4
  7. package/dist/vault/broker/server.js +124 -4
  8. package/package.json +7 -4
  9. package/profiles/_base/start.sh.hbs +101 -0
  10. package/profiles/_shared/agent-self-service.md.hbs +64 -109
  11. package/profiles/_shared/delegation-golden-rule.md.hbs +5 -5
  12. package/profiles/_shared/dev-protocol.md.hbs +13 -42
  13. package/profiles/_shared/execution-discipline.md.hbs +7 -14
  14. package/profiles/coding/CLAUDE.md.hbs +0 -6
  15. package/profiles/default/CLAUDE.md.hbs +21 -50
  16. package/skills/dev-protocol/SKILL.md +90 -107
  17. package/skills/switchroom-release/SKILL.md +103 -20
  18. package/telegram-plugin/bunfig.toml +10 -0
  19. package/telegram-plugin/card-format.ts +92 -3
  20. package/telegram-plugin/dist/gateway/gateway.js +873 -184
  21. package/telegram-plugin/edit-flood-fuse.ts +477 -0
  22. package/telegram-plugin/format.ts +19 -7
  23. package/telegram-plugin/gateway/backstop-delivery.ts +97 -16
  24. package/telegram-plugin/gateway/boot-sweep-gate.ts +164 -0
  25. package/telegram-plugin/gateway/callback-query-handlers.ts +454 -81
  26. package/telegram-plugin/gateway/captured-answer-resume.ts +46 -17
  27. package/telegram-plugin/gateway/gateway.ts +75 -63
  28. package/telegram-plugin/gateway/inbound-interceptors.ts +27 -4
  29. package/telegram-plugin/gateway/narrative-lane.ts +49 -3
  30. package/telegram-plugin/gateway/outbound-send-path.ts +8 -1
  31. package/telegram-plugin/gateway/status-pin-api.ts +145 -0
  32. package/telegram-plugin/gateway/stream-render.ts +6 -0
  33. package/telegram-plugin/gateway/turn-record-status.ts +19 -0
  34. package/telegram-plugin/gateway/turns-jsonl-rotate.ts +65 -0
  35. package/telegram-plugin/hooks/subagent-tracker-posttool.mjs +325 -45
  36. package/telegram-plugin/retry-api-call.ts +15 -2
  37. package/telegram-plugin/send-gate.ts +1 -1
  38. package/telegram-plugin/status-no-truncate.ts +64 -1
  39. package/telegram-plugin/status-pin-driver.ts +50 -27
  40. package/telegram-plugin/status-pin.ts +43 -5
  41. package/telegram-plugin/tests/activity-card-send-gate.test.ts +275 -0
  42. package/telegram-plugin/tests/activity-card-wiring.test.ts +16 -7
  43. package/telegram-plugin/tests/agent-state-dir-preload.test.ts +33 -0
  44. package/telegram-plugin/tests/backstop-delivery.test.ts +204 -7
  45. package/telegram-plugin/tests/backstop-readback-probe.test.ts +12 -0
  46. package/telegram-plugin/tests/boot-pin-sweep-wiring.test.ts +101 -0
  47. package/telegram-plugin/tests/boot-sweep-gate.test.ts +293 -0
  48. package/telegram-plugin/tests/boot-version-string.test.ts +0 -0
  49. package/telegram-plugin/tests/captured-answer-resume.test.ts +104 -0
  50. package/telegram-plugin/tests/edit-flood-fuse.test.ts +431 -0
  51. package/telegram-plugin/tests/pinned-card-collapse.test.ts +356 -0
  52. package/telegram-plugin/tests/status-pin-api.test.ts +178 -0
  53. package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +94 -11
  54. package/telegram-plugin/tests/status-pin.test.ts +106 -5
  55. package/telegram-plugin/tests/subagent-tracker-hooks.test.ts +631 -1
  56. package/telegram-plugin/tests/tool-activity-summary.test.ts +19 -10
  57. package/telegram-plugin/tests/turns-jsonl-rotate.test.ts +92 -1
  58. package/telegram-plugin/tests/vault-approval-posture.test.ts +6 -1
  59. package/telegram-plugin/tests/vault-passphrase-retry.test.ts +666 -0
  60. package/telegram-plugin/tests/vault-request-access-unlock-resume.test.ts +42 -21
  61. package/telegram-plugin/tests/worker-feed-coalesce.test.ts +233 -1
  62. package/telegram-plugin/tool-activity-summary.ts +85 -13
  63. package/telegram-plugin/worker-activity-feed.ts +5 -1
  64. package/vendor/hindsight-memory/scripts/drain_pending.py +304 -34
  65. package/vendor/hindsight-memory/scripts/lib/pending.py +886 -70
  66. package/vendor/hindsight-memory/scripts/lib/retain_split.py +71 -13
  67. package/vendor/hindsight-memory/scripts/recall.py +74 -5
  68. package/vendor/hindsight-memory/scripts/tests/test_pending_drops.py +1602 -14
  69. package/vendor/hindsight-memory/scripts/tests/test_pending_failure_class.py +105 -0
  70. package/vendor/hindsight-memory/scripts/tests/test_pending_wedge.py +300 -0
  71. package/vendor/hindsight-memory/scripts/tests/test_recall_degraded_notice.py +365 -0
  72. package/vendor/hindsight-memory/scripts/tests/test_recall_envelope_strip_telemetry.py +12 -4
  73. package/vendor/hindsight-memory/scripts/tests/test_recall_transcript_fallback.py +27 -2
  74. package/vendor/hindsight-memory/scripts/tests/test_retain_split.py +93 -13
  75. package/vendor/hindsight-memory/tests/test_drain_pending.py +44 -3
  76. package/vendor/hindsight-memory/tests/test_pending.py +12 -4
@@ -0,0 +1,145 @@
1
+ /**
2
+ * status-pin-api.ts — the Bot API surface the status-pin driver calls, bound to
3
+ * the gateway's retry policy + send gate (#3664).
4
+ *
5
+ * Extracted out of `gateway.ts` (which is under a line ratchet,
6
+ * `scripts/check-gateway-line-ratchet.mjs`) so the two invariants below can be
7
+ * enforced in code and proven in isolation, instead of resting on defaults that
8
+ * live in other modules.
9
+ *
10
+ * INVARIANT 1 — the bot must exist (`assertBotReady`).
11
+ * `lockedBot` in gateway.ts is declared `let lockedBot!: Bot<Context>`. The
12
+ * definite-assignment `!` is an ASSERTION, not a guarantee: it told `tsc` to
13
+ * stop checking, which is exactly why the boot orphan sweep dereferencing an
14
+ * unset `lockedBot` at module-eval time compiled clean and shipped. Every unpin
15
+ * it issued failed with the opaque
16
+ * `undefined is not an object (evaluating 'lockedBot.api')`, which read like a
17
+ * Telegram fault for a month. The ordering FIX is the two-condition gate in
18
+ * `boot-sweep-gate.ts`; this is the BACKSTOP, so any future pre-ready caller
19
+ * gets a named, greppable `STATUS_PIN_BOT_NOT_READY` instead.
20
+ *
21
+ * INVARIANT 2 — a shed send must not look like a landed one (`assertLanded`).
22
+ * See the docblock on `assertLanded`.
23
+ */
24
+
25
+ import type { PinBotApi } from '../status-pin-driver.js'
26
+ import { SEND_GATE_SHED } from '../send-gate.js'
27
+
28
+ /** Thrown when the pin API is used before `lockedBot` has been assigned. */
29
+ export class BotNotReadyError extends Error {
30
+ constructor(what: string) {
31
+ super(
32
+ `STATUS_PIN_BOT_NOT_READY: ${what} used before initGatewayBot() assigned ` +
33
+ `lockedBot — the boot sweep must run behind the boot-sweep gate`,
34
+ )
35
+ this.name = 'BotNotReadyError'
36
+ }
37
+ }
38
+
39
+ /** Narrow `bot` to non-null or throw {@link BotNotReadyError}. */
40
+ export function assertBotReady<T>(bot: T | undefined | null, what: string): T {
41
+ if (bot == null) throw new BotNotReadyError(what)
42
+ return bot
43
+ }
44
+
45
+ /** Thrown when the outbound send gate SHED a pin/unpin — it never reached Telegram. */
46
+ export class SendGateShedError extends Error {
47
+ constructor(verb: string) {
48
+ super(`STATUS_PIN_SEND_SHED: ${verb} was shed by the send gate and never reached Telegram`)
49
+ this.name = 'SendGateShedError'
50
+ }
51
+ }
52
+
53
+ /**
54
+ * Convert a send-gate SHED into a throw.
55
+ *
56
+ * The pin state machine reads "the call did not throw" as "the pin/unpin
57
+ * LANDED": `reconcilePin` claims the message on a resolved pin, and treats a
58
+ * resolved unpin as confirmed — dropping the in-memory claim AND (via the null
59
+ * branch of `reconcileAndPersistStatusPin`) the durable `status-pins.json` row.
60
+ * A send that was DROPPED but resolved success-shaped therefore reopens #3664
61
+ * Defect B through a second door: the still-pinned message loses its last
62
+ * record and no reaper or boot sweep can ever find it again.
63
+ *
64
+ * `robustApiCall` does not throw on a shed — it RESOLVES the gate's
65
+ * `SEND_GATE_SHED` sentinel (send-gate.ts). Today a status-pin call
66
+ * cannot be shed, because it is an untagged SEND and untagged sends admit as
67
+ * `UNTAGGED_SEND_CLASS`, which is `'critical'` and is never shed.
68
+ * But that is a DEFAULT IN ANOTHER MODULE: tagging `status-pin.unpin` with a
69
+ * `priorityClass`, or changing that default, would silently reopen the defect
70
+ * with no test failing. This turns the default into an enforced invariant.
71
+ *
72
+ * Sentinel ONLY. A plain `undefined` is deliberately NOT a failure: the gate
73
+ * also resolves `undefined` for a benign no-op drop (identical payload already
74
+ * on screen) and the retry policy resolves `undefined` for swallowed benign
75
+ * 400s. Conflating those with a shed is the exact ambiguity `SEND_GATE_SHED`
76
+ * was introduced to remove (see its docblock in send-gate.ts).
77
+ *
78
+ * Known residual: a `useful`-classed send whose queue TTL EXPIRES also resolves
79
+ * `undefined` (`send-gate.ts`, `outcome.result === 'expired'`) and so is not
80
+ * caught here. That is unreachable for status pins — they are untagged, hence
81
+ * `critical`, which is never TTL-dropped — and closing it would mean treating
82
+ * every benign `undefined` as a failure, which is strictly worse. If a status
83
+ * pin is ever deliberately tagged `useful`, this needs a distinguishable
84
+ * expiry sentinel too.
85
+ */
86
+ export function assertLanded(result: unknown, verb: string): unknown {
87
+ if (result === SEND_GATE_SHED) throw new SendGateShedError(verb)
88
+ return result
89
+ }
90
+
91
+ /** Minimal shape of the wrapped gateway bot this module needs. */
92
+ export interface PinCapableBot {
93
+ api: {
94
+ pinChatMessage: (
95
+ chatId: string | number,
96
+ messageId: number,
97
+ opts?: Record<string, unknown>,
98
+ ) => Promise<unknown>
99
+ unpinChatMessage: (chatId: string | number, messageId: number) => Promise<unknown>
100
+ }
101
+ }
102
+
103
+ /**
104
+ * The gateway's `robustApiCall` seam (retry policy + send gate), erased to the
105
+ * shape this module needs so `gateway.ts` can pass it with a single cast.
106
+ */
107
+ export type RobustApiSeam = (
108
+ fn: () => Promise<unknown>,
109
+ opts: Record<string, unknown>,
110
+ ) => Promise<unknown>
111
+
112
+ /**
113
+ * Build the pin API.
114
+ *
115
+ * `getBot` is read LAZILY on every call — `lockedBot` is assigned late, inside
116
+ * `initGatewayBot()` — and asserted non-null (INVARIANT 1). `robust` is the
117
+ * gateway's `robustApiCall`, so pins/unpins ride the send gate and retry policy
118
+ * exactly as before this extraction, with the shed sentinel converted to a
119
+ * throw on the way out (INVARIANT 2).
120
+ */
121
+ export function createStatusPinApi(
122
+ getBot: () => PinCapableBot | undefined,
123
+ robust: RobustApiSeam,
124
+ ): PinBotApi {
125
+ const call = (verb: string, fn: (bot: PinCapableBot) => Promise<unknown>, chatId: string) =>
126
+ robust(() => fn(assertBotReady(getBot(), verb)), { chat_id: chatId, verb }).then((r) =>
127
+ assertLanded(r, verb),
128
+ )
129
+ return {
130
+ pinChatMessage: (chat_id, message_id, opts) =>
131
+ call(
132
+ 'status-pin.pin',
133
+ // allow-raw-bot-api: `call` runs this inside the injected `robust` seam (robustApiCall).
134
+ (bot) => bot.api.pinChatMessage(chat_id, message_id, opts),
135
+ String(chat_id),
136
+ ),
137
+ unpinChatMessage: (chat_id, message_id) =>
138
+ call(
139
+ 'status-pin.unpin',
140
+ // allow-raw-bot-api: as above — wrapped by the injected `robust` seam.
141
+ (bot) => bot.api.unpinChatMessage(chat_id, message_id),
142
+ String(chat_id),
143
+ ),
144
+ }
145
+ }
@@ -1856,6 +1856,12 @@ export function handleSessionEvent(deps: StreamRenderDeps, ev: SessionEvent): vo
1856
1856
  sentIds = delivery.sentIds
1857
1857
  chunkCount = delivery.chunkCount
1858
1858
  delivered = delivery.delivered
1859
+ // #3702 — how many landed ids the read-back never corroborated.
1860
+ // Stamped on the turn so `emitTurnRecord` writes `landed_unconfirmed`
1861
+ // (omitted when 0): the fleet-visible counter for deliveries we call
1862
+ // `complete` on the Bot API's ack alone, because the probe is
1863
+ // inconclusive. Observational only — it never changes the status.
1864
+ turn.landedUnconfirmed = delivery.landedUnconfirmed
1859
1865
 
1860
1866
  // #546 dedup: record what turn-flush just sent so a late-arriving
1861
1867
  // reply / stream_reply with the same content gets suppressed.
@@ -148,6 +148,20 @@ export interface TurnRecordRow {
148
148
  tools: number
149
149
  status: TurnStatus
150
150
  turn_id: string
151
+ /**
152
+ * How many landed message ids of this turn's backstop delivery the read-back
153
+ * probe never corroborated (`sentIds` minus the confirmed subset). OMITTED
154
+ * when zero, so an ordinary row is byte-identical to before.
155
+ *
156
+ * This is the measurable counterpart of the delivery verdict: since an
157
+ * inconclusive probe counts as delivered, a `complete` row carrying
158
+ * `landed_unconfirmed > 0` is a turn we called delivered on the Bot API's
159
+ * ack alone. Counting those is how the fleet can tell whether that optimism
160
+ * is ever wrong (a `landed_unconfirmed` turn followed by a "you never
161
+ * answered me" is the falsifying observation). It is NOT a failure signal and
162
+ * nothing escalates on it.
163
+ */
164
+ landed_unconfirmed?: number
151
165
  }
152
166
 
153
167
  /**
@@ -165,6 +179,7 @@ export function buildTurnRecord(
165
179
  turnId: string
166
180
  finalAnswerDelivered: boolean
167
181
  deliveryOutcome?: DeliveryOutcome
182
+ landedUnconfirmed?: number
168
183
  },
169
184
  endedAt: number,
170
185
  ): TurnRecordRow {
@@ -175,5 +190,9 @@ export function buildTurnRecord(
175
190
  tools: turn.toolCallCount ?? 0,
176
191
  status: computeTurnStatus(turn),
177
192
  turn_id: turn.turnId,
193
+ // Emitted ONLY when non-zero (see `TurnRecordRow.landed_unconfirmed`).
194
+ ...(turn.landedUnconfirmed != null && turn.landedUnconfirmed > 0
195
+ ? { landed_unconfirmed: turn.landedUnconfirmed }
196
+ : {}),
178
197
  }
179
198
  }
@@ -13,6 +13,71 @@
13
13
  */
14
14
  export const TURNS_JSONL_MAX_BYTES = 5 * 1024 * 1024 // 5 MiB
15
15
 
16
+ /** The agent state dir inside a switchroom agent container (bind-mounted to
17
+ * `~/.switchroom/agents/<name>/` on the host). */
18
+ export const DEFAULT_AGENT_STATE_DIR = '/state/agent'
19
+
20
+ /**
21
+ * Resolve the agent state dir from the environment — the ONE reader for
22
+ * `SWITCHROOM_AGENT_STATE_DIR` that every writer into that dir must use.
23
+ *
24
+ * Normalisation is the point. The gateway had two writers into this dir a few
25
+ * lines apart (the context-occupancy snapshot and the turn record) reading the
26
+ * env var with two different expressions: a bare
27
+ * `process.env.SWITCHROOM_AGENT_STATE_DIR ?? '/state/agent'` and this one. For
28
+ * a value like `"/x/ "` or `"/x/"` those resolve to DIFFERENT directories, so
29
+ * the two artifacts of the same turn would land in two places — exactly the
30
+ * kind of split-brain state that made the turn-record leak hard to see. Both
31
+ * call sites now share this function.
32
+ *
33
+ * Blank/whitespace-only is treated as unset (a compose file that renders an
34
+ * empty value must not send state to `/turns.jsonl` at the filesystem root),
35
+ * and a trailing slash is stripped so joins never double it.
36
+ */
37
+ export function resolveAgentStateDir(
38
+ env: Record<string, string | undefined> = process.env,
39
+ ): string {
40
+ const dir = env.SWITCHROOM_AGENT_STATE_DIR?.trim()
41
+ return dir != null && dir !== '' ? dir.replace(/\/+$/, '') : DEFAULT_AGENT_STATE_DIR
42
+ }
43
+
44
+ /**
45
+ * Resolve the turn-record path from the environment.
46
+ *
47
+ * `emitTurnRecord` used to hard-code `/state/agent/turns.jsonl`, ignoring
48
+ * `SWITCHROOM_AGENT_STATE_DIR` — which the sibling context-occupancy writer a
49
+ * few lines above it in `gateway.ts` already honours. Inside an agent container
50
+ * that path is the bind-mounted PRODUCTION `~/.switchroom/agents/<name>/
51
+ * turns.jsonl`, so any test that drove the real turn-end funnel while running
52
+ * in an agent container appended its synthetic rows straight into that agent's
53
+ * production turn record — even when the test had pointed every state-dir env
54
+ * var at a tmpdir. Those rows are then read back by the fleet-health L0 sensor
55
+ * (`src/fleet-health/scan.ts`) as that agent's real production turns.
56
+ *
57
+ * Honouring the env var is the root-cause fix: production containers do not set
58
+ * it (default unchanged), and a test that isolates its state dir now isolates
59
+ * its turn records with it.
60
+ *
61
+ * ── Operator coupling: setting this var RELOCATES the fleet-health input ──
62
+ *
63
+ * `agent.env` in `switchroom.yaml` is propagated verbatim into the container
64
+ * (`src/agents/compose.ts` `userEnv`), so an operator CAN set
65
+ * `SWITCHROOM_AGENT_STATE_DIR` on an agent. If they point it anywhere other
66
+ * than the bind-mounted `/state/agent`, this file moves with it — but the
67
+ * fleet-health L0 sensor reads `~/.switchroom/agents/<name>/turns.jsonl` at a
68
+ * FIXED host path (`src/fleet-health/scan.ts`). That agent then presents no
69
+ * turns artifact, lands in the scan's `skipped[]`, and goes quiet on the health
70
+ * board: no findings, no ledger entries, indistinguishable from a healthy
71
+ * agent. Do not set this var in production `agent.env`; it exists so tests (and
72
+ * the vitest `agent-state-dir-guard` setup file) can isolate agent state into a
73
+ * tmpdir.
74
+ */
75
+ export function resolveTurnsJsonlPath(
76
+ env: Record<string, string | undefined> = process.env,
77
+ ): string {
78
+ return `${resolveAgentStateDir(env)}/turns.jsonl`
79
+ }
80
+
16
81
  export interface RotateFs {
17
82
  statSize: (path: string) => number | undefined // undefined ⇒ file absent
18
83
  rename: (from: string, to: string) => void