@jskit-ai/assistant-core 0.2.1 → 0.2.2
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/examples/conversation/package.json +1 -1
- package/package.json +1 -1
- package/src/server/conversation/claudeProcess.js +88 -17
- package/src/server/conversation/claudeTurn.js +20 -4
- package/src/server/conversation/codexProviderOwner.js +62 -5
- package/src/server/conversation/continuity.js +21 -13
- package/src/server/conversation/openCodeRuntime.js +32 -5
- package/src/server/conversation/providers/claudeDriver.js +4 -2
- package/src/server/conversation/providers/codexDriver.js +13 -12
- package/src/server/conversation/providers/opencodeDriver.js +13 -12
- package/src/server/conversation/providers/scoped.js +5 -2
- package/src/server/conversation/runtime.js +21 -4
- package/test/assistantScroll.browser.test.js +41 -18
- package/test/claudeConversationSession.test.js +26 -1
- package/test/claudeProcess.test.js +196 -9
- package/test/claudeTurn.test.js +18 -0
- package/test/codexTurn.test.js +31 -0
- package/test/conversationNativeStorage.test.js +227 -0
package/package.json
CHANGED
|
@@ -10,6 +10,8 @@ import { CLAUDE_APPLICATION_TOOL_SERVER } from "./claudeTools.js";
|
|
|
10
10
|
import { nativeCommandHook, validateCommandWrapper } from "./commandWrapper.js";
|
|
11
11
|
|
|
12
12
|
const statusReads = new WeakMap();
|
|
13
|
+
const STATUS_REUSE_MS = 30_000;
|
|
14
|
+
const STATUS_UNFINISHED_GRACE_MS = 5 * 60_000;
|
|
13
15
|
const CLAUDE_EXIT_WAIT_MS = 5_000;
|
|
14
16
|
|
|
15
17
|
/** Person-facing cause for a Claude process that ended without being stopped. */
|
|
@@ -51,12 +53,21 @@ export async function readClaudeCodeAuthStatus({
|
|
|
51
53
|
const revision = JSON.stringify(revisions);
|
|
52
54
|
const previous = reads.get(key);
|
|
53
55
|
if (previous?.revision === revision && (previous.pending || previous.expiresAt > Date.now())) return previous.value;
|
|
54
|
-
|
|
56
|
+
// Claude's last answer for these exact account files.
|
|
57
|
+
const answered = previous?.revision === revision ? previous.answered : null;
|
|
58
|
+
const entry = { revision, pending: true, expiresAt: 0, answered };
|
|
55
59
|
reads.set(key, entry);
|
|
56
|
-
entry.value = readNativeClaudeCodeAuthStatus({ env, credentialHome, command, commandRunner, signal }).then((status) => {
|
|
60
|
+
entry.value = readNativeClaudeCodeAuthStatus({ env, credentialHome, command, commandRunner, signal }).then(({ status, unfinished }) => {
|
|
61
|
+
// A read the host timed out or refused for now says nothing about the account.
|
|
62
|
+
const value = unfinished && answered && Date.now() - answered.at < STATUS_UNFINISHED_GRACE_MS
|
|
63
|
+
? answered.status : Object.freeze(status);
|
|
57
64
|
// Keychain changes cannot be observed with file metadata on macOS.
|
|
58
|
-
if (
|
|
59
|
-
|
|
65
|
+
if (process.platform !== "darwin") {
|
|
66
|
+
// Unfinished reads back off too, so a busy host is not probed again by every caller.
|
|
67
|
+
if (!status.error || unfinished) entry.expiresAt = Date.now() + STATUS_REUSE_MS;
|
|
68
|
+
if (!status.error) entry.answered = { status: value, at: Date.now() };
|
|
69
|
+
}
|
|
70
|
+
return value;
|
|
60
71
|
}).finally(() => { entry.pending = false; });
|
|
61
72
|
return entry.value;
|
|
62
73
|
}
|
|
@@ -67,6 +78,12 @@ async function readNativeClaudeCodeAuthStatus({ env, credentialHome, command, co
|
|
|
67
78
|
credentialHome, cwd: credentialHome.home, mode: "capture",
|
|
68
79
|
timeout: 30_000, maxBuffer: 64 * 1024, ...(signal ? { signal } : {})
|
|
69
80
|
});
|
|
81
|
+
const status = claudeCodeAuthStatus(result);
|
|
82
|
+
return { status, unfinished: Boolean(status.error) && result.ok !== true &&
|
|
83
|
+
(result.timedOut === true || result.retryable === true) };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function claudeCodeAuthStatus(result) {
|
|
70
87
|
const failure = result.error ? { loggedIn: false, error: result.error }
|
|
71
88
|
: { loggedIn: false, error: "Claude Code is not connected.", messageCode: "assistant.claude.notConnected", messageParams: {} };
|
|
72
89
|
if (!result.ok && (result.exitCode !== 1 || result.timedOut || result.signal)) return failure;
|
|
@@ -226,6 +243,32 @@ export function claudePlanUsage(value) {
|
|
|
226
243
|
windows, checkedAt: Date.now() };
|
|
227
244
|
}
|
|
228
245
|
|
|
246
|
+
// Plan usage is read through a conversation's live process at most this often,
|
|
247
|
+
// and otherwise through a one-shot Claude started only for the read.
|
|
248
|
+
const PLAN_USAGE_LIVE_READ_MS = 60_000;
|
|
249
|
+
const PLAN_USAGE_IDLE_READ_MS = 300_000;
|
|
250
|
+
// A verified reading stays current until the next idle read, one client poll and
|
|
251
|
+
// its read deadline could have replaced it.
|
|
252
|
+
const PLAN_USAGE_CURRENT_MS = 420_000;
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Claude Code answers `get_usage` from the usage endpoint, or from its saved copy
|
|
256
|
+
* of that answer while under a minute old, including the endpoint's `limits` list.
|
|
257
|
+
* While that read fails or is held after a 429, it answers from the rate-limit
|
|
258
|
+
* headers of the answering process's last API response, which can be hours old,
|
|
259
|
+
* and omits `limits`. The answer has no age, so only one with `limits` is current.
|
|
260
|
+
*/
|
|
261
|
+
function claudePlanUsageVerified(value) {
|
|
262
|
+
return !value.rate_limits_available || Array.isArray(value.rate_limits?.limits);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function presentPlanUsage(reading) {
|
|
266
|
+
if (!reading) return { status: "unavailable", windows: [] };
|
|
267
|
+
if (reading.status !== "available") return reading;
|
|
268
|
+
const currentForMs = PLAN_USAGE_CURRENT_MS - (Date.now() - reading.checkedAt);
|
|
269
|
+
return currentForMs > 0 ? { ...reading, currentForMs } : { ...reading, status: "stale" };
|
|
270
|
+
}
|
|
271
|
+
|
|
229
272
|
export function claudeCatalogueModels(initialization) {
|
|
230
273
|
return (initialization.models || []).map((model) => ({
|
|
231
274
|
id: model.value, label: model.displayName || model.value, description: model.description || "",
|
|
@@ -242,7 +285,7 @@ export function createClaudeAccountQueries({ createProcess, accountIdentity, pro
|
|
|
242
285
|
const accountReads = new Set();
|
|
243
286
|
let catalog;
|
|
244
287
|
const catalogStarts = new Map();
|
|
245
|
-
|
|
288
|
+
const planUsage = new Map();
|
|
246
289
|
const planUsagePending = new Map();
|
|
247
290
|
let revision = 0;
|
|
248
291
|
|
|
@@ -257,12 +300,16 @@ export function createClaudeAccountQueries({ createProcess, accountIdentity, pro
|
|
|
257
300
|
accountProcessStops.delete(native);
|
|
258
301
|
}
|
|
259
302
|
|
|
260
|
-
|
|
303
|
+
function ownedProcess(identity) {
|
|
304
|
+
return [...processes()].find((entry) =>
|
|
305
|
+
entry.process && !entry.stopping && entry.accountIdentity === identity);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
async function readAccountProcess(identity, expectedRevision, purpose, read, { borrow = true } = {}) {
|
|
261
309
|
assertCurrent(expectedRevision);
|
|
262
310
|
for (const native of accountProcessStops) await stopAccountProcess(native);
|
|
263
311
|
assertCurrent(expectedRevision);
|
|
264
|
-
const owned =
|
|
265
|
-
entry.process && !entry.stopping && entry.accountIdentity === identity);
|
|
312
|
+
const owned = borrow ? ownedProcess(identity) : null;
|
|
266
313
|
const controller = new AbortController();
|
|
267
314
|
accountReads.add(controller);
|
|
268
315
|
const cancelled = Promise.withResolvers();
|
|
@@ -306,26 +353,50 @@ export function createClaudeAccountQueries({ createProcess, accountIdentity, pro
|
|
|
306
353
|
assertCurrent(expectedRevision);
|
|
307
354
|
const identity = await accountIdentity(context);
|
|
308
355
|
assertCurrent(expectedRevision);
|
|
309
|
-
if (planUsage?.identity === identity && Date.now() - planUsage.checkedAt < 60_000) return planUsage;
|
|
310
356
|
if (planUsagePending.has(identity)) return planUsagePending.get(identity);
|
|
357
|
+
// One reading per account; a started process is stopped after its read.
|
|
358
|
+
const usage = planUsage.get(identity) || { liveReadAt: -Infinity, temporaryReadAt: -Infinity, reading: null };
|
|
359
|
+
planUsage.set(identity, usage);
|
|
360
|
+
const live = Boolean(ownedProcess(identity));
|
|
361
|
+
const liveDue = live && Date.now() - usage.liveReadAt >= PLAN_USAGE_LIVE_READ_MS;
|
|
362
|
+
const temporaryDue = () => Date.now() - usage.temporaryReadAt >= PLAN_USAGE_IDLE_READ_MS;
|
|
363
|
+
if (!liveDue && (live || !temporaryDue())) return presentPlanUsage(usage.reading);
|
|
311
364
|
const pending = (async () => {
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
365
|
+
// A live process's held endpoint read is its own, so a fresh one-shot Claude
|
|
366
|
+
// may still reach the endpoint when the live answer cannot prove it is current.
|
|
367
|
+
for (const borrow of liveDue ? [true, false] : [false]) {
|
|
368
|
+
if (!borrow && !temporaryDue()) break;
|
|
369
|
+
usage[borrow ? "liveReadAt" : "temporaryReadAt"] = Date.now();
|
|
370
|
+
let value;
|
|
371
|
+
try {
|
|
372
|
+
value = await readAccountProcess(identity, expectedRevision, "usage", (native) => native.client.request(
|
|
373
|
+
{ subtype: "get_usage", skip_behaviors: true }), { borrow });
|
|
374
|
+
} catch (cause) {
|
|
375
|
+
if (borrow || !liveDue) throw cause;
|
|
376
|
+
assertCurrent(expectedRevision);
|
|
377
|
+
break;
|
|
378
|
+
}
|
|
379
|
+
if (identity !== await accountIdentity(context)) return { status: "unavailable", windows: [] };
|
|
380
|
+
assertCurrent(expectedRevision);
|
|
381
|
+
// An answer that cannot prove it is current keeps the last verified reading.
|
|
382
|
+
if (claudePlanUsageVerified(value)) {
|
|
383
|
+
usage.reading = { ...claudePlanUsage(value), identity };
|
|
384
|
+
break;
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
return presentPlanUsage(usage.reading);
|
|
318
388
|
})().finally(() => { planUsagePending.delete(identity); });
|
|
319
389
|
planUsagePending.set(identity, pending);
|
|
320
390
|
return pending;
|
|
321
391
|
},
|
|
322
|
-
|
|
392
|
+
// A native usage change lets the next request read again without hiding the last reading.
|
|
393
|
+
invalidateUsage() { for (const usage of planUsage.values()) usage.liveReadAt = -Infinity; },
|
|
323
394
|
async invalidate() {
|
|
324
395
|
revision++;
|
|
325
396
|
for (const controller of accountReads) controller.abort(error("Claude account query was cancelled for reconnection."));
|
|
326
397
|
await Promise.allSettled([...catalogStarts.values(), ...planUsagePending.values()]);
|
|
327
398
|
catalog = null;
|
|
328
|
-
planUsage
|
|
399
|
+
planUsage.clear();
|
|
329
400
|
for (const native of accountProcessStops) await stopAccountProcess(native);
|
|
330
401
|
}
|
|
331
402
|
});
|
|
@@ -19,6 +19,14 @@ function hasPendingClaudeStartup(entry) {
|
|
|
19
19
|
return entry.startupCleanup?.pending === true && entry.startupCleanup.executionId === entry.executionId;
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
+
// Claude records an admitted prompt in its history, and only a send in
|
|
23
|
+
// progress writes prompts to a live process. With neither left, a prompt
|
|
24
|
+
// missing from history can never arrive. A turn left marked active by a
|
|
25
|
+
// lost process does not change that.
|
|
26
|
+
function nativeWorkSettled(entry) {
|
|
27
|
+
return !entry.sending && !entry.process && !entry.executionId && !entry.command && !entry.stopPending;
|
|
28
|
+
}
|
|
29
|
+
|
|
22
30
|
export function claudeNativeMessageId(id) {
|
|
23
31
|
const value = createHash("sha256").update(String(id)).digest("hex");
|
|
24
32
|
return `${value.slice(0, 8)}-${value.slice(8, 12)}-4${value.slice(13, 16)}-8${value.slice(17, 20)}-${value.slice(20, 32)}`;
|
|
@@ -563,7 +571,13 @@ export function createClaudeConversationOwner({
|
|
|
563
571
|
timeoutMessage: "Claude timed out summarising its context." });
|
|
564
572
|
}
|
|
565
573
|
|
|
566
|
-
async function send(entry,
|
|
574
|
+
async function send(entry, ...args) {
|
|
575
|
+
entry.sending = (entry.sending || 0) + 1;
|
|
576
|
+
try { return await dispatchPrompt(entry, ...args); }
|
|
577
|
+
finally { entry.sending -= 1; }
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
async function dispatchPrompt(entry, input = {}, { renewal = false, command, applicationCommand } = {}) {
|
|
567
581
|
const message = command ? input.text : text(input.message || input.prompt);
|
|
568
582
|
if (!command && !message) throw createError("Enter a message for Claude.");
|
|
569
583
|
const context = entry.context;
|
|
@@ -732,10 +746,11 @@ export function createClaudeConversationOwner({
|
|
|
732
746
|
}
|
|
733
747
|
const requestIds = new Map((input.requestMessageIds || [input.messageId]).filter(id => typeof id === "string" && id)
|
|
734
748
|
.map(id => [claudeNativeMessageId(id), id]));
|
|
749
|
+
const admitted = history.userIds.includes(claudeNativeMessageId(input.messageId || ""));
|
|
735
750
|
return { ...entry.nativeTurn.readResult(), ok: true,
|
|
736
751
|
messages: [...messages.values()].map(message => ({ ...message,
|
|
737
752
|
...(message.userId ? { requestMessageId: requestIds.get(message.userId) || "" } : {}) })), text: current.text || history.text,
|
|
738
|
-
admitted:
|
|
753
|
+
admitted, notAdmitted: !admitted && Boolean(input.messageId) && nativeWorkSettled(entry) };
|
|
739
754
|
}
|
|
740
755
|
|
|
741
756
|
async function inspectAdmission(entry, input) {
|
|
@@ -743,7 +758,8 @@ export function createClaudeConversationOwner({
|
|
|
743
758
|
const history = await readHistory(entry);
|
|
744
759
|
const turnId = claudeNativeMessageId(input.messageId);
|
|
745
760
|
const accepted = history.userIds.includes(turnId);
|
|
746
|
-
|
|
761
|
+
const admission = accepted ? "accepted" : nativeWorkSettled(entry) ? "not-admitted" : "unknown";
|
|
762
|
+
return { ok: true, admission, messageId: input.messageId,
|
|
747
763
|
threadId: entry.id, turnId: accepted ? turnId : "" };
|
|
748
764
|
}
|
|
749
765
|
|
|
@@ -1155,7 +1171,7 @@ export function createClaudeConversationOwner({
|
|
|
1155
1171
|
if (!bound) return { value: delivered.value };
|
|
1156
1172
|
}
|
|
1157
1173
|
|
|
1158
|
-
return Object.freeze({ applicationToolsSupported: true, entries, closingSessions, get closing() { return closing; }, createAccountQueries, open, acquire, createConversation, snapshot, readRetainedTurn, readFinalAssistantResult, readHistory, ensureReady, ensureProcess, readContextUsage, compactContext, releaseRetainedGoal, stopForTerminal, prepareTerminal, send, startTurn,
|
|
1174
|
+
return Object.freeze({ applicationToolsSupported: true, entries, closingSessions, get closing() { return closing; }, createAccountQueries, open, acquire, createConversation, snapshot, readRetainedTurn, readFinalAssistantResult, readHistory, nativeWorkSettled, ensureReady, ensureProcess, readContextUsage, compactContext, releaseRetainedGoal, stopForTerminal, prepareTerminal, send, startTurn,
|
|
1159
1175
|
interrupt, read, inspectAdmission, hasActiveTemporaryConversation, hasActiveConversation, retireConversationHistory, reconcileSessions, restoreSessionEntries, closeSession, closeProject, invalidateRuntimes, drain, forget, close, deleteConversation, recover, stopSelected, wait, stopConversation,
|
|
1160
1176
|
readGoal, updateGoal, renewal, run, steer, cancel });
|
|
1161
1177
|
}
|
|
@@ -63,7 +63,8 @@ export function createCodexAppServerProviderOwner({
|
|
|
63
63
|
runtimeBusyCode = "codex_app_server_runtime_busy",
|
|
64
64
|
exitUnverifiedCode = "codex_runtime_exit_unverified",
|
|
65
65
|
requiredStopCode = "codex_runtime_exit_unverified",
|
|
66
|
-
runtimeCloseError = "Codex app-server runtime close failed."
|
|
66
|
+
runtimeCloseError = "Codex app-server runtime close failed.",
|
|
67
|
+
transientIdleMs = 60_000
|
|
67
68
|
} = {}) {
|
|
68
69
|
const scopeRoot = normalizeText(runtimeRoot) ? path.resolve(runtimeRoot) : "";
|
|
69
70
|
const records = new Map();
|
|
@@ -73,6 +74,8 @@ export function createCodexAppServerProviderOwner({
|
|
|
73
74
|
let closing = false;
|
|
74
75
|
let runtimeLifecycle = null;
|
|
75
76
|
let codexAppServerChatModelCatalog = null;
|
|
77
|
+
// Providers borrowed by work that is not a conversation, terminal or helper.
|
|
78
|
+
const transientReleases = new Map();
|
|
76
79
|
|
|
77
80
|
const providers = {
|
|
78
81
|
get: key => records.get(key)?.provider,
|
|
@@ -274,7 +277,53 @@ export function createCodexAppServerProviderOwner({
|
|
|
274
277
|
return provider;
|
|
275
278
|
}
|
|
276
279
|
|
|
280
|
+
// A conversation, terminal or helper keeps a provider a storage read created.
|
|
281
|
+
// Every exported way to reach a provider for real use claims it here.
|
|
282
|
+
function claimTransient(providerKey = "") {
|
|
283
|
+
const key = normalizeText(providerKey);
|
|
284
|
+
const release = transientReleases.get(key);
|
|
285
|
+
if (!release) return;
|
|
286
|
+
clearTimeout(release.timer);
|
|
287
|
+
transientReleases.delete(key);
|
|
288
|
+
}
|
|
289
|
+
|
|
277
290
|
async function ensureSession(context, mainThreadId = "") {
|
|
291
|
+
claimTransient(context.providerKey);
|
|
292
|
+
return acquireSession(context, mainThreadId);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// Native storage inventory and retirement borrow a session provider. A short
|
|
296
|
+
// idle after the last such use lets consecutive reads share one process; the
|
|
297
|
+
// release then stops it unless another provider still uses it.
|
|
298
|
+
async function withTransientSession(context, operation) {
|
|
299
|
+
const providerKey = context.providerKey;
|
|
300
|
+
let release = transientReleases.get(providerKey);
|
|
301
|
+
// An existing provider or a managed main thread already has a real user.
|
|
302
|
+
if (!release && (records.has(providerKey) || context.runOwner?.runtimeLifecycle?.managedThreadId?.(providerKey))) {
|
|
303
|
+
return operation(await acquireSession(context));
|
|
304
|
+
}
|
|
305
|
+
if (!release) transientReleases.set(providerKey, release = { users: 0, timer: null });
|
|
306
|
+
clearTimeout(release.timer);
|
|
307
|
+
release.users += 1;
|
|
308
|
+
try {
|
|
309
|
+
return await operation(await acquireSession(context));
|
|
310
|
+
} finally {
|
|
311
|
+
release.users -= 1;
|
|
312
|
+
if (release.users === 0 && transientReleases.get(providerKey) === release && !closing) {
|
|
313
|
+
release.timer = setTimeout(() => {
|
|
314
|
+
if (transientReleases.get(providerKey) !== release) return;
|
|
315
|
+
transientReleases.delete(providerKey);
|
|
316
|
+
if (closing) return;
|
|
317
|
+
stopCachedProvider(providerKey).catch(error => debugLog("appServerRuntime.transientRelease.error", {
|
|
318
|
+
error: debugError(error), providerKey
|
|
319
|
+
}));
|
|
320
|
+
}, transientIdleMs);
|
|
321
|
+
release.timer.unref?.();
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
async function acquireSession(context, mainThreadId = "") {
|
|
278
327
|
const sessionId = context.sessionId;
|
|
279
328
|
const providerOptions = context.providerOptions;
|
|
280
329
|
const providerKey = context.providerKey;
|
|
@@ -1156,9 +1205,10 @@ export function createCodexAppServerProviderOwner({
|
|
|
1156
1205
|
return { exitFailure };
|
|
1157
1206
|
}
|
|
1158
1207
|
|
|
1208
|
+
// A provider a storage read borrowed is not the session's conversation.
|
|
1159
1209
|
async function readPlanUsage(sessionKey) {
|
|
1160
1210
|
for (const [key, provider] of providers) {
|
|
1161
|
-
if (providerOwners.get(key)?.sessionKey === sessionKey &&
|
|
1211
|
+
if (providerOwners.get(key)?.sessionKey === sessionKey && !transientReleases.has(key) &&
|
|
1162
1212
|
!provider.isHelperProvider() && provider.isAvailable()) {
|
|
1163
1213
|
return provider.readPlanUsage();
|
|
1164
1214
|
}
|
|
@@ -1169,7 +1219,7 @@ export function createCodexAppServerProviderOwner({
|
|
|
1169
1219
|
async function readSessionGoal(sessionKey, threadId) {
|
|
1170
1220
|
if (!threadId) return { status: "available", threadId: "", goal: null };
|
|
1171
1221
|
for (const [key, provider] of providers) {
|
|
1172
|
-
if (providerOwners.get(key)?.sessionKey === sessionKey &&
|
|
1222
|
+
if (providerOwners.get(key)?.sessionKey === sessionKey && !transientReleases.has(key) &&
|
|
1173
1223
|
!provider.isHelperProvider() && provider.isAvailable()) {
|
|
1174
1224
|
const result = await provider.readGoal(threadId);
|
|
1175
1225
|
return { status: "available", threadId, goal: result.goal || null };
|
|
@@ -1181,10 +1231,17 @@ export function createCodexAppServerProviderOwner({
|
|
|
1181
1231
|
const api = Object.freeze({
|
|
1182
1232
|
providers, providerOwners, ownedRuntimes, lifecycleTasks, runtimeAcquisitions,
|
|
1183
1233
|
withLifecycle, runtimeIsShared, rememberOwnedRuntime, forgetOwnedRuntime, attachRuntimeLifecycle,
|
|
1184
|
-
acquireRuntime
|
|
1234
|
+
acquireRuntime: options => { claimTransient(options?.providerKey); return acquireRuntime(options); },
|
|
1235
|
+
createProvider: options => { claimTransient(options?.providerKey); return createProvider(options); },
|
|
1236
|
+
providerForSession: context => { claimTransient(context.providerKey); return providerForSession(context); },
|
|
1237
|
+
createNativeProvider, ensureSession, withTransientSession, retireAndCloseProvider, closeProvider, stopOwnedRuntime, stopCachedProvider, stopProvider, stopThreadBeforeRelease, readModelCatalog, invalidateRuntimes, recover,
|
|
1185
1238
|
reportRecoveryEvent, pendingRecovery, readSessionGoal, readPlanUsage, stopCachedProvidersForSession, stopProvidersForProject, drainThreadEnvironmentTasks, stopPersistedRuntime, sessionExitProof,
|
|
1186
1239
|
unsubscribeSessionThread, releasePersistedRuntimeProof, releasePreparedRuntimeProof, releaseSession,
|
|
1187
|
-
beginShutdown() {
|
|
1240
|
+
beginShutdown() {
|
|
1241
|
+
closing = true;
|
|
1242
|
+
for (const release of transientReleases.values()) clearTimeout(release.timer);
|
|
1243
|
+
transientReleases.clear();
|
|
1244
|
+
}
|
|
1188
1245
|
});
|
|
1189
1246
|
return api;
|
|
1190
1247
|
}
|
|
@@ -122,7 +122,7 @@ export function createConversationChangeover({ state, transcript, agent, identit
|
|
|
122
122
|
code: unconfirmedCode, messageId, threadId, error: unconfirmedMessage };
|
|
123
123
|
}
|
|
124
124
|
|
|
125
|
-
async function send({ engineId, messages, input, turnMetadata, completedEnvelope = false, excludedMessageIds = [] }) {
|
|
125
|
+
async function send({ engineId, messages, input, turnMetadata, completedEnvelope = false, excludedMessageIds = [], inspectOnly = false }) {
|
|
126
126
|
if (typeof completedEnvelope !== "boolean" || !Array.isArray(excludedMessageIds) ||
|
|
127
127
|
excludedMessageIds.some(id => typeof id !== "string" || !id.trim() || id.length > 128) ||
|
|
128
128
|
new Set(excludedMessageIds).size !== excludedMessageIds.length || excludedMessageIds.length && !completedEnvelope) {
|
|
@@ -163,20 +163,28 @@ export function createConversationChangeover({ state, transcript, agent, identit
|
|
|
163
163
|
const receipt = savedReceipt ? { admission: "accepted" } : await agent.inspectMessageAdmission({
|
|
164
164
|
messageId: pending.messageId, threadId: pending.threadId
|
|
165
165
|
}).catch(() => null);
|
|
166
|
-
if (receipt?.admission
|
|
166
|
+
if (receipt?.admission === "not-admitted") {
|
|
167
|
+
// The engine proved this attempt never arrived. It was not sent, so
|
|
168
|
+
// a send may dispatch it (or a newer message) without duplicating it.
|
|
169
|
+
pending.attempted = false;
|
|
170
|
+
await persist();
|
|
171
|
+
log({ event: "not-sent", engineId, messageId: pending.messageId, threadId: pending.threadId });
|
|
172
|
+
if (inspectOnly) return { ok: false, delivered: false, notSent: true, messageId: pending.messageId, threadId: pending.threadId };
|
|
173
|
+
} else if (receipt?.admission !== "accepted") {
|
|
167
174
|
log({ event: "unconfirmed", engineId, messageId: pending.messageId, threadId: pending.threadId });
|
|
168
175
|
return unconfirmed(pending.messageId, pending.threadId);
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
176
|
+
} else {
|
|
177
|
+
await transcript.writeUserMessage({
|
|
178
|
+
text: pending.displayMessage, messageId: pending.messageId,
|
|
179
|
+
attachments: pending.displayAttachments,
|
|
180
|
+
...(pending.data !== undefined ? { data: pending.data } : {}),
|
|
181
|
+
turnMetadata: { ...pending.turnMetadata, ...turnMetadata }
|
|
182
|
+
});
|
|
183
|
+
await markDelivered(pending.seen);
|
|
184
|
+
log({ event: "accepted", engineId, messageId: pending.messageId, threadId: pending.threadId, recovered: true });
|
|
185
|
+
if (pending.messageId === input.messageId) {
|
|
186
|
+
return { ok: true, delivered: true, messageId: input.messageId, threadId: pending.threadId };
|
|
187
|
+
}
|
|
180
188
|
}
|
|
181
189
|
}
|
|
182
190
|
|
|
@@ -169,7 +169,7 @@ export async function ensureOpenCodeSession(target, { selection, model, workdir
|
|
|
169
169
|
|
|
170
170
|
// The production provider's users and pending acquisitions share this owner.
|
|
171
171
|
// Hosts supply authorized startup inputs and their own publication/cleanup work.
|
|
172
|
-
export function createOpenCodeSharedRuntime({ onStop = () => {}, scope } = {}) {
|
|
172
|
+
export function createOpenCodeSharedRuntime({ onStop = () => {}, scope, storageIdleMs = 60_000 } = {}) {
|
|
173
173
|
if (scope !== undefined && !path.isAbsolute(scope)) throw new TypeError("OpenCode runtime scopes require an absolute directory.");
|
|
174
174
|
if (scope && sharedRuntimes.has(scope)) return sharedRuntimes.get(scope);
|
|
175
175
|
const processes = new Map();
|
|
@@ -184,6 +184,8 @@ export function createOpenCodeSharedRuntime({ onStop = () => {}, scope } = {}) {
|
|
|
184
184
|
let failedStartup = null;
|
|
185
185
|
let closed = false;
|
|
186
186
|
let connectionChanging = false;
|
|
187
|
+
let storageReads = 0;
|
|
188
|
+
let storageIdle = null;
|
|
187
189
|
|
|
188
190
|
function requireConnectionAvailable() {
|
|
189
191
|
if (connectionChanging) throw Object.assign(new Error("OpenCode is changing a connection. Retry after the connection change finishes."), {
|
|
@@ -291,6 +293,29 @@ export function createOpenCodeSharedRuntime({ onStop = () => {}, scope } = {}) {
|
|
|
291
293
|
}
|
|
292
294
|
}
|
|
293
295
|
|
|
296
|
+
// Native storage inventory and retirement borrow the shared server; they are
|
|
297
|
+
// not users that keep it running. A short idle after the last read lets one
|
|
298
|
+
// maintenance pass share it, then it stops unless a conversation uses it.
|
|
299
|
+
async function withStorageProcess(start, operation, failure = error => { throw error; }) {
|
|
300
|
+
clearTimeout(storageIdle);
|
|
301
|
+
storageIdle = null;
|
|
302
|
+
storageReads += 1;
|
|
303
|
+
try {
|
|
304
|
+
return await operation(await ensure(null, start).catch(failure));
|
|
305
|
+
} finally {
|
|
306
|
+
storageReads -= 1;
|
|
307
|
+
if (storageReads === 0 && !closed) {
|
|
308
|
+
storageIdle = setTimeout(() => {
|
|
309
|
+
storageIdle = null;
|
|
310
|
+
if (storageReads === 0 && processes.size === 0 && processStarts.size === 0) {
|
|
311
|
+
stop("opencode-storage-read-finished").catch(() => null);
|
|
312
|
+
}
|
|
313
|
+
}, storageIdleMs);
|
|
314
|
+
storageIdle.unref?.();
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
294
319
|
function targetFor(key, prepared) {
|
|
295
320
|
const current = processes.get(key);
|
|
296
321
|
const nativeId = prepared.upstreamSessionId;
|
|
@@ -346,7 +371,7 @@ export function createOpenCodeSharedRuntime({ onStop = () => {}, scope } = {}) {
|
|
|
346
371
|
if (processStarts.get(key) === start) processStarts.delete(key);
|
|
347
372
|
if (!processes.has(key)) {
|
|
348
373
|
await onFailure();
|
|
349
|
-
if (processes.size === 0 && processStarts.size === 0 && !failedStartup) {
|
|
374
|
+
if (processes.size === 0 && processStarts.size === 0 && storageReads === 0 && !failedStartup) {
|
|
350
375
|
await stop("opencode-session-start-failed").catch(() => null);
|
|
351
376
|
}
|
|
352
377
|
}
|
|
@@ -365,7 +390,7 @@ export function createOpenCodeSharedRuntime({ onStop = () => {}, scope } = {}) {
|
|
|
365
390
|
processes.delete(target.key);
|
|
366
391
|
await onRemoved?.();
|
|
367
392
|
}
|
|
368
|
-
return !retainSharedProcess && processes.size === 0 && processStarts.size === 0
|
|
393
|
+
return !retainSharedProcess && processes.size === 0 && processStarts.size === 0 && storageReads === 0
|
|
369
394
|
? stop()
|
|
370
395
|
: { exited: true, sharedProcessRetained: true };
|
|
371
396
|
}
|
|
@@ -444,7 +469,7 @@ export function createOpenCodeSharedRuntime({ onStop = () => {}, scope } = {}) {
|
|
|
444
469
|
application.ownsTarget(target) && (!projectContextRoot || target.projectContextRoot === path.resolve(projectContextRoot))
|
|
445
470
|
));
|
|
446
471
|
const results = await Promise.all(targets.map((target) => releaseProcessTarget(target, {}, application)));
|
|
447
|
-
if (processes.size === 0 && (sharedProcess || failedStartup)) {
|
|
472
|
+
if (processes.size === 0 && storageReads === 0 && (sharedProcess || failedStartup)) {
|
|
448
473
|
results.push(await stop(text(input.reason) || "opencode-project-close").catch(application.failure));
|
|
449
474
|
}
|
|
450
475
|
return {
|
|
@@ -456,6 +481,8 @@ export function createOpenCodeSharedRuntime({ onStop = () => {}, scope } = {}) {
|
|
|
456
481
|
async function invalidateRuntimes(input = {}, preparation) {
|
|
457
482
|
if (text(input.reason) === "server-shutdown") {
|
|
458
483
|
closed = true;
|
|
484
|
+
clearTimeout(storageIdle);
|
|
485
|
+
storageIdle = null;
|
|
459
486
|
}
|
|
460
487
|
const application = preparation.release;
|
|
461
488
|
if (typeof input.publish === "function") {
|
|
@@ -1767,7 +1794,7 @@ export function createOpenCodeSharedRuntime({ onStop = () => {}, scope } = {}) {
|
|
|
1767
1794
|
get current() { return sharedProcess; },
|
|
1768
1795
|
get closed() { return closed; },
|
|
1769
1796
|
get cleanupPending() { return Boolean(failedStartup); },
|
|
1770
|
-
acquire, targetFor, ensure, release, releaseTarget, releaseProcessTarget, closeSession, closeProject,
|
|
1797
|
+
acquire, targetFor, ensure, withStorageProcess, release, releaseTarget, releaseProcessTarget, closeSession, closeProject,
|
|
1771
1798
|
startPreparedProcess, acquirePrepared, createPreparedConversation, runPreparedRenewalTurn, selectPreparedConversation, existingPreparedConversation,
|
|
1772
1799
|
runPreparedConversationTurn, readPreparedConversation, waitPreparedConversationTurn, stopPreparedConversation, deletePreparedConversation,
|
|
1773
1800
|
sendPreparedMessage, inspectPreparedMessage, interruptPreparedTurn,
|
|
@@ -432,7 +432,8 @@ export function createClaudeConversationDriver({ connections, host = {}, limits
|
|
|
432
432
|
if (supplied) {
|
|
433
433
|
await acquire(context);
|
|
434
434
|
const result = await owner.inspectAdmission(await acquire(context), input);
|
|
435
|
-
return representation === "native" ? result
|
|
435
|
+
return representation === "native" ? result
|
|
436
|
+
: { accepted: result.admission === "accepted", notAdmitted: result.admission === "not-admitted" };
|
|
436
437
|
}
|
|
437
438
|
if (verifyAccount === true) {
|
|
438
439
|
const binding = input.receiptBinding;
|
|
@@ -446,7 +447,8 @@ export function createClaudeConversationDriver({ connections, host = {}, limits
|
|
|
446
447
|
}
|
|
447
448
|
const history = await owner.readHistory(entry);
|
|
448
449
|
const nativeId = claudeNativeMessageId(input.messageId);
|
|
449
|
-
|
|
450
|
+
const accepted = history.userIds.includes(nativeId);
|
|
451
|
+
return { accepted, notAdmitted: !accepted && owner.nativeWorkSettled(entry),
|
|
450
452
|
messages: history.messages.filter(message => message.userId === nativeId) };
|
|
451
453
|
},
|
|
452
454
|
steer: command => owner.steer(control, command, commandOptions),
|
|
@@ -126,10 +126,11 @@ export function createCodexConversationDriver({ connections, host = {}, limits =
|
|
|
126
126
|
runtime: createCodexAppServerRuntime({ credentials, execution: processExecution, configuration }) };
|
|
127
127
|
}
|
|
128
128
|
|
|
129
|
-
|
|
129
|
+
// Storage work borrows the shared process; it is not a user that keeps it running.
|
|
130
|
+
async function withNativeStorageProvider({ sessionId, native, binding, context }, operation) {
|
|
130
131
|
const prepared = await native.preparation.storage(sessionId, binding, context);
|
|
131
|
-
return
|
|
132
|
-
|
|
132
|
+
return native.providerOwner.withTransientSession(prepared.providerContext,
|
|
133
|
+
provider => operation(provider, prepared.toolHomeSource));
|
|
133
134
|
}
|
|
134
135
|
|
|
135
136
|
async function disposeNative({ sessionId, namespace, native, options }) {
|
|
@@ -160,17 +161,17 @@ export function createCodexConversationDriver({ connections, host = {}, limits =
|
|
|
160
161
|
deleteDetachedConversation({ sessionId, native, input, context }) {
|
|
161
162
|
return native.runOwner.deleteDetachedThread(sessionId, input, context);
|
|
162
163
|
},
|
|
163
|
-
|
|
164
|
-
const { provider } = await nativeStorageProvider(prepared);
|
|
164
|
+
listConversationStorage(prepared) {
|
|
165
165
|
const { binding } = prepared;
|
|
166
|
-
return (
|
|
166
|
+
return withNativeStorageProvider(prepared, async provider =>
|
|
167
|
+
(await provider.listNativeThreadsForCwd(binding.workdir)).map((conversationId) => ({ ...binding, conversationId })));
|
|
167
168
|
},
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
169
|
+
retireConversationHistory(prepared) {
|
|
170
|
+
return withNativeStorageProvider(prepared, (provider, toolHomeSource) =>
|
|
171
|
+
retireCodexConversationHistory(provider, prepared.binding, {
|
|
172
|
+
toolHomeSource, beforeDelete: prepared.context.beforeDelete, signal: prepared.context.signal,
|
|
173
|
+
errorPrefix: prepared.native.errorPrefix
|
|
174
|
+
}));
|
|
174
175
|
},
|
|
175
176
|
invalidateRuntimes({ native, input }) {
|
|
176
177
|
return native.runOwner.invalidateRuntimes(input, native.preparation);
|
|
@@ -76,10 +76,10 @@ export function createOpenCodeConversationDriver({ connections, host = {}, limit
|
|
|
76
76
|
return { text: prepared, content: input.content };
|
|
77
77
|
}
|
|
78
78
|
|
|
79
|
-
|
|
79
|
+
function withNativeStorageTarget({ native, context }, operation) {
|
|
80
80
|
const prepared = native.preparation.storage(context);
|
|
81
|
-
return native.owner.
|
|
82
|
-
native.owner.startPreparedProcess(await prepared.process())
|
|
81
|
+
return native.owner.withStorageProcess(async () =>
|
|
82
|
+
native.owner.startPreparedProcess(await prepared.process()), operation, prepared.failure);
|
|
83
83
|
}
|
|
84
84
|
|
|
85
85
|
async function disposeNative({ native, options }) {
|
|
@@ -127,17 +127,18 @@ export function createOpenCodeConversationDriver({ connections, host = {}, limit
|
|
|
127
127
|
const prepared = await native.preparation.existing(input, context, { operation: "delete" });
|
|
128
128
|
return native.owner.deletePreparedConversation(prepared, input);
|
|
129
129
|
},
|
|
130
|
-
|
|
131
|
-
const target = await nativeStorageTarget(prepared);
|
|
130
|
+
listConversationStorage(prepared) {
|
|
132
131
|
const { binding, context } = prepared;
|
|
133
|
-
return (
|
|
134
|
-
|
|
132
|
+
return withNativeStorageTarget(prepared, async target =>
|
|
133
|
+
(await target.server.client.listConversationsForDirectory(binding.workdir, { signal: context.signal }))
|
|
134
|
+
.map((row) => ({ ...binding, conversationId: row.id })));
|
|
135
135
|
},
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
136
|
+
retireConversationHistory(prepared) {
|
|
137
|
+
return withNativeStorageTarget(prepared, target => {
|
|
138
|
+
// Native status is scoped to the saved project instance, not the control server.
|
|
139
|
+
const client = openCodeServerForDirectory(target.server, prepared.binding.workdir).client;
|
|
140
|
+
return retireOpenCodeConversationHistory(target.server.client, client, prepared.binding, prepared.context);
|
|
141
|
+
});
|
|
141
142
|
},
|
|
142
143
|
closeProject({ native, input }) {
|
|
143
144
|
return native.owner.closeProject(input, native.preparation.projectCleanup());
|
|
@@ -320,7 +320,7 @@ export async function inspectPersistentConversationAdmission(options) {
|
|
|
320
320
|
if (!record.providerConversationId) return { admission: "unknown", turnId: "" };
|
|
321
321
|
const state = requireSuccess(await operations.readConversation(sessionId,
|
|
322
322
|
inputFor(record, { messageId: input.messageId }), options.context));
|
|
323
|
-
return { admission: state.admitted ? "accepted" : "unknown", turnId: state.runId };
|
|
323
|
+
return { admission: state.admitted ? "accepted" : state.notAdmitted ? "not-admitted" : "unknown", turnId: state.runId };
|
|
324
324
|
}
|
|
325
325
|
|
|
326
326
|
export async function preparePersistentConversationChangeover(options) {
|
|
@@ -353,7 +353,10 @@ export async function inspectPersistentConversationDelivery(options) {
|
|
|
353
353
|
const result = await createConversationChangeover({ ...continuity, agent: {
|
|
354
354
|
inspectMessageAdmission: request => inspectMessageAdmission(sessionId, request, context),
|
|
355
355
|
sendMessage() { throw new Error("Receipt inspection cannot submit another native prompt."); }
|
|
356
|
-
} }).send({ engineId, messages, input: { messageId: input.messageId }, turnMetadata });
|
|
356
|
+
} }).send({ engineId, messages, input: { messageId: input.messageId }, turnMetadata, inspectOnly: true });
|
|
357
|
+
if (result?.notSent) return { status: "not-sent", messageId: input.messageId,
|
|
358
|
+
error: "This message was not sent. Review it before sending a new message.",
|
|
359
|
+
messageCode: "assistant.conversation.messageNotSent", messageParams: {} };
|
|
357
360
|
accepted = result?.delivered === true;
|
|
358
361
|
} else if (!accepted) {
|
|
359
362
|
if (row && row.user?.receipt !== false) return { status: "accepted", messageId: input.messageId, turnId: row.turnId, duplicate: true };
|