switchroom 0.14.91 → 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.
@@ -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,
@@ -5474,6 +5475,19 @@ const ipcServer: IpcServer = createIpcServer({
5474
5475
 
5475
5476
  onClientRegistered(client: IpcClient) {
5476
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
+ }
5477
5491
  // Phase 2b shadow: ONLY emit bridgeUp for the REAL bridge sidecar
5478
5492
  // (with an agent name). Anonymous IPC clients (recall.py, mcp
5479
5493
  // handshakes, etc.) connect briefly without a name and would
@@ -5652,6 +5666,17 @@ const ipcServer: IpcServer = createIpcServer({
5652
5666
  },
5653
5667
 
5654
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
+ }
5655
5680
  // ONLY log "bridge disconnected" + emit bridgeDown for the REAL
5656
5681
  // bridge sidecar (matching the bridgeUp gate above). Anonymous IPC
5657
5682
  // clients — e.g. recall.py's one-shot legacy update_placeholder
@@ -5722,7 +5747,13 @@ const ipcServer: IpcServer = createIpcServer({
5722
5747
  }
5723
5748
  },
5724
5749
 
5725
- 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
5726
5757
  // Track the session-tail's attached file for the proactive-
5727
5758
  // compaction occupancy read (see maybeProactiveCompact).
5728
5759
  if (msg.activeFile) lastSessionActiveFile = msg.activeFile
@@ -5954,7 +5985,10 @@ const ipcServer: IpcServer = createIpcServer({
5954
5985
  * `handlePtyPartial` does its own buffering for the
5955
5986
  * partial-before-enqueue race.
5956
5987
  */
5957
- 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
5958
5992
  handlePtyPartial(msg.text)
5959
5993
  },
5960
5994
 
@@ -6291,16 +6325,28 @@ const ipcServer: IpcServer = createIpcServer({
6291
6325
  const source = typeof msg.inbound.meta?.source === 'string'
6292
6326
  ? msg.inbound.meta.source
6293
6327
  : 'unknown'
6294
- const delivered = ipcServer.sendToAgent(msg.agentName, msg.inbound)
6295
- 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)
6296
6342
  process.stderr.write(
6297
- `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`,
6298
6344
  )
6299
6345
  // #1150: same buffer-on-failure pattern as vault_grant_approved.
6300
6346
  // Cron fires use this path too — if a cron-driven wake-up lands
6301
6347
  // mid bridge-reconnect, buffer it for the next register.
6302
6348
  if (!delivered) {
6303
- pendingInboundBuffer.push(msg.agentName, msg.inbound)
6349
+ pendingInboundBuffer.push(target, msg.inbound)
6304
6350
  }
6305
6351
  },
6306
6352
 
@@ -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
+ })