@0xmaxma/claude-gateway 1.8.2 → 1.8.3

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 (77) hide show
  1. package/README.md +7 -2
  2. package/config.template.json +4 -0
  3. package/dist/agent/dreaming/config.d.ts +6 -0
  4. package/dist/agent/dreaming/config.d.ts.map +1 -1
  5. package/dist/agent/dreaming/config.js +17 -24
  6. package/dist/agent/dreaming/config.js.map +1 -1
  7. package/dist/agent/knowledge/config.d.ts.map +1 -1
  8. package/dist/agent/knowledge/config.js +7 -13
  9. package/dist/agent/knowledge/config.js.map +1 -1
  10. package/dist/agent/runner.d.ts +87 -13
  11. package/dist/agent/runner.d.ts.map +1 -1
  12. package/dist/agent/runner.js +342 -117
  13. package/dist/agent/runner.js.map +1 -1
  14. package/dist/agent/turn-stream.d.ts +202 -0
  15. package/dist/agent/turn-stream.d.ts.map +1 -0
  16. package/dist/agent/turn-stream.js +322 -0
  17. package/dist/agent/turn-stream.js.map +1 -0
  18. package/dist/api/apps-router.d.ts.map +1 -1
  19. package/dist/api/apps-router.js +14 -2
  20. package/dist/api/apps-router.js.map +1 -1
  21. package/dist/api/gateway-router.d.ts.map +1 -1
  22. package/dist/api/gateway-router.js +2 -3
  23. package/dist/api/gateway-router.js.map +1 -1
  24. package/dist/api/line-webhook-router.d.ts +43 -1
  25. package/dist/api/line-webhook-router.d.ts.map +1 -1
  26. package/dist/api/line-webhook-router.js +286 -38
  27. package/dist/api/line-webhook-router.js.map +1 -1
  28. package/dist/api/router.d.ts +7 -1
  29. package/dist/api/router.d.ts.map +1 -1
  30. package/dist/api/router.js +328 -114
  31. package/dist/api/router.js.map +1 -1
  32. package/dist/apps/agent-manager.d.ts.map +1 -1
  33. package/dist/apps/agent-manager.js +4 -1
  34. package/dist/apps/agent-manager.js.map +1 -1
  35. package/dist/apps/installer.d.ts +75 -2
  36. package/dist/apps/installer.d.ts.map +1 -1
  37. package/dist/apps/installer.js +198 -13
  38. package/dist/apps/installer.js.map +1 -1
  39. package/dist/config/agent-env.d.ts +41 -0
  40. package/dist/config/agent-env.d.ts.map +1 -0
  41. package/dist/config/agent-env.js +154 -0
  42. package/dist/config/agent-env.js.map +1 -0
  43. package/dist/config/loader.d.ts +33 -2
  44. package/dist/config/loader.d.ts.map +1 -1
  45. package/dist/config/loader.js +33 -9
  46. package/dist/config/loader.js.map +1 -1
  47. package/dist/config/watcher.d.ts +1 -0
  48. package/dist/config/watcher.d.ts.map +1 -1
  49. package/dist/config/watcher.js +17 -1
  50. package/dist/config/watcher.js.map +1 -1
  51. package/dist/index.js +24 -51
  52. package/dist/index.js.map +1 -1
  53. package/dist/load-dotenv.d.ts +17 -0
  54. package/dist/load-dotenv.d.ts.map +1 -1
  55. package/dist/load-dotenv.js +32 -9
  56. package/dist/load-dotenv.js.map +1 -1
  57. package/dist/session/store.d.ts +9 -0
  58. package/dist/session/store.d.ts.map +1 -1
  59. package/dist/session/store.js +12 -0
  60. package/dist/session/store.js.map +1 -1
  61. package/dist/shell/bypass-dialog.d.ts +142 -0
  62. package/dist/shell/bypass-dialog.d.ts.map +1 -0
  63. package/dist/shell/bypass-dialog.js +207 -0
  64. package/dist/shell/bypass-dialog.js.map +1 -0
  65. package/dist/shell/claude-pty-shell.js +42 -3
  66. package/dist/shell/claude-pty-shell.js.map +1 -1
  67. package/dist/shell/screen.d.ts +7 -0
  68. package/dist/shell/screen.d.ts.map +1 -1
  69. package/dist/shell/screen.js +10 -1
  70. package/dist/shell/screen.js.map +1 -1
  71. package/dist/types.d.ts +20 -0
  72. package/dist/types.d.ts.map +1 -1
  73. package/dist/utils/config-num.d.ts +22 -0
  74. package/dist/utils/config-num.d.ts.map +1 -0
  75. package/dist/utils/config-num.js +34 -0
  76. package/dist/utils/config-num.js.map +1 -0
  77. package/package.json +1 -1
