@jskit-ai/assistant-core 0.2.0 → 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.
@@ -8,7 +8,7 @@
8
8
  "build": "vite build"
9
9
  },
10
10
  "dependencies": {
11
- "@jskit-ai/assistant-core": "0.2.0",
11
+ "@jskit-ai/assistant-core": "0.2.2",
12
12
  "@jskit-ai/http-runtime": "0.2.0",
13
13
  "@jskit-ai/kernel": "0.2.0",
14
14
  "@mdi/js": "^7.4.47",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jskit-ai/assistant-core",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
4
4
  "type": "module",
5
5
  "scripts": {
6
6
  "test": "node --test --test-concurrency=1"
@@ -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
- const entry = { revision, pending: true, expiresAt: 0 };
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 (!status.error && process.platform !== "darwin") entry.expiresAt = Date.now() + 30_000;
59
- return Object.freeze(status);
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
- let planUsage;
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
- async function readAccountProcess(identity, expectedRevision, purpose, read) {
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 = [...processes()].find((entry) =>
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
- const value = await readAccountProcess(identity, expectedRevision, "usage", (native) => native.client.request(
313
- { subtype: "get_usage", skip_behaviors: true }));
314
- if (identity !== await accountIdentity(context)) return { status: "unavailable", windows: [] };
315
- assertCurrent(expectedRevision);
316
- planUsage = { ...claudePlanUsage(value), identity };
317
- return planUsage;
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
- invalidateUsage() { planUsage = null; },
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 = null;
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, input = {}, { renewal = false, command, applicationCommand } = {}) {
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: history.userIds.includes(claudeNativeMessageId(input.messageId || "")) };
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
- return { ok: true, admission: accepted ? "accepted" : "unknown", messageId: input.messageId,
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
 
@@ -829,12 +845,28 @@ export function createClaudeConversationOwner({
829
845
  closing = true;
830
846
  try {
831
847
  await account.queries.invalidate().catch(account.failure);
832
- return await closeProject(undefined, application);
848
+ return await stopRetained(application);
833
849
  } finally {
834
850
  closing = input.reason === "server-shutdown";
835
851
  }
836
852
  }
837
853
 
854
+ // Account changes and shutdown stop only what this server runs: a Claude
855
+ // process cannot outlive the server that started it, so retained entries
856
+ // hold every one. Saved conversations are not reopened just to be stopped.
857
+ async function stopRetained(application) {
858
+ const sessions = new Map([...entries.values()].map(entry => [entry.context.key, entry.context.sessionId]));
859
+ let closed = 0;
860
+ let ok = true;
861
+ for (const [key, sessionId] of sessions) {
862
+ closed += (await drain(key)).length;
863
+ const terminal = await application.terminals.close(sessionId);
864
+ closed += Number(terminal?.closed || 0);
865
+ ok &&= terminal?.ok !== false;
866
+ }
867
+ return { ok, closed };
868
+ }
869
+
838
870
  async function drain(key) {
839
871
  const selected = [...entries.values()].filter(entry => entry.context.key === key);
840
872
  // Startup already lends cleanup before initialization completes. Stop it
@@ -1139,7 +1171,7 @@ export function createClaudeConversationOwner({
1139
1171
  if (!bound) return { value: delivered.value };
1140
1172
  }
1141
1173
 
1142
- 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,
1143
1175
  interrupt, read, inspectAdmission, hasActiveTemporaryConversation, hasActiveConversation, retireConversationHistory, reconcileSessions, restoreSessionEntries, closeSession, closeProject, invalidateRuntimes, drain, forget, close, deleteConversation, recover, stopSelected, wait, stopConversation,
1144
1176
  readGoal, updateGoal, renewal, run, steer, cancel });
1145
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, createProvider, createNativeProvider, providerForSession, ensureSession, retireAndCloseProvider, closeProvider, stopOwnedRuntime, stopCachedProvider, stopProvider, stopThreadBeforeRelease, readModelCatalog, invalidateRuntimes, recover,
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() { closing = true; }
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 !== "accepted") {
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
- await transcript.writeUserMessage({
171
- text: pending.displayMessage, messageId: pending.messageId,
172
- attachments: pending.displayAttachments,
173
- ...(pending.data !== undefined ? { data: pending.data } : {}),
174
- turnMetadata: { ...pending.turnMetadata, ...turnMetadata }
175
- });
176
- await markDelivered(pending.seen);
177
- log({ event: "accepted", engineId, messageId: pending.messageId, threadId: pending.threadId, recovered: true });
178
- if (pending.messageId === input.messageId) {
179
- return { ok: true, delivered: true, messageId: input.messageId, threadId: pending.threadId };
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 : { accepted: result.admission === "accepted" };
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
- return { accepted: history.userIds.includes(nativeId),
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
- async function nativeStorageProvider({ sessionId, native, binding, context }) {
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 { provider: await native.providerOwner.ensureSession(prepared.providerContext),
132
- toolHomeSource: prepared.toolHomeSource };
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
- async listConversationStorage(prepared) {
164
- const { provider } = await nativeStorageProvider(prepared);
164
+ listConversationStorage(prepared) {
165
165
  const { binding } = prepared;
166
- return (await provider.listNativeThreadsForCwd(binding.workdir)).map((conversationId) => ({ ...binding, conversationId }));
166
+ return withNativeStorageProvider(prepared, async provider =>
167
+ (await provider.listNativeThreadsForCwd(binding.workdir)).map((conversationId) => ({ ...binding, conversationId })));
167
168
  },
168
- async retireConversationHistory(prepared) {
169
- const { provider, toolHomeSource } = await nativeStorageProvider(prepared);
170
- return retireCodexConversationHistory(provider, prepared.binding, {
171
- toolHomeSource, beforeDelete: prepared.context.beforeDelete, signal: prepared.context.signal,
172
- errorPrefix: prepared.native.errorPrefix
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
- async function nativeStorageTarget({ native, context }) {
79
+ function withNativeStorageTarget({ native, context }, operation) {
80
80
  const prepared = native.preparation.storage(context);
81
- return native.owner.ensure(null, async () =>
82
- native.owner.startPreparedProcess(await prepared.process())).catch(prepared.failure);
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
- async listConversationStorage(prepared) {
131
- const target = await nativeStorageTarget(prepared);
130
+ listConversationStorage(prepared) {
132
131
  const { binding, context } = prepared;
133
- return (await target.server.client.listConversationsForDirectory(binding.workdir, { signal: context.signal }))
134
- .map((row) => ({ ...binding, conversationId: row.id }));
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
- async retireConversationHistory(prepared) {
137
- const target = await nativeStorageTarget(prepared);
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);
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());