switchroom 0.19.44 → 0.19.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/cli/switchroom.js +848 -826
- package/dist/host-control/main.js +1 -1
- package/package.json +1 -1
- package/profiles/_base/start.sh.hbs +48 -4
- package/telegram-plugin/dist/gateway/gateway.js +499 -322
- package/telegram-plugin/flushed-turn-supersede.ts +190 -16
- package/telegram-plugin/gateway/auth-command.ts +7 -4
- package/telegram-plugin/gateway/auth-loopback-relay.ts +39 -20
- package/telegram-plugin/gateway/cron-session.ts +31 -0
- package/telegram-plugin/gateway/gateway.ts +67 -66
- package/telegram-plugin/gateway/outbound-send-path.ts +60 -8
- package/telegram-plugin/gateway/reply-owner-wiring.ts +128 -0
- package/telegram-plugin/gateway/stream-render.ts +91 -1
- package/telegram-plugin/gateway/subagent-handback-marker.ts +49 -9
- package/telegram-plugin/gateway/subagent-reply-authority.ts +147 -0
- package/telegram-plugin/gateway/turn-end.ts +9 -1
- package/telegram-plugin/reply-owner-resolve.ts +102 -26
- package/telegram-plugin/tests/auth-loopback-relay.test.ts +58 -0
- package/telegram-plugin/tests/flushed-turn-supersede.test.ts +110 -8
- package/telegram-plugin/tests/narrative-lane-golden.test.ts +2 -0
- package/telegram-plugin/tests/reply-owner-resolve.test.ts +297 -27
- package/telegram-plugin/tests/reply-quote-wire.test.ts +2 -0
- package/telegram-plugin/tests/send-reply-golden.test.ts +567 -5
- package/telegram-plugin/tests/stream-render-golden.test.ts +204 -1
|
@@ -26,7 +26,9 @@
|
|
|
26
26
|
*
|
|
27
27
|
* NOT fully deterministic — a residual race remains. The flush→reply direction
|
|
28
28
|
* this registry covers, plus the controller's fire-time recount for the
|
|
29
|
-
* reply→flush direction, close the COMMON
|
|
29
|
+
* reply→flush direction, close the COMMON replay-gap case (~10 s for a
|
|
30
|
+
* tool-call replay; unbounded for the Stop-hook-nudged recompose after a
|
|
31
|
+
* proactive /compact — see the turn-completion window below, #4173); but if a reply
|
|
30
32
|
* and a flush interleave so the reply's `take()` runs BEFORE the flush's
|
|
31
33
|
* `record()` (a much smaller window), `take` finds no record and the duplicate
|
|
32
34
|
* can still slip through. We deliberately trade that residual window for the
|
|
@@ -46,10 +48,85 @@
|
|
|
46
48
|
* `now`. Fully unit-testable; the gateway wires the actual delete/send.
|
|
47
49
|
*/
|
|
48
50
|
|
|
49
|
-
/**
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
|
|
51
|
+
/**
|
|
52
|
+
* ## The supersede window is a TURN-COMPLETION SIGNAL, not a fixed TTL (#4173)
|
|
53
|
+
*
|
|
54
|
+
* The window in which a landing reply may claim a flushed turn used to be a
|
|
55
|
+
* single constant (`DEFAULT_SUPERSEDE_TTL_MS = 60_000`). That constant was
|
|
56
|
+
* sized for the ~10 s tool-replay gap and missed the SLOW variant of the exact
|
|
57
|
+
* class this module exists for (2026-08-01, klanker DM, msgs 25680/25682): the
|
|
58
|
+
* quiescence flush delivered the composed answer, the Stop hook told the model
|
|
59
|
+
* its prose never reached the user, and the model recomposed and called
|
|
60
|
+
* `reply` — but a proactive `/compact` ran in between (occupancy ~393 k), so
|
|
61
|
+
* the canonical reply landed **2 m 24 s** after the flush. Every layer keyed
|
|
62
|
+
* on the 60 s TTL had aged out and the reply shipped as a second bubble
|
|
63
|
+
* (#4166). Widening the constant (the first attempt: 5 min) merely moves the
|
|
64
|
+
* cliff — a longer compaction reproduces the duplicate — while ALSO holding
|
|
65
|
+
* the destructive window open for foreign late repliers (#4172).
|
|
66
|
+
*
|
|
67
|
+
* The framework already holds the right signal. The flush fires a
|
|
68
|
+
* SYNTHETIC turn_end (it nulls the gateway's turn atom so the session can
|
|
69
|
+
* move on), but the claude session's REAL turn_end arrives later, when the
|
|
70
|
+
* model actually stops. A `reply` landing on the MAIN session bridge BETWEEN
|
|
71
|
+
* those two events is that turn's own answer still being composed — PROVIDED
|
|
72
|
+
* nothing else on that bridge can emit a reply in the span. Two things can, and
|
|
73
|
+
* neither is covered by this window; each is closed by its own deterministic
|
|
74
|
+
* gate, and the window is only sound in combination with them:
|
|
75
|
+
*
|
|
76
|
+
* - a FOREIGN session (a Tier-1 cheap-cron bridge, an unregistered client) —
|
|
77
|
+
* closed by caller identity at the send path (`replyCallerIsForeignSession`,
|
|
78
|
+
* gateway/cron-session.ts, #4172);
|
|
79
|
+
* - a background `Task` SUB-AGENT, which runs concurrently with the parent
|
|
80
|
+
* loop and calls `reply` over the SAME main bridge, so identity cannot see
|
|
81
|
+
* it — closed by gateway-observed sub-agent liveness refusing the
|
|
82
|
+
* content-gate bypass (gateway/subagent-reply-authority.ts, #4176). The
|
|
83
|
+
* claude session's own loop is serial; its sub-agents are not, and the
|
|
84
|
+
* earlier revision of this header asserted the stronger, false claim that
|
|
85
|
+
* "no other work can run on the serial session in that span".
|
|
86
|
+
*
|
|
87
|
+
* So the record's lifetime is now three-phase:
|
|
88
|
+
*
|
|
89
|
+
* 1. OPEN (`completedAt == null`) — the flush delivered, the real turn_end
|
|
90
|
+
* has not been observed yet. The turn is still composing (Stop-hook
|
|
91
|
+
* nudge, /compact, recompose all live here). A same-session reply
|
|
92
|
+
* supersedes REGARDLESS of how long this takes — a 20-minute compaction
|
|
93
|
+
* no longer reproduces #4166. Bounded only by the
|
|
94
|
+
* `SUPERSEDE_OPEN_WINDOW_CAP_MS` crash backstop below, and — for a reply
|
|
95
|
+
* the two gates above cannot positively attribute to the session's own
|
|
96
|
+
* loop — by the #3429 content gate, which this phase does NOT relax.
|
|
97
|
+
* 2. COMPLETED (`completedAt` set) — the real turn_end (or a proxy for it:
|
|
98
|
+
* a new turn minting, or the bridge dying) was observed. The turn's own
|
|
99
|
+
* answer either already landed or can only arrive as a bounded REPLAY of
|
|
100
|
+
* an un-acked tool_call (~10 s after a bridge reconnect), so the record
|
|
101
|
+
* stays claimable for `SUPERSEDE_COMPLETED_GRACE_MS` and then expires.
|
|
102
|
+
* 3. EXPIRED — no supersede; a genuinely late duplicate ships as a second
|
|
103
|
+
* visible bubble (safe direction — never an edit-over).
|
|
104
|
+
*
|
|
105
|
+
* In the COMMON case (real turn_end lands seconds after the flush) this is
|
|
106
|
+
* STRICTLY NARROWER than any widened TTL — the destructive window closes ~60 s
|
|
107
|
+
* after the session actually stopped — while being unbounded-correct in the
|
|
108
|
+
* slow case. The `FlushCompletionTracker` below carries the same signal onto
|
|
109
|
+
* the ended TURN ATOM (`realEndObservedAt`) so the latest-ended owner tier
|
|
110
|
+
* (`reply-owner-resolve.ts`) applies the identical three-phase rule.
|
|
111
|
+
*/
|
|
112
|
+
|
|
113
|
+
/** Crash backstop for an OPEN record: if the real turn_end signal is LOST
|
|
114
|
+
* (every loss path — bridge death, new-turn mint, gateway restart dropping
|
|
115
|
+
* the in-memory registry — normally closes the window explicitly), an open
|
|
116
|
+
* record is forgotten after this long. Generous by design: it exists only
|
|
117
|
+
* for signal loss, and the failure past it is a visible duplicate, never a
|
|
118
|
+
* silent edit-over. Operator override: `SWITCHROOM_SUPERSEDE_OPEN_CAP_MS`
|
|
119
|
+
* (resolved in gateway.ts — this module stays env-free/pure). */
|
|
120
|
+
export const SUPERSEDE_OPEN_WINDOW_CAP_MS = 30 * 60_000
|
|
121
|
+
|
|
122
|
+
/** How long a COMPLETED record stays claimable after the real turn_end was
|
|
123
|
+
* observed. Sized for the bounded replay class the original 60 s TTL was
|
|
124
|
+
* built for: claude-code re-sending an un-acked `reply` tool_call after a
|
|
125
|
+
* bridge reconnect (~10 s), with margin. The exact-text #546 dedup keeps its
|
|
126
|
+
* own 60 s window (`recent-outbound-dedup.ts`) — byte-identical replays, a
|
|
127
|
+
* different class. Operator override: `SWITCHROOM_SUPERSEDE_GRACE_MS`
|
|
128
|
+
* (resolved in gateway.ts). */
|
|
129
|
+
export const SUPERSEDE_COMPLETED_GRACE_MS = 60_000
|
|
53
130
|
|
|
54
131
|
export interface FlushedTurnRecord {
|
|
55
132
|
/** The per-turn `turnId` nonce (`deriveTurnId` shape) of the flushed turn.
|
|
@@ -68,6 +145,13 @@ export interface FlushedTurnRecord {
|
|
|
68
145
|
text: string
|
|
69
146
|
/** Wall-clock ms when recorded. */
|
|
70
147
|
ts: number
|
|
148
|
+
/** #4173 — wall-clock ms the flushed turn's REAL turn_end (or a proxy: new
|
|
149
|
+
* turn mint / bridge death) was observed, or null while the session is
|
|
150
|
+
* still composing (the OPEN phase — see the module header). Null records
|
|
151
|
+
* are claimable indefinitely up to the `openCapMs` crash backstop; once
|
|
152
|
+
* set, the record expires `completedGraceMs` later (the bounded
|
|
153
|
+
* tool-call-replay window). */
|
|
154
|
+
completedAt: number | null
|
|
71
155
|
}
|
|
72
156
|
|
|
73
157
|
/**
|
|
@@ -186,12 +270,24 @@ export function decideSupersede(
|
|
|
186
270
|
replyText?: string | null
|
|
187
271
|
positiveAttribution?: boolean
|
|
188
272
|
now: number
|
|
189
|
-
|
|
273
|
+
/** OPEN-phase crash backstop (see module header). */
|
|
274
|
+
openCapMs?: number
|
|
275
|
+
/** COMPLETED-phase replay grace (see module header). */
|
|
276
|
+
completedGraceMs?: number
|
|
190
277
|
},
|
|
191
278
|
): SupersedeDecision {
|
|
192
|
-
const
|
|
279
|
+
const openCapMs = args.openCapMs ?? SUPERSEDE_OPEN_WINDOW_CAP_MS
|
|
280
|
+
const completedGraceMs = args.completedGraceMs ?? SUPERSEDE_COMPLETED_GRACE_MS
|
|
193
281
|
if (record == null) return { supersede: false, deleteMessageIds: [], reason: 'no-record' }
|
|
194
|
-
|
|
282
|
+
// #4173 three-phase freshness (module header): an OPEN record (real turn_end
|
|
283
|
+
// not yet observed — the session is still composing this turn's answer) is
|
|
284
|
+
// claimable up to the crash-backstop cap; a COMPLETED record only for the
|
|
285
|
+
// bounded tool-call-replay grace after the observed completion.
|
|
286
|
+
const expired =
|
|
287
|
+
record.completedAt == null
|
|
288
|
+
? args.now - record.ts > openCapMs
|
|
289
|
+
: args.now - record.completedAt > completedGraceMs
|
|
290
|
+
if (expired) {
|
|
195
291
|
return { supersede: false, deleteMessageIds: [], reason: 'expired' }
|
|
196
292
|
}
|
|
197
293
|
const sameTurn =
|
|
@@ -312,20 +408,26 @@ function turnKey(turnId: string | null): string {
|
|
|
312
408
|
*/
|
|
313
409
|
export class FlushedTurnSupersedeRegistry {
|
|
314
410
|
private readonly entries = new Map<string, Map<string, FlushedTurnRecord>>()
|
|
315
|
-
private readonly
|
|
411
|
+
private readonly openCapMs: number
|
|
412
|
+
private readonly completedGraceMs: number
|
|
316
413
|
|
|
317
|
-
constructor(opts: {
|
|
318
|
-
this.
|
|
414
|
+
constructor(opts: { openCapMs?: number; completedGraceMs?: number } = {}) {
|
|
415
|
+
this.openCapMs = opts.openCapMs ?? SUPERSEDE_OPEN_WINDOW_CAP_MS
|
|
416
|
+
this.completedGraceMs = opts.completedGraceMs ?? SUPERSEDE_COMPLETED_GRACE_MS
|
|
319
417
|
}
|
|
320
418
|
|
|
321
419
|
/** Record a flush's posted message(s) so a later same-turn reply supersedes
|
|
322
420
|
* them, keyed on the flush's `turnId` within the chat/thread lane. No record
|
|
323
421
|
* is kept when the flush posted zero messages. Sweeps expired records first
|
|
324
|
-
* (lightweight active GC — LOW-2) so orphan records never accumulate.
|
|
422
|
+
* (lightweight active GC — LOW-2) so orphan records never accumulate.
|
|
423
|
+
* `completedAt` (#4173): the flush's async send can outlast the real
|
|
424
|
+
* turn_end, so the record site passes the turn atom's `realEndObservedAt` —
|
|
425
|
+
* a window that was closed BEFORE the record existed is born completed
|
|
426
|
+
* (grace-bounded), never resurrected as open. */
|
|
325
427
|
record(
|
|
326
428
|
chatId: string,
|
|
327
429
|
threadId: number | undefined,
|
|
328
|
-
rec: { turnId: string | null; messageIds: number[]; text: string },
|
|
430
|
+
rec: { turnId: string | null; messageIds: number[]; text: string; completedAt?: number | null },
|
|
329
431
|
now: number,
|
|
330
432
|
): void {
|
|
331
433
|
if (rec.messageIds.length === 0) return
|
|
@@ -341,9 +443,23 @@ export class FlushedTurnSupersedeRegistry {
|
|
|
341
443
|
messageIds: [...rec.messageIds],
|
|
342
444
|
text: rec.text,
|
|
343
445
|
ts: now,
|
|
446
|
+
completedAt: rec.completedAt ?? null,
|
|
344
447
|
})
|
|
345
448
|
}
|
|
346
449
|
|
|
450
|
+
/** #4173 — the real-turn-end signal (or a proxy: new turn mint / bridge
|
|
451
|
+
* death) was observed. Close every OPEN record's window: from `now` each
|
|
452
|
+
* has `completedGraceMs` (the bounded replay window) left to be claimed by
|
|
453
|
+
* its own late replay, then expires. Records already completed keep their
|
|
454
|
+
* original (earlier) completion — the signal only ever narrows. */
|
|
455
|
+
completeAll(now: number): void {
|
|
456
|
+
for (const laneMap of this.entries.values()) {
|
|
457
|
+
for (const rec of laneMap.values()) {
|
|
458
|
+
if (rec.completedAt == null) rec.completedAt = now
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
|
|
347
463
|
/** Decide supersede for a landing reply WITHOUT consuming the record. Selects
|
|
348
464
|
* the record whose turnId matches the reply's resolved `liveTurnId` (or the
|
|
349
465
|
* null-turnId record when `liveTurnId == null`). `replyText` (#3429) enables
|
|
@@ -364,7 +480,8 @@ export class FlushedTurnSupersedeRegistry {
|
|
|
364
480
|
replyText: args.replyText,
|
|
365
481
|
positiveAttribution: args.positiveAttribution,
|
|
366
482
|
now: args.now,
|
|
367
|
-
|
|
483
|
+
openCapMs: this.openCapMs,
|
|
484
|
+
completedGraceMs: this.completedGraceMs,
|
|
368
485
|
})
|
|
369
486
|
}
|
|
370
487
|
|
|
@@ -404,11 +521,16 @@ export class FlushedTurnSupersedeRegistry {
|
|
|
404
521
|
this.entries.clear()
|
|
405
522
|
}
|
|
406
523
|
|
|
407
|
-
/** Evict every record past its
|
|
524
|
+
/** Evict every record past its window (three-phase rule, module header) and
|
|
525
|
+
* prune emptied lanes. */
|
|
408
526
|
private sweep(now: number): void {
|
|
409
527
|
for (const [lane, laneMap] of this.entries) {
|
|
410
528
|
for (const [tk, rec] of laneMap) {
|
|
411
|
-
|
|
529
|
+
const expired =
|
|
530
|
+
rec.completedAt == null
|
|
531
|
+
? now - rec.ts > this.openCapMs
|
|
532
|
+
: now - rec.completedAt > this.completedGraceMs
|
|
533
|
+
if (expired) laneMap.delete(tk)
|
|
412
534
|
}
|
|
413
535
|
if (laneMap.size === 0) this.entries.delete(lane)
|
|
414
536
|
}
|
|
@@ -426,3 +548,55 @@ export class FlushedTurnSupersedeRegistry {
|
|
|
426
548
|
function makeKey(chatId: string, threadId: number | undefined): string {
|
|
427
549
|
return threadId == null ? chatId : `${chatId}|${threadId}`
|
|
428
550
|
}
|
|
551
|
+
|
|
552
|
+
/**
|
|
553
|
+
* #4173 — carries the turn-completion signal onto the flushed TURN ATOMS, the
|
|
554
|
+
* registry's sibling surface. The registry above bounds the RECORD (message
|
|
555
|
+
* ids); the latest-ended owner tier (`reply-owner-resolve.ts`) is bounded by
|
|
556
|
+
* the TURN's `realEndObservedAt`, and this tracker is what stamps it:
|
|
557
|
+
*
|
|
558
|
+
* - `open(turn)` at flush-fire time (stream-render's turn-flush branch, the
|
|
559
|
+
* one path that ends a turn SYNTHETICALLY before the session stopped):
|
|
560
|
+
* nulls `realEndObservedAt` and holds the atom pending.
|
|
561
|
+
* - `closeAll(now)` when the real turn_end signal (or a proxy: a new turn
|
|
562
|
+
* minting on the session, the bridge dying) is observed: stamps every
|
|
563
|
+
* pending atom's `realEndObservedAt = now` and releases it. Every other
|
|
564
|
+
* turn-end path stamps `realEndObservedAt` directly in
|
|
565
|
+
* `endCurrentTurnAtomic` (turn-end.ts) — for a normally-ended turn the
|
|
566
|
+
* real end IS its end.
|
|
567
|
+
*
|
|
568
|
+
* Serial-session note: claude processes one turn at a time, so pending atoms
|
|
569
|
+
* are naturally few (normally exactly one); `closeAll` closing every pending
|
|
570
|
+
* window on any completion signal is deliberate — closing early is the SAFE
|
|
571
|
+
* direction (a too-short window costs a visible duplicate, never an
|
|
572
|
+
* edit-over). Pure: no clock reads beyond the caller-supplied `now`, no I/O.
|
|
573
|
+
*/
|
|
574
|
+
export class FlushCompletionTracker {
|
|
575
|
+
private pending: Array<{ realEndObservedAt: number | null }> = []
|
|
576
|
+
|
|
577
|
+
/** Hold `turn` pending until the real-turn-end signal; marks its window
|
|
578
|
+
* open (`realEndObservedAt = null`). */
|
|
579
|
+
open(turn: { realEndObservedAt: number | null }): void {
|
|
580
|
+
turn.realEndObservedAt = null
|
|
581
|
+
if (!this.pending.includes(turn)) this.pending.push(turn)
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
/** The real-turn-end signal (or a proxy) was observed: stamp every pending
|
|
585
|
+
* atom and release it. Returns how many windows were closed. */
|
|
586
|
+
closeAll(now: number): number {
|
|
587
|
+
let closed = 0
|
|
588
|
+
for (const turn of this.pending) {
|
|
589
|
+
if (turn.realEndObservedAt == null) {
|
|
590
|
+
turn.realEndObservedAt = now
|
|
591
|
+
closed++
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
this.pending = []
|
|
595
|
+
return closed
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
/** Test/diagnostics: number of atoms still awaiting their real turn_end. */
|
|
599
|
+
pendingCount(): number {
|
|
600
|
+
return this.pending.length
|
|
601
|
+
}
|
|
602
|
+
}
|
|
@@ -65,6 +65,8 @@ export type ParsedAuthCommand =
|
|
|
65
65
|
replace: boolean
|
|
66
66
|
/** Google `--write` (Drive write scope). */
|
|
67
67
|
write: boolean
|
|
68
|
+
/** Google `--calendar` (read-only Calendar scope). */
|
|
69
|
+
calendar: boolean
|
|
68
70
|
/** Microsoft `--org-mode`. */
|
|
69
71
|
orgMode: boolean
|
|
70
72
|
}
|
|
@@ -251,7 +253,7 @@ export function parseAuthCommand(text: string): ParsedAuthCommand | null {
|
|
|
251
253
|
* wired; anything else is a help-with-reason so the operator sees the shape.
|
|
252
254
|
*
|
|
253
255
|
* Flags mirror the CLI:
|
|
254
|
-
* google: `add <email> [--replace] [--write]`
|
|
256
|
+
* google: `add <email> [--replace] [--write] [--calendar]`
|
|
255
257
|
* microsoft: `add <email> [--replace] [--org-mode]`
|
|
256
258
|
*/
|
|
257
259
|
export function parseProviderVerb(
|
|
@@ -265,7 +267,7 @@ export function parseProviderVerb(
|
|
|
265
267
|
if (sub !== 'add') {
|
|
266
268
|
const usage =
|
|
267
269
|
provider === 'google'
|
|
268
|
-
? 'Usage: /auth google add <email> [--replace] [--write]'
|
|
270
|
+
? 'Usage: /auth google add <email> [--replace] [--write] [--calendar]'
|
|
269
271
|
: 'Usage: /auth microsoft add <email> [--replace] [--org-mode]'
|
|
270
272
|
return {
|
|
271
273
|
kind: 'help',
|
|
@@ -280,7 +282,7 @@ export function parseProviderVerb(
|
|
|
280
282
|
if (!email) {
|
|
281
283
|
const usage =
|
|
282
284
|
provider === 'google'
|
|
283
|
-
? 'Usage: /auth google add <email> [--replace] [--write]'
|
|
285
|
+
? 'Usage: /auth google add <email> [--replace] [--write] [--calendar]'
|
|
284
286
|
: 'Usage: /auth microsoft add <email> [--replace] [--org-mode]'
|
|
285
287
|
return { kind: 'help', reason: usage }
|
|
286
288
|
}
|
|
@@ -288,7 +290,7 @@ export function parseProviderVerb(
|
|
|
288
290
|
if (emailErr) return { kind: 'help', reason: emailErr }
|
|
289
291
|
// Reject unknown flags so a typo (`--replaced`) isn't silently dropped.
|
|
290
292
|
const allowed = provider === 'google'
|
|
291
|
-
? new Set(['--replace', '--write'])
|
|
293
|
+
? new Set(['--replace', '--write', '--calendar'])
|
|
292
294
|
: new Set(['--replace', '--org-mode'])
|
|
293
295
|
for (const f of flags) {
|
|
294
296
|
if (!allowed.has(f)) {
|
|
@@ -304,6 +306,7 @@ export function parseProviderVerb(
|
|
|
304
306
|
email,
|
|
305
307
|
replace: flags.has('--replace'),
|
|
306
308
|
write: provider === 'google' && flags.has('--write'),
|
|
309
|
+
calendar: provider === 'google' && flags.has('--calendar'),
|
|
307
310
|
orgMode: provider === 'microsoft' && flags.has('--org-mode'),
|
|
308
311
|
}
|
|
309
312
|
}
|
|
@@ -84,6 +84,8 @@ export interface SpawnRelayOpts {
|
|
|
84
84
|
replace?: boolean
|
|
85
85
|
/** Google `--write` (Drive write scope). */
|
|
86
86
|
write?: boolean
|
|
87
|
+
/** Google `--calendar` (read-only Calendar scope). */
|
|
88
|
+
calendar?: boolean
|
|
87
89
|
/** Microsoft `--org-mode`. */
|
|
88
90
|
orgMode?: boolean
|
|
89
91
|
/** Override the CLI binary (tests / non-default install). */
|
|
@@ -96,6 +98,42 @@ export type SpawnRelay = (
|
|
|
96
98
|
opts: SpawnRelayOpts,
|
|
97
99
|
) => RelayChild
|
|
98
100
|
|
|
101
|
+
/**
|
|
102
|
+
* Build the `switchroom auth <provider> account add` argv the relay spawns.
|
|
103
|
+
*
|
|
104
|
+
* Pure + exported so the flag plumbing is unit-pinned: every opt-in flag
|
|
105
|
+
* the chat command accepts has to actually reach the CLI, and a flag that
|
|
106
|
+
* was NOT requested must never appear (the relay is the only path most
|
|
107
|
+
* operators use, so a dropped `--calendar` here is indistinguishable from
|
|
108
|
+
* the feature not existing, and a spurious one silently widens a grant).
|
|
109
|
+
*/
|
|
110
|
+
export function buildRelayArgs(
|
|
111
|
+
provider: LoopbackProvider,
|
|
112
|
+
email: string,
|
|
113
|
+
opts: SpawnRelayOpts,
|
|
114
|
+
): string[] {
|
|
115
|
+
return provider === 'google'
|
|
116
|
+
? [
|
|
117
|
+
'auth',
|
|
118
|
+
'google',
|
|
119
|
+
'account',
|
|
120
|
+
'add',
|
|
121
|
+
email,
|
|
122
|
+
...(opts.replace ? ['--replace'] : []),
|
|
123
|
+
...(opts.write ? ['--write'] : []),
|
|
124
|
+
...(opts.calendar ? ['--calendar'] : []),
|
|
125
|
+
]
|
|
126
|
+
: [
|
|
127
|
+
'auth',
|
|
128
|
+
'microsoft',
|
|
129
|
+
'account',
|
|
130
|
+
'add',
|
|
131
|
+
email,
|
|
132
|
+
...(opts.replace ? ['--replace'] : []),
|
|
133
|
+
...(opts.orgMode ? ['--org-mode'] : []),
|
|
134
|
+
]
|
|
135
|
+
}
|
|
136
|
+
|
|
99
137
|
/**
|
|
100
138
|
* Default spawn: the real `switchroom` CLI, stdout+stderr piped, stdin
|
|
101
139
|
* ignored. `BROWSER=/bin/true` suppresses any browser auto-open; the Drive
|
|
@@ -109,26 +147,7 @@ export function defaultSpawnRelay(
|
|
|
109
147
|
opts: SpawnRelayOpts,
|
|
110
148
|
): RelayChild {
|
|
111
149
|
const binary = opts.binary ?? 'switchroom'
|
|
112
|
-
const args =
|
|
113
|
-
provider === 'google'
|
|
114
|
-
? [
|
|
115
|
-
'auth',
|
|
116
|
-
'google',
|
|
117
|
-
'account',
|
|
118
|
-
'add',
|
|
119
|
-
email,
|
|
120
|
-
...(opts.replace ? ['--replace'] : []),
|
|
121
|
-
...(opts.write ? ['--write'] : []),
|
|
122
|
-
]
|
|
123
|
-
: [
|
|
124
|
-
'auth',
|
|
125
|
-
'microsoft',
|
|
126
|
-
'account',
|
|
127
|
-
'add',
|
|
128
|
-
email,
|
|
129
|
-
...(opts.replace ? ['--replace'] : []),
|
|
130
|
-
...(opts.orgMode ? ['--org-mode'] : []),
|
|
131
|
-
]
|
|
150
|
+
const args = buildRelayArgs(provider, email, opts)
|
|
132
151
|
const child: ChildProcess = spawn(binary, args, {
|
|
133
152
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
134
153
|
env: {
|
|
@@ -35,6 +35,37 @@ export function baseAgent(name: string): string {
|
|
|
35
35
|
return isCronIdentity(name) ? name.slice(0, -CRON_IDENTITY_SUFFIX.length) : name;
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
+
/**
|
|
39
|
+
* #4172 — is a `reply` tool call arriving on this IPC client identity a
|
|
40
|
+
* FOREIGN-SESSION reply, i.e. one that can NEVER be the main claude session's
|
|
41
|
+
* own (possibly late/reworded) answer to a turn the gateway attributed?
|
|
42
|
+
*
|
|
43
|
+
* The whole flushed-turn supersede/bypass/latch machinery
|
|
44
|
+
* (`outbound-send-path.ts`) rests on the premise that a decoupled late reply
|
|
45
|
+
* resolving an ended turn is that turn's own answer unless a handback marker
|
|
46
|
+
* says otherwise. That premise only holds for replies from the MAIN agent
|
|
47
|
+
* bridge — the one whose session the turns belong to. A Tier-1 cheap-cron
|
|
48
|
+
* fire's reply arrives on the derived `<agent>-cron` bridge: it never mints a
|
|
49
|
+
* gateway turn (its session events are dropped in `onSessionEvent`), never
|
|
50
|
+
* traverses `pendingInboundBuffer.push` (so it cannot stamp the handback
|
|
51
|
+
* marker), and lands decoupled with foreign content — the measured #4172
|
|
52
|
+
* failure was such a reply EDITING OVER a flushed answer (double loss: the
|
|
53
|
+
* flushed answer destroyed, the cron digest delivered as a silent edit).
|
|
54
|
+
* Identity is the one deterministic signal that separates it.
|
|
55
|
+
*
|
|
56
|
+
* Returns true (foreign — refuse supersede/bypass/latch authority, always
|
|
57
|
+
* send fresh) for:
|
|
58
|
+
* - a cron-session identity (`<agent>-cron`);
|
|
59
|
+
* - a null/absent identity (an unregistered client — it owns no session,
|
|
60
|
+
* so fail SAFE: a visible fresh message, never destructive authority).
|
|
61
|
+
* Returns false only for a registered non-cron agent bridge — the main
|
|
62
|
+
* session.
|
|
63
|
+
*/
|
|
64
|
+
export function replyCallerIsForeignSession(name: string | null | undefined): boolean {
|
|
65
|
+
if (name == null || name === "") return true;
|
|
66
|
+
return isCronIdentity(name);
|
|
67
|
+
}
|
|
68
|
+
|
|
38
69
|
/**
|
|
39
70
|
* True iff an inject_inbound fire is a scheduled cron fire — Tier-1 cheap-cron
|
|
40
71
|
* (`meta.session='cron'`, routed to the derived `<agent>-cron` bridge) OR a
|