switchroom 0.14.90 → 0.14.92

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.
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Cheap-cron session identity — docs/rfcs/cheap-cron-sessions.md §3.3.
3
+ *
4
+ * Rather than rekey the gateway's hardened single-bridge machinery
5
+ * (agentIndex / pendingInboundBuffer / handleRegister, each carrying
6
+ * subtle race fixes), a Tier-1 cron fire is routed to a SECOND bridge
7
+ * that registers under a DERIVED identity `<agent>-cron`. To the IPC
8
+ * layer it is "just another agent", so routing, buffering, disconnect,
9
+ * and heartbeat all work unchanged. The gateway gates its SINGLETON
10
+ * status machinery (shadow bridge-state, boot card, currentTurn /
11
+ * progress card / silence-poke) off the cron identity — which IS the
12
+ * §2.4 "cron session is status-silent" requirement, so it is one change,
13
+ * not two.
14
+ *
15
+ * The cron session's bridge sets SWITCHROOM_AGENT_NAME=`<agent>-cron`;
16
+ * the scheduler emits `meta.session='cron'` and the gateway derives the
17
+ * target via `cronIdentity()`. Pure string fns — pinned in cron-session.test.ts.
18
+ */
19
+
20
+ /** Suffix that distinguishes a cron-session bridge from the main agent bridge. */
21
+ export const CRON_IDENTITY_SUFFIX = "-cron";
22
+
23
+ /** Derive the cron-session bridge identity for an agent. */
24
+ export function cronIdentity(agent: string): string {
25
+ return `${agent}${CRON_IDENTITY_SUFFIX}`;
26
+ }
27
+
28
+ /** True iff `name` is a cron-session bridge identity (not a main agent bridge). */
29
+ export function isCronIdentity(name: string | null | undefined): boolean {
30
+ return typeof name === "string" && name.endsWith(CRON_IDENTITY_SUFFIX);
31
+ }
32
+
33
+ /** The real agent name behind a (possibly cron) identity. */
34
+ export function baseAgent(name: string): string {
35
+ return isCronIdentity(name) ? name.slice(0, -CRON_IDENTITY_SUFFIX.length) : name;
36
+ }
37
+
38
+ /**
39
+ * Resolve the IPC routing target for an inject_inbound. When the fire
40
+ * carries `meta.session='cron'` it goes to the derived cron bridge; every
41
+ * other fire (and all of today's callers) goes to the agent unchanged.
42
+ */
43
+ export function resolveInjectTarget(agentName: string, meta: Record<string, string> | undefined): string {
44
+ return meta?.session === "cron" ? cronIdentity(agentName) : agentName;
45
+ }
@@ -287,6 +287,7 @@ import { handleRequestDriveApproval } from './drive-write-approval.js'
287
287
  import { handleRequestMs365Approval } from './ms365-write-approval.js'
288
288
  import { buildDiffPreviewCard } from './diff-preview-card.js'
289
289
  import { createPendingInboundBuffer, redeliverBufferedInbound, idleDrainTick } from './pending-inbound-buffer.js'