@@ -0,0 +1,202 @@
1
+ /**
2
+ * Resumable turn streams (#421).
3
+ *
4
+ * A streamed turn used to be bound to exactly one HTTP request: every event was
5
+ * written straight to `res` behind an `if (!clientGone)` guard, so a client
6
+ * disconnect (or the soft timeout, which set the same flag) discarded the rest
7
+ * of the turn even though it kept running server-side and landed in history.
8
+ *
9
+ * A TurnStream decouples the two: the producer always records into an ordered,
10
+ * sequence-numbered buffer, and a *sink* — the thing that writes to a socket —
11
+ * is attached, detached and re-attached independently. A new SSE connection can
12
+ * therefore replay the tail from a cursor and then keep receiving live events,
13
+ * with no gap and no duplicate at the seam (attach() replays and installs in one
14
+ * synchronous block, so no event can slip between the two).
15
+ */
16
+ import { ApiAttachment, StreamEvent } from '../types';
17
+ /** Max events retained per turn before the oldest are evicted. */
18
+ export declare const TURN_BUFFER_MAX_EVENTS = 2000;
19
+ /** Max approximate payload bytes retained per turn (heavy tool output). */
20
+ export declare const TURN_BUFFER_MAX_BYTES: number;
21
+ /** How long a completed turn stays replayable after its terminal frame. */
22
+ export declare const TURN_REPLAY_GRACE_MS = 120000;
23
+ /** A buffered event plus its per-turn sequence number. */
24
+ export interface SeqEvent {
25
+ seq: number;
26
+ event: StreamEvent;
27
+ /**
28
+ * Terminal `error` frames only: the original Error, so an in-process sink can
29
+ * hand callers the real object (with its `code`) rather than a re-wrapped
30
+ * message. SSE sinks ignore it and serialise `event` alone.
31
+ */
32
+ error?: Error;
33
+ }
34
+ /** Whatever is currently writing a turn's events out (an SSE response, a test spy). */
35
+ export interface TurnSink {
36
+ /** A non-terminal event. */
37
+ write(e: SeqEvent): void;
38
+ /** The terminal `result` / `error` frame. The sink should close after this. */
39
+ finish(e: SeqEvent): void;
40
+ /**
41
+ * Another connection took over this turn. Optional: sinks that own a socket
42
+ * should close it, otherwise the displaced connection hangs open with no
43
+ * terminal frame ever coming — it is no longer the one being written to.
44
+ */
45
+ displaced?(): void;
46
+ }
47
+ export type AttachFailure = 'truncated' | 'ahead';
48
+ /**
49
+ * One in-flight (or recently completed) turn: its ordered event buffer, its
50
+ * terminal frame once known, and the sink currently draining it.
51
+ */
52
+ export declare class TurnStream {
53
+ /** The registry key this turn is filed under — see turnStreamKey(). */
54
+ readonly key: string;
55
+ readonly requestId: string;
56
+ readonly startedAt: number;
57
+ private readonly events;
58
+ private nextSeq;
59
+ /**
60
+ * seq of the NEWEST event evicted so far — eviction walks the head forward, so
61
+ * this advances with each drop. Anything at or below it is unreplayable, which
62
+ * is exactly the comparison attach() makes.
63
+ */
64
+ private evictedThroughSeq;
65
+ private bytes;
66
+ private terminalEvent;
67
+ private sink;
68
+ constructor(
69
+ /** The registry key this turn is filed under — see turnStreamKey(). */
70
+ key: string, requestId: string);
71
+ get isComplete(): boolean;
72
+ get lastSeq(): number;
73
+ /** Record a non-terminal event and, if a sink is attached, write it out. */
74
+ emit(event: StreamEvent): void;
75
+ /**
76
+ * Record the terminal frame (`result` or `error`), flush it to the sink and
77
+ * detach — the turn is over, and anything that attaches later replays instead.
78
+ */
79
+ complete(event: StreamEvent, error?: Error): void;
80
+ /**
81
+ * Replay everything after `afterSeq`, then install `sink` as the live one.
82
+ * Deliberately synchronous end to end: an event produced during an `await`
83
+ * here would land in the buffer but miss the sink (gap) or arrive twice (dup).
84
+ *
85
+ * Returns `'truncated'` — installing nothing — when `afterSeq` names an event
86
+ * the buffer has already evicted: the caller asked to continue seamlessly from
87
+ * a point that no longer exists, and that promise cannot be honoured.
88
+ *
89
+ * `afterSeq === 0` is exempt. It claims to have seen nothing, so there is no
90
+ * seam to break, and refusing it is what turned the headline case — a reload
91
+ * during a long turn — into a dead end: history holds no assistant row while
92
+ * the turn is still running, so the client was sent somewhere with nothing in
93
+ * it. It gets whatever the buffer still holds instead; the first replayed
94
+ * frame's `seq` is > 1 exactly when older events were dropped, and the terminal
95
+ * `result` carries the turn's full text regardless.
96
+ *
97
+ * Returns `'ahead'` when `afterSeq` runs past the last event this turn has
98
+ * produced. Seq numbering restarts at 1 for every turn, so a client that
99
+ * reloads and replays a cursor held over from an earlier turn would otherwise
100
+ * attach successfully, match no event, and still be handed the terminal frame
101
+ * — the final answer with every delta silently missing.
102
+ *
103
+ * This catches a stale cursor only when the new turn is *shorter* than the old
104
+ * one; a cursor from turn N sits inside turn N+1 the moment N+1 has emitted
105
+ * that many events, and no seq comparison can tell that apart from a genuine
106
+ * resume. Identity is what separates them, which is why the resume endpoint
107
+ * requires `request_id` whenever `after_seq > 0`.
108
+ */
109
+ attach(sink: TurnSink, afterSeq: number): AttachFailure | null;
110
+ /**
111
+ * Detach `sink` if it is still the live one. Passing the sink (rather than
112
+ * clearing unconditionally) keeps a stale `res.on('close')` from a superseded
113
+ * connection from silencing the sink that replaced it.
114
+ */
115
+ detach(sink: TurnSink): void;
116
+ private evictIfOverCap;
117
+ }
118
+ /**
119
+ * Registry key for a turn. Every producer namespaces its session ids, because
120
+ * one registry serves them all: the API path files under `api`, a channel turn
121
+ * under its channel name. Without the prefix the two id spaces shared one
122
+ * keyspace and a collision would let one producer's turn evict or terminate the
123
+ * other's — a claim the old comments here asserted could not happen while
124
+ * complete()'s guard below existed precisely because it could.
125
+ *
126
+ * The chat id is deliberately NOT part of the key: an API session id is minted
127
+ * by the gateway inside a single `api-{chatId}` index and cannot be presented
128
+ * for another chat (see apiSessionExists), so the namespace prefix already makes
129
+ * every key unique.
130
+ */
131
+ export declare function turnStreamKey(namespace: string, sessionId: string): string;
132
+ /**
133
+ * Per-session registry of turn streams, with the completed-turn grace window.
134
+ *
135
+ * Keyed by turnStreamKey(); each record carries its request id, so a client that
136
+ * re-attaches can assert it is resuming the turn it thinks it is.
137
+ */
138
+ export declare class TurnStreamRegistry {
139
+ private readonly graceMs;
140
+ private readonly turns;
141
+ private readonly releaseTimers;
142
+ constructor(graceMs?: number);
143
+ /**
144
+ * Begin a new turn, replacing (and un-scheduling) any previous record for the
145
+ * key so a fresh turn never inherits the last one's buffer — the same reason
146
+ * `pendingApiAttachments` is cleared at the top of every turn.
147
+ *
148
+ * This also ends the previous turn's replay grace window early: a session
149
+ * holds at most one turn, so nothing of turn N survives once turn N+1 starts.
150
+ * A client that resumes turn N by name (`request_id`) therefore gets
151
+ * TURN_MISMATCH — not a replay of N, and not a replay of N+1 either. A client
152
+ * that resumes by cursor alone cannot be told apart from one legitimately
153
+ * following N+1, which is why the resume endpoint requires the request id
154
+ * alongside a non-zero cursor. Documented in API.md under "Resuming an
155
+ * interrupted stream"; history is the fallback.
156
+ */
157
+ start(key: string, requestId: string): TurnStream;
158
+ get(key: string): TurnStream | undefined;
159
+ /**
160
+ * Record the terminal frame and keep the turn replayable for the grace window.
161
+ *
162
+ * Takes the TurnStream itself, not a key: a turn superseded by the next one on
163
+ * the same session finishes late often enough, and it must terminate its OWN
164
+ * record, never the one that replaced it.
165
+ */
166
+ complete(turn: TurnStream, event: StreamEvent, error?: Error): void;
167
+ /**
168
+ * Terminal frame + immediate release, for a producer whose turns nothing can
169
+ * re-attach to: the cross-channel live view files under its channel namespace,
170
+ * and the resume endpoint only ever looks under `api`. Holding that buffer for
171
+ * the grace window would retain up to the full per-turn cap (2,000 events /
172
+ * ~4 MB) for two minutes with no code path able to replay a byte of it.
173
+ *
174
+ * Same superseded-turn guard as complete(): a late finisher releases its OWN
175
+ * record, never the one that replaced it.
176
+ */
177
+ completeAndRelease(turn: TurnStream, event: StreamEvent, error?: Error): void;
178
+ /** Drop the record and its grace timer. */
179
+ release(key: string): void;
180
+ clear(): void;
181
+ }
182
+ /** Terminal frame for a turn that finished normally. */
183
+ export declare function resultEvent(text: string, attachments: ApiAttachment[]): StreamEvent;
184
+ /** Terminal frame for a turn that failed, carrying the Error's `code` when it has one. */
185
+ export declare function errorEvent(err: Error): StreamEvent;
186
+ /** The `code` property producers attach to their Errors, when it is a string. */
187
+ export declare function errorCode(err: unknown): string | undefined;
188
+ /**
189
+ * The callback shape every streaming producer in AgentRunner emits into. `seq`
190
+ * is the event's per-turn sequence number — the cursor a client passes back to
191
+ * `GET …/sessions/:sessionId/stream?after_seq=` to resume without a gap.
192
+ */
193
+ export interface ApiStreamCallbacks {
194
+ onChunk: (event: StreamEvent, seq?: number) => void;
195
+ onDone: (fullText: string, attachments: ApiAttachment[], seq?: number) => void;
196
+ onError: (err: Error, seq?: number) => void;
197
+ /** Another connection resumed this turn; this one is no longer being written to. */
198
+ onDisplaced?: () => void;
199
+ }
200
+ /** Adapt a callback trio into a sink a TurnStream can drive. */
201
+ export declare function callbackSink(callbacks: ApiStreamCallbacks): TurnSink;
202
+ //# sourceMappingURL=turn-stream.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"turn-stream.d.ts","sourceRoot":"","sources":["../../src/agent/turn-stream.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,322 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.TurnStreamRegistry = exports.TurnStream = exports.TURN_REPLAY_GRACE_MS = exports.TURN_BUFFER_MAX_BYTES = exports.TURN_BUFFER_MAX_EVENTS = void 0;
4
+ exports.turnStreamKey = turnStreamKey;
5
+ exports.resultEvent = resultEvent;
6
+ exports.errorEvent = errorEvent;
7
+ exports.errorCode = errorCode;
8
+ exports.callbackSink = callbackSink;
9
+ /** Max events retained per turn before the oldest are evicted. */
10
+ exports.TURN_BUFFER_MAX_EVENTS = 2000;
11
+ /** Max approximate payload bytes retained per turn (heavy tool output). */
12
+ exports.TURN_BUFFER_MAX_BYTES = 4 * 1024 * 1024;
13
+ /** How long a completed turn stays replayable after its terminal frame. */
14
+ exports.TURN_REPLAY_GRACE_MS = 120000;
15
+ /** Fraction of the cap evicted at once, so eviction is amortised O(1) per event. */
16
+ const EVICT_BATCH_RATIO = 0.1;
17
+ /**
18
+ * One in-flight (or recently completed) turn: its ordered event buffer, its
19
+ * terminal frame once known, and the sink currently draining it.
20
+ */
21
+ class TurnStream {
22
+ constructor(
23
+ /** The registry key this turn is filed under — see turnStreamKey(). */
24
+ key, requestId) {
25
+ this.key = key;
26
+ this.requestId = requestId;
27
+ this.startedAt = Date.now();
28
+ this.events = [];
29
+ this.nextSeq = 1;
30
+ /**
31
+ * seq of the NEWEST event evicted so far — eviction walks the head forward, so
32
+ * this advances with each drop. Anything at or below it is unreplayable, which
33
+ * is exactly the comparison attach() makes.
34
+ */
35
+ this.evictedThroughSeq = 0;
36
+ this.bytes = 0;
37
+ this.terminalEvent = null;
38
+ this.sink = null;
39
+ }
40
+ get isComplete() {
41
+ return this.terminalEvent !== null;
42
+ }
43
+ get lastSeq() {
44
+ return this.nextSeq - 1;
45
+ }
46
+ /** Record a non-terminal event and, if a sink is attached, write it out. */
47
+ emit(event) {
48
+ if (this.terminalEvent)
49
+ return; // nothing may follow the terminal frame
50
+ const e = { seq: this.nextSeq++, event, bytes: approxSize(event) };
51
+ this.events.push(e);
52
+ this.bytes += e.bytes;
53
+ this.evictIfOverCap();
54
+ this.sink?.write(e);
55
+ }
56
+ /**
57
+ * Record the terminal frame (`result` or `error`), flush it to the sink and
58
+ * detach — the turn is over, and anything that attaches later replays instead.
59
+ */
60
+ complete(event, error) {
61
+ if (this.terminalEvent)
62
+ return;
63
+ const e = { seq: this.nextSeq++, event, ...(error ? { error } : {}) };
64
+ this.terminalEvent = e;
65
+ const sink = this.sink;
66
+ this.sink = null;
67
+ sink?.finish(e);
68
+ }
69
+ /**
70
+ * Replay everything after `afterSeq`, then install `sink` as the live one.
71
+ * Deliberately synchronous end to end: an event produced during an `await`
72
+ * here would land in the buffer but miss the sink (gap) or arrive twice (dup).
73
+ *
74
+ * Returns `'truncated'` — installing nothing — when `afterSeq` names an event
75
+ * the buffer has already evicted: the caller asked to continue seamlessly from
76
+ * a point that no longer exists, and that promise cannot be honoured.
77
+ *
78
+ * `afterSeq === 0` is exempt. It claims to have seen nothing, so there is no
79
+ * seam to break, and refusing it is what turned the headline case — a reload
80
+ * during a long turn — into a dead end: history holds no assistant row while
81
+ * the turn is still running, so the client was sent somewhere with nothing in
82
+ * it. It gets whatever the buffer still holds instead; the first replayed
83
+ * frame's `seq` is > 1 exactly when older events were dropped, and the terminal
84
+ * `result` carries the turn's full text regardless.
85
+ *
86
+ * Returns `'ahead'` when `afterSeq` runs past the last event this turn has
87
+ * produced. Seq numbering restarts at 1 for every turn, so a client that
88
+ * reloads and replays a cursor held over from an earlier turn would otherwise
89
+ * attach successfully, match no event, and still be handed the terminal frame
90
+ * — the final answer with every delta silently missing.
91
+ *
92
+ * This catches a stale cursor only when the new turn is *shorter* than the old
93
+ * one; a cursor from turn N sits inside turn N+1 the moment N+1 has emitted
94
+ * that many events, and no seq comparison can tell that apart from a genuine
95
+ * resume. Identity is what separates them, which is why the resume endpoint
96
+ * requires `request_id` whenever `after_seq > 0`.
97
+ */
98
+ attach(sink, afterSeq) {
99
+ if (afterSeq > this.lastSeq)
100
+ return 'ahead';
101
+ if (afterSeq > 0 && afterSeq < this.evictedThroughSeq)
102
+ return 'truncated';
103
+ // Retire whoever held the slot before the replay, so the displaced socket
104
+ // closes instead of waiting forever for a terminal frame it will not get.
105
+ const previous = this.sink;
106
+ if (previous && previous !== sink) {
107
+ this.sink = null;
108
+ try {
109
+ previous.displaced?.();
110
+ }
111
+ catch { /* already gone */ }
112
+ }
113
+ for (const e of this.events) {
114
+ if (e.seq > afterSeq)
115
+ sink.write(e);
116
+ }
117
+ if (this.terminalEvent) {
118
+ sink.finish(this.terminalEvent);
119
+ return null;
120
+ }
121
+ this.sink = sink;
122
+ return null;
123
+ }
124
+ /**
125
+ * Detach `sink` if it is still the live one. Passing the sink (rather than
126
+ * clearing unconditionally) keeps a stale `res.on('close')` from a superseded
127
+ * connection from silencing the sink that replaced it.
128
+ */
129
+ detach(sink) {
130
+ if (this.sink === sink)
131
+ this.sink = null;
132
+ }
133
+ evictIfOverCap() {
134
+ if (this.events.length <= exports.TURN_BUFFER_MAX_EVENTS && this.bytes <= exports.TURN_BUFFER_MAX_BYTES)
135
+ return;
136
+ const batch = Math.max(1, Math.floor(exports.TURN_BUFFER_MAX_EVENTS * EVICT_BATCH_RATIO));
137
+ while (this.events.length > 0 &&
138
+ (this.events.length > exports.TURN_BUFFER_MAX_EVENTS - batch || this.bytes > exports.TURN_BUFFER_MAX_BYTES)) {
139
+ const dropped = this.events.shift();
140
+ this.bytes -= dropped.bytes;
141
+ this.evictedThroughSeq = dropped.seq;
142
+ }
143
+ }
144
+ }
145
+ exports.TurnStream = TurnStream;
146
+ /**
147
+ * Registry key for a turn. Every producer namespaces its session ids, because
148
+ * one registry serves them all: the API path files under `api`, a channel turn
149
+ * under its channel name. Without the prefix the two id spaces shared one
150
+ * keyspace and a collision would let one producer's turn evict or terminate the
151
+ * other's — a claim the old comments here asserted could not happen while
152
+ * complete()'s guard below existed precisely because it could.
153
+ *
154
+ * The chat id is deliberately NOT part of the key: an API session id is minted
155
+ * by the gateway inside a single `api-{chatId}` index and cannot be presented
156
+ * for another chat (see apiSessionExists), so the namespace prefix already makes
157
+ * every key unique.
158
+ */
159
+ function turnStreamKey(namespace, sessionId) {
160
+ return `${namespace}:${sessionId}`;
161
+ }
162
+ /**
163
+ * Per-session registry of turn streams, with the completed-turn grace window.
164
+ *
165
+ * Keyed by turnStreamKey(); each record carries its request id, so a client that
166
+ * re-attaches can assert it is resuming the turn it thinks it is.
167
+ */
168
+ class TurnStreamRegistry {
169
+ constructor(graceMs = exports.TURN_REPLAY_GRACE_MS) {
170
+ this.graceMs = graceMs;
171
+ this.turns = new Map();
172
+ this.releaseTimers = new Map();
173
+ }
174
+ /**
175
+ * Begin a new turn, replacing (and un-scheduling) any previous record for the
176
+ * key so a fresh turn never inherits the last one's buffer — the same reason
177
+ * `pendingApiAttachments` is cleared at the top of every turn.
178
+ *
179
+ * This also ends the previous turn's replay grace window early: a session
180
+ * holds at most one turn, so nothing of turn N survives once turn N+1 starts.
181
+ * A client that resumes turn N by name (`request_id`) therefore gets
182
+ * TURN_MISMATCH — not a replay of N, and not a replay of N+1 either. A client
183
+ * that resumes by cursor alone cannot be told apart from one legitimately
184
+ * following N+1, which is why the resume endpoint requires the request id
185
+ * alongside a non-zero cursor. Documented in API.md under "Resuming an
186
+ * interrupted stream"; history is the fallback.
187
+ */
188
+ start(key, requestId) {
189
+ this.release(key);
190
+ const turn = new TurnStream(key, requestId);
191
+ this.turns.set(key, turn);
192
+ return turn;
193
+ }
194
+ get(key) {
195
+ return this.turns.get(key);
196
+ }
197
+ /**
198
+ * Record the terminal frame and keep the turn replayable for the grace window.
199
+ *
200
+ * Takes the TurnStream itself, not a key: a turn superseded by the next one on
201
+ * the same session finishes late often enough, and it must terminate its OWN
202
+ * record, never the one that replaced it.
203
+ */
204
+ complete(turn, event, error) {
205
+ turn.complete(event, error);
206
+ // Only the current record earns a grace timer; a superseded one is already
207
+ // unreachable and would otherwise schedule a release for its successor.
208
+ if (this.turns.get(turn.key) !== turn)
209
+ return;
210
+ const timer = setTimeout(() => this.release(turn.key), this.graceMs);
211
+ timer.unref?.();
212
+ this.releaseTimers.set(turn.key, timer);
213
+ }
214
+ /**
215
+ * Terminal frame + immediate release, for a producer whose turns nothing can
216
+ * re-attach to: the cross-channel live view files under its channel namespace,
217
+ * and the resume endpoint only ever looks under `api`. Holding that buffer for
218
+ * the grace window would retain up to the full per-turn cap (2,000 events /
219
+ * ~4 MB) for two minutes with no code path able to replay a byte of it.
220
+ *
221
+ * Same superseded-turn guard as complete(): a late finisher releases its OWN
222
+ * record, never the one that replaced it.
223
+ */
224
+ completeAndRelease(turn, event, error) {
225
+ turn.complete(event, error);
226
+ if (this.turns.get(turn.key) === turn)
227
+ this.release(turn.key);
228
+ }
229
+ /** Drop the record and its grace timer. */
230
+ release(key) {
231
+ const timer = this.releaseTimers.get(key);
232
+ if (timer) {
233
+ clearTimeout(timer);
234
+ this.releaseTimers.delete(key);
235
+ }
236
+ this.turns.delete(key);
237
+ }
238
+ clear() {
239
+ for (const timer of this.releaseTimers.values())
240
+ clearTimeout(timer);
241
+ this.releaseTimers.clear();
242
+ this.turns.clear();
243
+ }
244
+ }
245
+ exports.TurnStreamRegistry = TurnStreamRegistry;
246
+ /** Cheap stand-in for the serialised size — exact bytes aren't worth the hot-path cost. */
247
+ function approxSize(event) {
248
+ switch (event.type) {
249
+ case 'text_delta':
250
+ case 'thinking':
251
+ return event.text.length + 32;
252
+ case 'result':
253
+ return event.text.length + 32 + (event.attachments?.length ?? 0) * 128;
254
+ case 'error':
255
+ case 'timeout':
256
+ return event.message.length + 32;
257
+ case 'tool_use':
258
+ return event.name.length + event.id.length + (event.input ? JSON.stringify(event.input).length : 0) + 32;
259
+ }
260
+ }
261
+ /** Terminal frame for a turn that finished normally. */
262
+ function resultEvent(text, attachments) {
263
+ return attachments.length ? { type: 'result', text, attachments } : { type: 'result', text };
264
+ }
265
+ /** Terminal frame for a turn that failed, carrying the Error's `code` when it has one. */
266
+ function errorEvent(err) {
267
+ const code = errorCode(err);
268
+ return code ? { type: 'error', message: err.message, code } : { type: 'error', message: err.message };
269
+ }
270
+ /** The `code` property producers attach to their Errors, when it is a string. */
271
+ function errorCode(err) {
272
+ const code = err?.code;
273
+ return typeof code === 'string' ? code : undefined;
274
+ }
275
+ /** Best-effort message for a terminal frame that is not a `result`. */
276
+ function terminalMessage(event) {
277
+ return 'message' in event ? event.message : `Turn ended (${event.type})`;
278
+ }
279
+ /**
280
+ * Rebuild an Error for a terminal frame whose original Error is not available —
281
+ * a replayed `error` frame reaches a sink as data, not as the live throw. The
282
+ * frame's `code` is copied back onto it so a resumed client learns the same
283
+ * thing the original connection did.
284
+ */
285
+ function terminalError(event) {
286
+ const err = new Error(terminalMessage(event));
287
+ if (event.type === 'error' && event.code)
288
+ Object.assign(err, { code: event.code });
289
+ return err;
290
+ }
291
+ /** Adapt a callback trio into a sink a TurnStream can drive. */
292
+ function callbackSink(callbacks) {
293
+ return {
294
+ write: (e) => {
295
+ try {
296
+ callbacks.onChunk(e.event, e.seq);
297
+ }
298
+ catch { /* sink gone */ }
299
+ },
300
+ finish: (e) => {
301
+ try {
302
+ if (e.event.type === 'result') {
303
+ callbacks.onDone(e.event.text, e.event.attachments ?? [], e.seq);
304
+ }
305
+ else {
306
+ // `error` — and anything else that ever ends up terminal. A sink that
307
+ // is handed a terminal frame it does not recognise must still be told
308
+ // the turn is over, or an SSE response stays open forever.
309
+ callbacks.onError(e.error ?? terminalError(e.event), e.seq);
310
+ }
311
+ }
312
+ catch { /* sink gone */ }
313
+ },
314
+ displaced: () => {
315
+ try {
316
+ callbacks.onDisplaced?.();
317
+ }
318
+ catch { /* sink gone */ }
319
+ },
320
+ };
321
+ }
322
+ //# sourceMappingURL=turn-stream.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"turn-stream.js","sourceRoot":"","sources":["../../src/agent/turn-stream.ts"],"names":[],"mappings":"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"}
@@ -1 +1 @@
1
- {"version":3,"file":"apps-router.d.ts","sourceRoot":"","sources":["../../src/api/apps-router.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAAqB,MAAM,SAAS,CAAC;AACpD,OAAO,EAAE,MAAM,EAAE,MAAM,UAAU,CAAC;AAElC,OAAO,EAAE,YAAY,EAAE,MAAM,kBAAkB,CAAC;AAChD,OAAO,EAAE,YAAY,EAAE,MAAM,mBAAmB,CAAC;AACjD,OAAO,EAAE,cAAc,EAAE,MAAM,yBAAyB,CAAC;AAgDzD,wBAAgB,gBAAgB,CAC9B,QAAQ,EAAE,YAAY,EACtB,SAAS,EAAE,YAAY,EACvB,cAAc,EAAE,cAAc,EAC9B,OAAO,EAAE,MAAM,EAAE,GAChB,MAAM,CAwbR"}
1
+ {"version":3,"file":"apps-router.d.ts","sourceRoot":"","sources":["../../src/api/apps-router.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAAqB,MAAM,SAAS,CAAC;AACpD,OAAO,EAAE,MAAM,EAAE,MAAM,UAAU,CAAC;AAElC,OAAO,EAAE,YAAY,EAAY,MAAM,kBAAkB,CAAC;AAC1D,OAAO,EAAE,YAAY,EAAE,MAAM,mBAAmB,CAAC;AACjD,OAAO,EAAE,cAAc,EAAE,MAAM,yBAAyB,CAAC;AA+DzD,wBAAgB,gBAAgB,CAC9B,QAAQ,EAAE,YAAY,EACtB,SAAS,EAAE,YAAY,EACvB,cAAc,EAAE,cAAc,EAC9B,OAAO,EAAE,MAAM,EAAE,GAChB,MAAM,CAwbR"}
@@ -4,6 +4,18 @@ exports.createAppsRouter = createAppsRouter;
4
4
  const express_1 = require("express");
5
5
  const auth_1 = require("./auth");
6
6
  const compose_generator_1 = require("../apps/compose-generator");
7
+ /**
8
+ * Attach this boot's restore failure to an app entry, when one is recorded, so
9
+ * an app the gateway tried and failed to start is distinguishable from one an
10
+ * operator deliberately stopped (issue #425). Absent on every healthy app, so
11
+ * the response shape is unchanged for existing consumers.
12
+ */
13
+ function withRestoreFailure(installer, entry) {
14
+ const failure = installer.getRestoreFailure(entry.name);
15
+ if (!failure)
16
+ return entry;
17
+ return { ...entry, restoreError: failure.error, restoreFailedAt: failure.at };
18
+ }
7
19
  /**
8
20
  * Parse a request-body `ports` field into a validated host-port override map.
9
21
  * Returns undefined when absent. Throws (→ 400) on a non-object, a non-integer
@@ -85,7 +97,7 @@ function createAppsRouter(registry, installer, registryClient, apiKeys) {
85
97
  // Reconcile each stored status against the live Docker runtime so a
86
98
  // container that crashed/was killed externally no longer reports running.
87
99
  const reconciled = await installer.reconcileStatuses(apps);
88
- res.json({ apps: reconciled });
100
+ res.json({ apps: reconciled.map((e) => withRestoreFailure(installer, e)) });
89
101
  }
90
102
  catch (err) {
91
103
  res.status(500).json({ error: err.message });
@@ -186,7 +198,7 @@ function createAppsRouter(registry, installer, registryClient, apiKeys) {
186
198
  }
187
199
  // Reconcile the stored status against the live Docker runtime before return.
188
200
  const reconciled = await installer.reconcileStatus(entry);
189
- res.json(reconciled);
201
+ res.json(withRestoreFailure(installer, reconciled));
190
202
  }
191
203
  catch (err) {
192
204
  res.status(500).json({ error: err.message });