@proagentstore/cli 0.4.98 → 0.4.100
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.
|
@@ -21,6 +21,8 @@ export { defaultStatePath } from "./headless-state.js";
|
|
|
21
21
|
* (refusing new records) would lose the turns that just happened rather than ancient ones.
|
|
22
22
|
*/
|
|
23
23
|
const MAX_PENDING_USAGE = 200;
|
|
24
|
+
/** How many consumed-input ids a session reports (#1019). Re-sent on every snapshot, so bounded; the oldest fall off. */
|
|
25
|
+
export const MAX_CONSUMED_INPUTS = 50;
|
|
24
26
|
/**
|
|
25
27
|
* How many un-drained consequential acts a session holds (#294).
|
|
26
28
|
*
|
|
@@ -92,6 +94,15 @@ export class HeadlessSession {
|
|
|
92
94
|
stopped = false;
|
|
93
95
|
/** How the last COMPLETED turn ended (#545). Null until one has. See {@link EngineTurnReport}. */
|
|
94
96
|
turnReport = null;
|
|
97
|
+
/**
|
|
98
|
+
* Inputs written to the persistent process and not yet answered by a turn end, oldest first (#1019).
|
|
99
|
+
* `null` holds the place of an input sent without an id, so the ids after it stay aligned.
|
|
100
|
+
*/
|
|
101
|
+
pendingInputs = [];
|
|
102
|
+
/** The id of the input the current one-shot turn was spawned for (#1019). */
|
|
103
|
+
turnInputId = null;
|
|
104
|
+
/** Ids of inputs a COMPLETED turn consumed, newest last and bounded (#1019). See {@link consumedInputs}. */
|
|
105
|
+
consumed = [];
|
|
95
106
|
/**
|
|
96
107
|
* The engine's own last output line of the turn in flight (#545).
|
|
97
108
|
*
|
|
@@ -295,6 +306,27 @@ export class HeadlessSession {
|
|
|
295
306
|
get lastTurn() {
|
|
296
307
|
return this.turnReport;
|
|
297
308
|
}
|
|
309
|
+
/**
|
|
310
|
+
* Ids of inputs a completed turn demonstrably consumed (#1019), newest last.
|
|
311
|
+
*
|
|
312
|
+
* The cloud used to mark an owner's message "processed" when ANY turn ended after it was sent —
|
|
313
|
+
* but a message queued behind a running turn arrives before that turn ends without being read
|
|
314
|
+
* by it. So consumption is correlated here, where the inputs are written: a one-shot turn
|
|
315
|
+
* consumed the input it was spawned for (unless we killed it); a persistent engine answers its
|
|
316
|
+
* inputs in order, so a turn end credits only the OLDEST outstanding one. If the engine folded
|
|
317
|
+
* two queued inputs into one turn, the second is credited one turn late — a delay, never a
|
|
318
|
+
* credit for something it had not read.
|
|
319
|
+
*/
|
|
320
|
+
get consumedInputs() {
|
|
321
|
+
return this.consumed;
|
|
322
|
+
}
|
|
323
|
+
credit(id) {
|
|
324
|
+
if (!id)
|
|
325
|
+
return;
|
|
326
|
+
this.consumed.push(id);
|
|
327
|
+
if (this.consumed.length > MAX_CONSUMED_INPUTS)
|
|
328
|
+
this.consumed.shift();
|
|
329
|
+
}
|
|
298
330
|
/** The engine process's pid while one is running — what per-session resource attribution reads (#924). */
|
|
299
331
|
get pid() {
|
|
300
332
|
return this.procAlive ? (this.proc?.pid ?? null) : null;
|
|
@@ -392,6 +424,8 @@ export class HeadlessSession {
|
|
|
392
424
|
}
|
|
393
425
|
if (this.procAlive)
|
|
394
426
|
return;
|
|
427
|
+
// A new process has read none of what the old one was sent.
|
|
428
|
+
this.pendingInputs = [];
|
|
395
429
|
const args = this.adapter.buildLaunchArgs(this.cmdArgs, this.claudeSessionId);
|
|
396
430
|
const proc = spawn(this.cmdBin, args, {
|
|
397
431
|
cwd: this.config.workDir,
|
|
@@ -493,8 +527,10 @@ export class HeadlessSession {
|
|
|
493
527
|
// disambiguation rides in the text instead (#505).
|
|
494
528
|
const msg = JSON.stringify({ type: "user", message: { role: "user", content: [{ type: "text", text: withSeed }] } });
|
|
495
529
|
this.proc?.stdin?.write(`${msg}\n`);
|
|
530
|
+
this.pendingInputs.push(opts.inputId ?? null);
|
|
496
531
|
}
|
|
497
532
|
else {
|
|
533
|
+
this.turnInputId = opts.inputId ?? null;
|
|
498
534
|
// Raw CLI: spawn THIS turn. See `oneShot` — there is no persistent process to
|
|
499
535
|
// write to, because a non-interactive binary would never have read it.
|
|
500
536
|
this.runOneShot(withSeed);
|
|
@@ -527,6 +563,7 @@ export class HeadlessSession {
|
|
|
527
563
|
this.sawStructuredEvent = false;
|
|
528
564
|
this.structuredOutputRejected = false;
|
|
529
565
|
const resumedCodexThreadId = this.codexResumeThreadId;
|
|
566
|
+
const inputId = this.turnInputId;
|
|
530
567
|
const turnArgs = resumedCodexThreadId
|
|
531
568
|
? buildCodexResumeArgs(this.cmdArgs, resumedCodexThreadId, text)
|
|
532
569
|
: this.adapter.buildTurnArgs(this.cmdArgs, text);
|
|
@@ -626,6 +663,9 @@ export class HeadlessSession {
|
|
|
626
663
|
// A signal means WE ended it (the wedge ceiling, an interrupt), which is the `killed`
|
|
627
664
|
// verdict: evidence about this platform's timers, not about the engine's health.
|
|
628
665
|
this.turnReport = turnReportFromExit(code, signal, this.turnLastLine, Date.now(), this.turnTail);
|
|
666
|
+
// A turn we killed did not finish reading its input (#1019).
|
|
667
|
+
if (this.turnReport.verdict !== "killed")
|
|
668
|
+
this.credit(inputId);
|
|
629
669
|
this.run = "idle";
|
|
630
670
|
this.proc = null;
|
|
631
671
|
});
|
|
@@ -800,6 +840,7 @@ export class HeadlessSession {
|
|
|
800
840
|
// own `is_error` — the same claim, in the words the engine states it in. Without
|
|
801
841
|
// this the field would exist for three engines and silently not for the flagship.
|
|
802
842
|
this.turnReport = turnReportFromResult(ev.isError, failure, Date.now(), this.turnTail);
|
|
843
|
+
this.credit(this.pendingInputs.shift() ?? null);
|
|
803
844
|
// The same event that ends the turn also reports what the turn COST (#267). It was
|
|
804
845
|
// parsed and thrown away, which is why Engine spend was absent from the ledger.
|
|
805
846
|
// An errored turn still burned tokens, so this is recorded regardless of is_error.
|
|
@@ -240,6 +240,7 @@ export class CodingRuntime {
|
|
|
240
240
|
// last turn end" is exactly the question asked about a session that just stopped, and
|
|
241
241
|
// the omitted-when-null shape keeps "not measured" distinguishable from a verdict.
|
|
242
242
|
...(session.lastTurn ? { lastTurn: session.lastTurn } : {}),
|
|
243
|
+
...(session.consumedInputs.length ? { consumedInputs: [...session.consumedInputs] } : {}),
|
|
243
244
|
...(opts.drainUsage ? { usage: session.takeUsage(), acts: session.takeActs() } : {}),
|
|
244
245
|
};
|
|
245
246
|
}
|
|
@@ -250,7 +251,11 @@ export class CodingRuntime {
|
|
|
250
251
|
case "message":
|
|
251
252
|
// Narrowed rather than trusted: this runner is a published package any caller can
|
|
252
253
|
// POST to, and an unrecognised author must read as "unstated", not become a label.
|
|
253
|
-
session.input(action.text, {
|
|
254
|
+
session.input(action.text, {
|
|
255
|
+
author: asTurnAuthor(action.author),
|
|
256
|
+
replay: typeof action.replay === "string" ? action.replay : undefined,
|
|
257
|
+
inputId: typeof action.inputId === "string" && action.inputId.length <= 128 ? action.inputId : undefined,
|
|
258
|
+
});
|
|
254
259
|
break;
|
|
255
260
|
case "keys": {
|
|
256
261
|
// A snapshot is no longer the whole answer (#448). `key()` records the attempt and
|
|
@@ -15,9 +15,14 @@ export class LocalApplyReconciliationRuntime {
|
|
|
15
15
|
deps;
|
|
16
16
|
sessions = new Map();
|
|
17
17
|
now;
|
|
18
|
+
schedule;
|
|
18
19
|
constructor(deps) {
|
|
19
20
|
this.deps = deps;
|
|
20
21
|
this.now = deps.now ?? (() => Date.now());
|
|
22
|
+
this.schedule = deps.schedule ?? ((task, delayMs) => {
|
|
23
|
+
const timer = setTimeout(task, delayMs);
|
|
24
|
+
return () => clearTimeout(timer);
|
|
25
|
+
});
|
|
21
26
|
}
|
|
22
27
|
async start(raw) {
|
|
23
28
|
const envelope = parseEnvelope(raw);
|
|
@@ -30,6 +35,9 @@ export class LocalApplyReconciliationRuntime {
|
|
|
30
35
|
const browser = await this.deps.browserFor(envelope.browserProfile, join(this.deps.dataDir, "reconciliation", envelope.reconciliationId));
|
|
31
36
|
const session = { envelope, browser, state: "running", expiresAt: this.now() + TTL_MS };
|
|
32
37
|
this.sessions.set(envelope.reconciliationId, session);
|
|
38
|
+
// The deadline belongs to the Runner, not to Console polling. It is installed before
|
|
39
|
+
// navigation so an abandoned client cannot retain an owned browser/liveWork indefinitely.
|
|
40
|
+
session.cancelDeadline = this.schedule(() => { void this.expire(session); }, TTL_MS);
|
|
33
41
|
try {
|
|
34
42
|
const page = this.page(session);
|
|
35
43
|
if (!page)
|
|
@@ -49,8 +57,10 @@ export class LocalApplyReconciliationRuntime {
|
|
|
49
57
|
return this.finish(session, { state: "ambiguous", proofKind: "ambiguous_site_history" });
|
|
50
58
|
if (!this.page(session))
|
|
51
59
|
return this.finish(session, { state: "unavailable", proofKind: "authorized_profile_unavailable" });
|
|
52
|
-
if (session.handoff?.state === "ready" && (session.handoff.expiresAt <= this.now() || !this.deps.takeover || await this.deps.takeover.state(session.handoff.id) !== "ready"))
|
|
60
|
+
if (session.handoff?.state === "ready" && (session.handoff.expiresAt <= this.now() || !this.deps.takeover || await this.deps.takeover.state(session.handoff.id) !== "ready")) {
|
|
53
61
|
await this.closeHandoff(session, session.handoff.expiresAt <= this.now() ? "expired" : "page_lost");
|
|
62
|
+
return this.finish(session, { state: "unavailable", proofKind: "authorized_profile_unavailable" });
|
|
63
|
+
}
|
|
54
64
|
return this.view(session);
|
|
55
65
|
}
|
|
56
66
|
async handoff(raw) {
|
|
@@ -80,6 +90,7 @@ export class LocalApplyReconciliationRuntime {
|
|
|
80
90
|
}
|
|
81
91
|
catch {
|
|
82
92
|
await this.closeHandoff(session, "page_lost");
|
|
93
|
+
await this.finish(session, { state: "unavailable", proofKind: "authorized_profile_unavailable" });
|
|
83
94
|
throw new RunnerInputError("The reconciliation page is no longer available", 409);
|
|
84
95
|
}
|
|
85
96
|
}
|
|
@@ -98,6 +109,7 @@ export class LocalApplyReconciliationRuntime {
|
|
|
98
109
|
}
|
|
99
110
|
catch {
|
|
100
111
|
await this.closeHandoff(session, "page_lost");
|
|
112
|
+
await this.finish(session, { state: "unavailable", proofKind: "authorized_profile_unavailable" });
|
|
101
113
|
throw new RunnerInputError("The reconciliation page is no longer available", 409);
|
|
102
114
|
}
|
|
103
115
|
}
|
|
@@ -106,12 +118,14 @@ export class LocalApplyReconciliationRuntime {
|
|
|
106
118
|
return this.inspect(session);
|
|
107
119
|
}
|
|
108
120
|
async end(raw) {
|
|
109
|
-
const
|
|
121
|
+
const request = this.handoffRequest(raw);
|
|
122
|
+
const session = this.require(request);
|
|
123
|
+
this.assertHandoffBinding(session, request);
|
|
124
|
+
if (session.handoff?.id !== request.handoffId)
|
|
125
|
+
throw new RunnerInputError("The reconciliation handoff is closed", 409);
|
|
110
126
|
if (session.handoff?.state === "ready")
|
|
111
127
|
await this.closeHandoff(session, "unavailable");
|
|
112
|
-
session
|
|
113
|
-
session.pauseReason = undefined;
|
|
114
|
-
return this.view(session);
|
|
128
|
+
return this.finish(session, { state: "ambiguous", proofKind: "ambiguous_site_history" });
|
|
115
129
|
}
|
|
116
130
|
liveWork() { return [...this.sessions.values()].filter((s) => s.state !== "ended").map((s) => `reconciliation:${s.envelope.reconciliationId}`); }
|
|
117
131
|
closeAll() { for (const session of this.sessions.values())
|
|
@@ -141,16 +155,29 @@ export class LocalApplyReconciliationRuntime {
|
|
|
141
155
|
// prose, receipt-like URLs, unauthenticated pages and absent receipts prove nothing.
|
|
142
156
|
return this.finish(session, { state: "ambiguous", proofKind: "ambiguous_site_history" });
|
|
143
157
|
}
|
|
158
|
+
async expire(session) {
|
|
159
|
+
if (session.state !== "ended" && session.expiresAt <= this.now())
|
|
160
|
+
await this.finish(session, { state: "ambiguous", proofKind: "ambiguous_site_history" });
|
|
161
|
+
}
|
|
144
162
|
async finish(session, result) {
|
|
163
|
+
if (session.finishing)
|
|
164
|
+
return session.finishing;
|
|
145
165
|
if (session.state === "ended")
|
|
146
166
|
return this.view(session);
|
|
147
|
-
|
|
148
|
-
|
|
167
|
+
// Set terminal state synchronously, before any async teardown. Concurrent End/timeout/page
|
|
168
|
+
// loss callers then converge on the same result and liveWork stops advertising ownership.
|
|
149
169
|
session.state = "ended";
|
|
150
170
|
session.pauseReason = undefined;
|
|
151
171
|
session.result = result;
|
|
152
|
-
|
|
153
|
-
|
|
172
|
+
session.cancelDeadline?.();
|
|
173
|
+
session.cancelDeadline = undefined;
|
|
174
|
+
session.finishing = (async () => {
|
|
175
|
+
if (session.handoff?.state === "ready")
|
|
176
|
+
await this.closeHandoff(session, "unavailable");
|
|
177
|
+
await session.browser.stop().catch(() => undefined);
|
|
178
|
+
return this.view(session);
|
|
179
|
+
})();
|
|
180
|
+
return session.finishing;
|
|
154
181
|
}
|
|
155
182
|
async closeHandoff(session, reason) {
|
|
156
183
|
if (!session.handoff || session.handoff.state === "closed")
|
|
@@ -159,7 +186,10 @@ export class LocalApplyReconciliationRuntime {
|
|
|
159
186
|
session.handoff.reason = reason;
|
|
160
187
|
await this.deps.takeover?.end(session.handoff.id).catch(() => undefined);
|
|
161
188
|
}
|
|
162
|
-
closedHandoff(session, id, reason) {
|
|
189
|
+
async closedHandoff(session, id, reason) {
|
|
190
|
+
session.handoff = { id, expiresAt: this.now(), state: "closed", reason };
|
|
191
|
+
return this.finish(session, { state: "unavailable", proofKind: "authorized_profile_unavailable" });
|
|
192
|
+
}
|
|
163
193
|
page(session) { const page = session.browser.handoffPage?.(); return page && !page.isClosed() ? page : null; }
|
|
164
194
|
permitted(session, raw) { try {
|
|
165
195
|
const host = new URL(raw).hostname.toLowerCase();
|
|
@@ -191,6 +221,7 @@ export class LocalApplyReconciliationRuntime {
|
|
|
191
221
|
async liveHandoff(raw) { const request = this.handoffRequest(raw); const session = this.require(request); this.assertHandoffBinding(session, request); if ((session.state !== "paused" && session.state !== "running") || session.handoff?.id !== request.handoffId || session.handoff.state !== "ready")
|
|
192
222
|
throw new RunnerInputError("The reconciliation handoff is closed", 409); if (session.handoff.expiresAt <= this.now()) {
|
|
193
223
|
await this.closeHandoff(session, "expired");
|
|
224
|
+
await this.finish(session, { state: "ambiguous", proofKind: "ambiguous_site_history" });
|
|
194
225
|
throw new RunnerInputError("The reconciliation handoff is closed", 409);
|
|
195
226
|
} return session; }
|
|
196
227
|
view(session) { const e = session.envelope; return { reconciliationId: e.reconciliationId, runId: e.runId, applicationId: e.applicationId, browserProfile: e.browserProfile, context: "separate_read_only_context", state: session.state, ...(session.pauseReason ? { pauseReason: session.pauseReason } : {}), ...(session.result ? { result: session.result } : {}) }; }
|