290
+ import { isCronIdentity, resolveInjectTarget } from './cron-session.js'
290
291
  import {
291
292
  ObligationLedger,
292
293
  buildObligationRepresentInbound,
@@ -471,6 +472,10 @@ import {
471
472
  } from './resume-inbound-builder.js'
472
473
  import { applySubagentsSchema, getSubagentByJsonlId } from '../registry/subagents-schema.js'
473
474
  import { resolveWorkerFeedDispatch, type WorkerFeedDispatch } from './worker-feed-dispatch.js'
475
+ import {
476
+ resolveSubagentStatusSurface,
477
+ isOrphanSubagentStatusEnabled,
478
+ } from './subagent-status-surface.js'
474
479
  import { formatIdleFooter } from '../idle-footer.js'
475
480
  import { resolveCallingSubagent } from './resolve-calling-subagent.js'
476
481
 
@@ -5470,6 +5475,19 @@ const ipcServer: IpcServer = createIpcServer({
5470
5475
 
5471
5476
  onClientRegistered(client: IpcClient) {
5472
5477
  process.stderr.write(`telegram gateway: bridge registered — agent=${client.agentName}\n`)
5478
+ // Cheap-cron (§2.4/§3.3): a `<agent>-cron` bridge is the Tier-1 cheap
5479
+ // session. It is STATUS-SILENT — it must NOT drive the gateway's
5480
+ // singleton machinery (shadow bridge-state, warmup, boot card, which all
5481
+ // track the MAIN agent's liveness). Drain any buffered cron fire to it
5482
+ // (so a fire that triggered a lazy spawn lands), then return early.
5483
+ if (isCronIdentity(client.agentName)) {
5484
+ client.send({ type: 'status', status: 'agent_connected' })
5485
+ const pending = pendingInboundBuffer.drain(client.agentName ?? '')
5486
+ for (const m of pending) {
5487
+ try { client.send(m) } catch { /* cron fire drop — best-effort, like today's cron */ }
5488
+ }
5489
+ return
5490
+ }
5473
5491
  // Phase 2b shadow: ONLY emit bridgeUp for the REAL bridge sidecar
5474
5492
  // (with an agent name). Anonymous IPC clients (recall.py, mcp
5475
5493
  // handshakes, etc.) connect briefly without a name and would
@@ -5648,6 +5666,17 @@ const ipcServer: IpcServer = createIpcServer({
5648
5666
  },
5649
5667
 
5650
5668
  onClientDisconnected(client: IpcClient) {
5669
+ // Cheap-cron §2.4: a `<agent>-cron` bridge is the status-silent cron
5670
+ // session. Its disconnect (e.g. B3 lazy idle-teardown) must NOT touch
5671
+ // the MAIN agent's singleton state — emitting bridgeDown (the shadow
5672
+ // state is unkeyed) or flushOnAgentDisconnect (flushes the main agent's
5673
+ // active reactions / disposes its progress driver mid-turn) would be the
5674
+ // "premature 👍" bug for the main session. Symmetric to the cron gate in
5675
+ // onClientRegistered / onSessionEvent / onPtyPartial.
5676
+ if (isCronIdentity(client.agentName)) {
5677
+ process.stderr.write(`telegram gateway: cron-session bridge disconnected — agent=${client.agentName}\n`)
5678
+ return
5679
+ }
5651
5680
  // ONLY log "bridge disconnected" + emit bridgeDown for the REAL
5652
5681
  // bridge sidecar (matching the bridgeUp gate above). Anonymous IPC
5653
5682
  // clients — e.g. recall.py's one-shot legacy update_placeholder
@@ -5718,7 +5747,13 @@ const ipcServer: IpcServer = createIpcServer({
5718
5747
  }
5719
5748
  },
5720
5749
 
5721
- onSessionEvent(_client: IpcClient, msg: SessionEventForward) {
5750
+ onSessionEvent(client: IpcClient, msg: SessionEventForward) {
5751
+ // Cheap-cron §2.4: the cron session is status-silent — its session
5752
+ // events must not drive the main agent's progress card, transcript
5753
+ // tail, currentTurn, or silence-poke. Its reply still flows (onToolCall
5754
+ // is not gated). currentTurn was never set for a cron fire (onInjectInbound
5755
+ // skips markClaudeBusy), so the card machinery has nothing to attach to.
5756
+ if (isCronIdentity(client.agentName)) return
5722
5757
  // Track the session-tail's attached file for the proactive-
5723
5758
  // compaction occupancy read (see maybeProactiveCompact).
5724
5759
  if (msg.activeFile) lastSessionActiveFile = msg.activeFile
@@ -5950,7 +5985,10 @@ const ipcServer: IpcServer = createIpcServer({
5950
5985
  * `handlePtyPartial` does its own buffering for the
5951
5986
  * partial-before-enqueue race.
5952
5987
  */
5953
- onPtyPartial(_client: IpcClient, msg: PtyPartialForward) {
5988
+ onPtyPartial(client: IpcClient, msg: PtyPartialForward) {
5989
+ // Cheap-cron §2.4: cron session is status-silent — no visible reply
5990
+ // stream from its PTY tail (it would edit a card the main session owns).
5991
+ if (isCronIdentity(client.agentName)) return
5954
5992
  handlePtyPartial(msg.text)
5955
5993
  },
5956
5994
 
@@ -6287,16 +6325,28 @@ const ipcServer: IpcServer = createIpcServer({
6287
6325
  const source = typeof msg.inbound.meta?.source === 'string'
6288
6326
  ? msg.inbound.meta.source
6289
6327
  : 'unknown'
6290
- const delivered = ipcServer.sendToAgent(msg.agentName, msg.inbound)
6291
- if (delivered) markClaudeBusyForInbound(msg.inbound)
6328
+ // Cheap-cron (docs/rfcs/cheap-cron-sessions.md §3.3): a Tier-1 fire
6329
+ // carries meta.session='cron' → route to the derived `<agent>-cron`
6330
+ // bridge (a 2nd interactive Sonnet session in the same container).
6331
+ // Every other fire (and all of today's callers) routes to the agent
6332
+ // unchanged. Route+buffer share the same target so a fire that lands
6333
+ // mid cron-session-spawn buffers under the cron identity and drains to
6334
+ // it on register.
6335
+ const target = resolveInjectTarget(msg.agentName, msg.inbound.meta)
6336
+ const toCron = target !== msg.agentName
6337
+ const delivered = ipcServer.sendToAgent(target, msg.inbound)
6338
+ // Status-silent (§2.4): a cron fire must NOT set the MAIN agent's
6339
+ // currentTurn (progress card / silence-poke). The cron session is
6340
+ // fire-and-forget; its reply is its only Telegram surface.
6341
+ if (delivered && !toCron) markClaudeBusyForInbound(msg.inbound)
6292
6342
  process.stderr.write(
6293
- `telegram gateway: inject_inbound agent=${msg.agentName} source=${source} prompt_key=${promptKey} delivered=${delivered}\n`,
6343
+ `telegram gateway: inject_inbound agent=${msg.agentName} target=${target} source=${source} prompt_key=${promptKey} delivered=${delivered}\n`,
6294
6344
  )
6295
6345
  // #1150: same buffer-on-failure pattern as vault_grant_approved.
6296
6346
  // Cron fires use this path too — if a cron-driven wake-up lands
6297
6347
  // mid bridge-reconnect, buffer it for the next register.
6298
6348
  if (!delivered) {
6299
- pendingInboundBuffer.push(msg.agentName, msg.inbound)
6349
+ pendingInboundBuffer.push(target, msg.inbound)
6300
6350
  }
6301
6351
  },
6302
6352
 
@@ -20410,6 +20460,11 @@ void (async () => {
20410
20460
  // compose draft, so no answer-stream contention). The kill-switch
20411
20461
  // disables only the nesting; the parent's own feed is unaffected.
20412
20462
  const foregroundNestingEnabled = process.env.SWITCHROOM_FOREGROUND_SUBAGENT_NESTING !== '0'
20463
+ // Orphaned-foreground status (2026-06-09): a FOREGROUND sub-agent
20464
+ // with no live parent turn to nest into (dispatched outside a turn,
20465
+ // or the turn ended while it kept running — extended autonomous
20466
+ // work) is surfaced via the worker feed instead of vanishing.
20467
+ const orphanStatusEnabled = isOrphanSubagentStatusEnabled(process.env.SWITCHROOM_ORPHAN_SUBAGENT_STATUS)
20413
20468
  const workerActivityFeed = createWorkerActivityFeed({
20414
20469
  bot: {
20415
20470
  sendMessage: async (cid, text, sendOpts) => {
@@ -20609,6 +20664,29 @@ void (async () => {
20609
20664
  }
20610
20665
  }
20611
20666
  }
20667
+ return
20668
+ }
20669
+ // Not nested → an orphaned foreground sub-agent that was
20670
+ // surfaced via the worker feed (no live turn to nest into):
20671
+ // finalize its message (no-op if none was posted). A
20672
+ // foreground result returns inline as the Task tool result, so
20673
+ // there is no handback to deliver — return after.
20674
+ if (
20675
+ resolveSubagentStatusSurface({
20676
+ isBackground: false,
20677
+ liveTurnPresent: false,
20678
+ workerFeedEnabled,
20679
+ orphanStatusEnabled,
20680
+ }) === 'worker-feed'
20681
+ ) {
20682
+ void workerActivityFeed.finish(agentId, {
20683
+ description: dispatch.feedDescription,
20684
+ lastTool: null,
20685
+ toolCount,
20686
+ latestSummary: resultText,
20687
+ elapsedMs: durationMs,
20688
+ state: outcome === 'failed' ? 'failed' : 'done',
20689
+ })
20612
20690
  }
20613
20691
  return
20614
20692
  }
@@ -20738,8 +20816,39 @@ void (async () => {
20738
20816
  // activity draft rather than a separate worker message. Pure
20739
20817
  // jsonl-tail → render (no model call), inside the
20740
20818
  // subscription-honest boundary.
20819
+ //
20820
+ // But a foreground sub-agent with NO live turn to nest into
20821
+ // (dispatched outside a turn, or the turn ended while it kept
20822
+ // running — extended autonomous work) has nowhere to nest, and
20823
+ // pre-fix it silently returned here → invisible. Route through
20824
+ // the proven decision: an orphaned foreground sub-agent goes to
20825
+ // the worker feed (owner-DM fallback), not into the void.
20826
+ const surface = resolveSubagentStatusSurface({
20827
+ isBackground: false,
20828
+ liveTurnPresent: currentTurn != null,
20829
+ workerFeedEnabled,
20830
+ orphanStatusEnabled,
20831
+ })
20832
+ if (surface === 'worker-feed') {
20833
+ const origin = resolveSubagentOriginChat(agentId)
20834
+ void workerActivityFeed.update(
20835
+ agentId,
20836
+ origin?.chatId || fleetChatId || (loadAccess().allowFrom[0] ?? ''),
20837
+ {
20838
+ description: dispatch.feedDescription,
20839
+ lastTool,
20840
+ toolCount,
20841
+ latestSummary,
20842
+ elapsedMs,
20843
+ state: 'running',
20844
+ },
20845
+ origin?.threadId,
20846
+ )
20847
+ return
20848
+ }
20849
+ if (surface !== 'nest') return // 'skip' — orphan-status off
20741
20850
  const turn = currentTurn
20742
- if (turn == null) return
20851
+ if (turn == null) return // defensive: 'nest' implies a live turn
20743
20852
  // Render regardless of `replyCalled` — a foreground Task
20744
20853
  // blocks the parent, so any reply seen while it runs is an
20745
20854
  // interim ack, never the final answer. Gating on replyCalled
@@ -0,0 +1,118 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import {
3
+ resolveSubagentStatusSurface,
4
+ isOrphanSubagentStatusEnabled,
5
+ type SubagentStatusSurface,
6
+ type SubagentStatusSurfaceInput,
7
+ } from './subagent-status-surface.js'
8
+
9
+ // ── Human-readable map ──────────────────────────────────────────────────────
10
+ describe('resolveSubagentStatusSurface', () => {
11
+ const base: SubagentStatusSurfaceInput = {
12
+ isBackground: false,
13
+ liveTurnPresent: true,
14
+ workerFeedEnabled: true,
15
+ orphanStatusEnabled: true,
16
+ }
17
+ it('foreground + live turn → nest (unchanged default)', () => {
18
+ expect(resolveSubagentStatusSurface(base)).toBe('nest')
19
+ })
20
+ it('THE fix: orphaned foreground (no live turn) → worker-feed', () => {
21
+ expect(resolveSubagentStatusSurface({ ...base, liveTurnPresent: false })).toBe('worker-feed')
22
+ })
23
+ it('kill switch: orphaned foreground with orphanStatus OFF → skip (pre-fix invisible)', () => {
24
+ expect(
25
+ resolveSubagentStatusSurface({ ...base, liveTurnPresent: false, orphanStatusEnabled: false }),
26
+ ).toBe('skip')
27
+ })
28
+ it('orphaned foreground but feed OFF → skip (nothing to surface through)', () => {
29
+ expect(
30
+ resolveSubagentStatusSurface({ ...base, liveTurnPresent: false, workerFeedEnabled: false }),
31
+ ).toBe('skip')
32
+ })
33
+ it('background + feed on → worker-feed', () => {
34
+ expect(resolveSubagentStatusSurface({ ...base, isBackground: true, liveTurnPresent: false })).toBe('worker-feed')
35
+ })
36
+ it('background + feed off → legacy-relay', () => {
37
+ expect(
38
+ resolveSubagentStatusSurface({ ...base, isBackground: true, workerFeedEnabled: false }),
39
+ ).toBe('legacy-relay')
40
+ })
41
+ })
42
+
43
+ describe('isOrphanSubagentStatusEnabled — default ON, =0 kill switch', () => {
44
+ it('undefined / "1" / "" → on; "0" → off', () => {
45
+ expect(isOrphanSubagentStatusEnabled(undefined)).toBe(true)
46
+ expect(isOrphanSubagentStatusEnabled('1')).toBe(true)
47
+ expect(isOrphanSubagentStatusEnabled('')).toBe(true)
48
+ expect(isOrphanSubagentStatusEnabled('0')).toBe(false)
49
+ })
50
+ })
51
+
52
+ // ── TOTAL-ENUMERATION DETERMINISM PROOF ─────────────────────────────────────
53
+ // 4 booleans = 16 reachable inputs. Enumerate all, assert totality, determinism,
54
+ // the documented table (independent spec), and the load-bearing invariants.
55
+ // (operator standard feedback_prove_finite_fsm_not_sample.)
56
+ function allInputs(): SubagentStatusSurfaceInput[] {
57
+ const rows: SubagentStatusSurfaceInput[] = []
58
+ for (const isBackground of [false, true])
59
+ for (const liveTurnPresent of [false, true])
60
+ for (const workerFeedEnabled of [false, true])
61
+ for (const orphanStatusEnabled of [false, true])
62
+ rows.push({ isBackground, liveTurnPresent, workerFeedEnabled, orphanStatusEnabled })
63
+ return rows
64
+ }
65
+
66
+ // Independent spec encoding (kept separate from the impl).
67
+ function spec(i: SubagentStatusSurfaceInput): SubagentStatusSurface {
68
+ if (i.isBackground) return i.workerFeedEnabled ? 'worker-feed' : 'legacy-relay'
69
+ if (i.liveTurnPresent) return 'nest'
70
+ if (!i.orphanStatusEnabled) return 'skip'
71
+ return i.workerFeedEnabled ? 'worker-feed' : 'skip'
72
+ }
73
+
74
+ describe('resolveSubagentStatusSurface — total enumeration (16 inputs)', () => {
75
+ const ROWS = allInputs()
76
+
77
+ it('exactly 16 reachable inputs (2^4)', () => {
78
+ expect(ROWS.length).toBe(16)
79
+ })
80
+ it('TOTAL + DETERMINISTIC: every input returns one of the four surfaces, idempotently', () => {
81
+ const surfaces = new Set<SubagentStatusSurface>(['nest', 'worker-feed', 'legacy-relay', 'skip'])
82
+ for (const i of ROWS) {
83
+ const a = resolveSubagentStatusSurface(i)
84
+ expect(surfaces.has(a)).toBe(true)
85
+ expect(resolveSubagentStatusSurface({ ...i })).toBe(a)
86
+ }
87
+ })
88
+ it('PRECEDENCE: matches the documented spec on all 16 inputs', () => {
89
+ for (const i of ROWS) expect(resolveSubagentStatusSurface(i)).toBe(spec(i))
90
+ })
91
+
92
+ it('INV-ORPHAN-VISIBLE: an orphaned foreground sub-agent is NEVER skip when orphanStatus + feed are on', () => {
93
+ for (const i of ROWS) {
94
+ if (!i.isBackground && !i.liveTurnPresent && i.orphanStatusEnabled && i.workerFeedEnabled) {
95
+ expect(resolveSubagentStatusSurface(i)).toBe('worker-feed')
96
+ }
97
+ }
98
+ })
99
+ it('INV-KILL-SWITCH: orphanStatus OFF ⇒ an orphaned foreground sub-agent is exactly the pre-fix behaviour (skip)', () => {
100
+ for (const i of ROWS) {
101
+ if (!i.isBackground && !i.liveTurnPresent && !i.orphanStatusEnabled) {
102
+ expect(resolveSubagentStatusSurface(i)).toBe('skip')
103
+ }
104
+ }
105
+ })
106
+ it('INV-NEST-UNCHANGED: a foreground sub-agent with a live turn is ALWAYS nest, independent of the other flags', () => {
107
+ for (const i of ROWS) {
108
+ if (!i.isBackground && i.liveTurnPresent) expect(resolveSubagentStatusSurface(i)).toBe('nest')
109
+ }
110
+ })
111
+ it('INV-BACKGROUND-UNCHANGED: background routing depends ONLY on the feed flag, never on liveTurn/orphanStatus', () => {
112
+ for (const i of ROWS) {
113
+ if (i.isBackground) {
114
+ expect(resolveSubagentStatusSurface(i)).toBe(i.workerFeedEnabled ? 'worker-feed' : 'legacy-relay')
115
+ }
116
+ }
117
+ })
118
+ })
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Where does a sub-agent's live status go?
3
+ *
4
+ * A sub-agent's progress is surfaced on one of four "surfaces". This pure
5
+ * decision picks which, so the routing is provable by total enumeration rather
6
+ * than buried in the gateway's imperative branches.
7
+ *
8
+ * - `nest` — a FOREGROUND sub-agent running inside a LIVE parent turn:
9
+ * its narrative nests under the parent's activity draft
10
+ * (the progress card). The default, unchanged.
11
+ * - `worker-feed` — a BACKGROUND worker (the `🛠 Worker` edit-in-place
12
+ * message), OR a FOREGROUND sub-agent that has NO live
13
+ * parent turn to nest into (dispatched outside a turn, or
14
+ * the turn ended while it kept running). The latter is the
15
+ * 2026-06-09 fix: extended autonomous work was invisible
16
+ * because foreground status was turn-scoped and a sub-agent
17
+ * with no turn silently returned. It now reuses the worker
18
+ * feed (with the same owner-DM fallback background workers
19
+ * already use), so post-turn work is always visible.
20
+ * - `legacy-relay` — a BACKGROUND worker when the worker feed is OFF: fall
21
+ * back to the legacy "still working" injected-inbound relay.
22
+ * - `skip` — nothing to surface (kill-switch off for an orphaned
23
+ * foreground, or no feed to surface it through).
24
+ *
25
+ * Determinism: the input space is 4 booleans = 16 rows, enumerated and proven
26
+ * in subagent-status-surface.test.ts (operator standard
27
+ * feedback_prove_finite_fsm_not_sample). The load-bearing invariant: an
28
+ * orphaned foreground sub-agent (no live turn) is `worker-feed`, never `skip`,
29
+ * whenever the orphan-status flag and the feed are both on.
30
+ */
31
+
32
+ export type SubagentStatusSurface = 'nest' | 'worker-feed' | 'legacy-relay' | 'skip'
33
+
34
+ export interface SubagentStatusSurfaceInput {
35
+ /** run_in_background dispatch (registry `subagents.background`). */
36
+ isBackground: boolean
37
+ /**
38
+ * A LIVE parent turn exists to nest this (foreground) sub-agent into.
39
+ * onProgress: `currentTurn != null`. onFinish: `currentTurn != null && it was
40
+ * actually nested` (so a foreground sub-agent that was surfaced via the worker
41
+ * feed — never nested — finalizes through the feed, not a turn collapse).
42
+ * Ignored for background sub-agents.
43
+ */
44
+ liveTurnPresent: boolean
45
+ /** SWITCHROOM_WORKER_ACTIVITY_FEED on. */
46
+ workerFeedEnabled: boolean
47
+ /** SWITCHROOM_ORPHAN_SUBAGENT_STATUS on — surface no-parent-turn foreground
48
+ * sub-agents via the worker feed. Off = pre-fix behaviour (invisible). */
49
+ orphanStatusEnabled: boolean
50
+ }
51
+
52
+ export function resolveSubagentStatusSurface(
53
+ input: SubagentStatusSurfaceInput,
54
+ ): SubagentStatusSurface {
55
+ if (!input.isBackground) {
56
+ // Foreground sub-agent.
57
+ if (input.liveTurnPresent) return 'nest'
58
+ // Orphaned foreground: no live turn to nest into — the invisible case.
59
+ if (!input.orphanStatusEnabled) return 'skip' // kill switch: pre-fix behaviour
60
+ return input.workerFeedEnabled ? 'worker-feed' : 'skip' // surfacing needs the feed
61
+ }
62
+ // Background worker.
63
+ return input.workerFeedEnabled ? 'worker-feed' : 'legacy-relay'
64
+ }
65
+
66
+ /** SWITCHROOM_ORPHAN_SUBAGENT_STATUS — default ON; `=0` restores pre-fix (invisible) behaviour. */
67
+ export function isOrphanSubagentStatusEnabled(envVal: string | undefined): boolean {
68
+ return envVal !== '0'
69
+ }
@@ -0,0 +1,32 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import {
3
+ CRON_IDENTITY_SUFFIX,
4
+ baseAgent,
5
+ cronIdentity,
6
+ isCronIdentity,
7
+ resolveInjectTarget,
8
+ } from '../gateway/cron-session.js'
9
+
10
+ describe('cron-session identity helpers', () => {
11
+ it('derives and detects the cron identity', () => {
12
+ expect(cronIdentity('clerk')).toBe(`clerk${CRON_IDENTITY_SUFFIX}`)
13
+ expect(isCronIdentity('clerk-cron')).toBe(true)
14
+ expect(isCronIdentity('clerk')).toBe(false)
15
+ expect(isCronIdentity(null)).toBe(false)
16
+ expect(isCronIdentity(undefined)).toBe(false)
17
+ })
18
+
19
+ it('round-trips base agent', () => {
20
+ expect(baseAgent(cronIdentity('marko'))).toBe('marko')
21
+ expect(baseAgent('marko')).toBe('marko')
22
+ })
23
+
24
+ it('resolveInjectTarget routes only meta.session=cron to the cron bridge', () => {
25
+ expect(resolveInjectTarget('clerk', { session: 'cron', source: 'cron' })).toBe('clerk-cron')
26
+ expect(resolveInjectTarget('clerk', { session: 'main', source: 'cron' })).toBe('clerk')
27
+ expect(resolveInjectTarget('clerk', { source: 'cron' })).toBe('clerk')
28
+ expect(resolveInjectTarget('clerk', undefined)).toBe('clerk')
29
+ // back-compat: every legacy caller (no session) is unchanged.
30
+ expect(resolveInjectTarget('clerk', { source: 'telegram' })).toBe('clerk')
31
+ })
32
+ })