opencode-codex-memory 0.6.0 → 0.6.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/README.md CHANGED
@@ -52,7 +52,7 @@ If you want the mental model before the details, jump to
52
52
 
53
53
  ```json
54
54
  {
55
- "plugin": ["opencode-codex-memory@0.6.0"]
55
+ "plugin": ["opencode-codex-memory@0.6.2"]
56
56
  }
57
57
  ```
58
58
 
@@ -254,7 +254,7 @@ To set options, turn the plugin entry into a `[name, options]` pair:
254
254
  ```json
255
255
  {
256
256
  "plugin": [
257
- ["opencode-codex-memory@0.6.0", { "disable_on_external_context": true, "min_rollout_idle_hours": 2 }]
257
+ ["opencode-codex-memory@0.6.2", { "disable_on_external_context": true, "min_rollout_idle_hours": 2 }]
258
258
  ]
259
259
  }
260
260
  ```
@@ -316,7 +316,7 @@ Off by default; no changes to Codex's own config are required.
316
316
  {
317
317
  "plugin": [
318
318
  [
319
- "opencode-codex-memory@0.6.0",
319
+ "opencode-codex-memory@0.6.2",
320
320
  { "codex_interop": { "import": true, "export": true } }
321
321
  ]
322
322
  ]
@@ -373,7 +373,7 @@ from the project memories Claude already keeps on your machine. **One-way only**
373
373
  ```json
374
374
  {
375
375
  "plugin": [
376
- ["opencode-codex-memory@0.6.0", { "claude_import": { "enabled": true } }]
376
+ ["opencode-codex-memory@0.6.2", { "claude_import": { "enabled": true } }]
377
377
  ]
378
378
  }
379
379
  ```
@@ -400,7 +400,7 @@ Claude names each project with an opaque id (a folder under
400
400
  {
401
401
  "plugin": [
402
402
  [
403
- "opencode-codex-memory@0.6.0",
403
+ "opencode-codex-memory@0.6.2",
404
404
  {
405
405
  "claude_import": {
406
406
  "enabled": true,
@@ -485,7 +485,8 @@ mirrors that decision rather than layering scoping back on top.
485
485
  When memory does not seem to build, ask the agent to run **`memory_inspect`**.
486
486
  It reports:
487
487
 
488
- - stage-1 job counts and recent extraction errors
488
+ - stage-1 job counts, failure classes (`backoff` / `provider_capacity` / `other_exhausted`), and recent extraction errors
489
+ - active provider/model quota backoffs and their retry times
489
490
  - phase-2 status / last error / cooldown
490
491
  - last session-discovery outcome
491
492
  - effective options (after clamping) and config warnings
@@ -500,6 +501,7 @@ Common causes:
500
501
  | Discovery failed | Host API/`experimental/session` unavailable; inspect shows the error. Retry after restarting OpenCode. |
501
502
  | Pin stuck on old version | OpenCode freezes bare package specs; pin an explicit version and bump it (see Install). |
502
503
  | Consolidation never runs | Check `phase2_status` and `phase2_last_error` in inspect; failed artifacts keep the workspace diff for the next run. |
504
+ | `stage1_error` mentions usage/rate limit | Temporary provider quota. Inspect shows `provider_capacity` or `backoff`; those jobs retry after about an hour once quota returns. Permanent holes are `other_exhausted`. |
503
505
 
504
506
  Install target: this package runs **inside OpenCode** (Bun). You do not need to
505
507
  install it as a standalone Node app; OpenCode resolves the plugin into its own
@@ -1,22 +1,8 @@
1
1
  import { SCAN_LIMIT } from "./store.js";
2
2
  import { getPluginInput } from "./llm.js";
3
3
  import { recordDiscoveryStatus } from "./diagnostics.js";
4
- import { hostPartType, hostSessionMessages, pluginHttpGet } from "./host-client.js";
4
+ import { hostListSessionsGlobal, hostPartType, hostSessionMessages, pluginHttpGet, withHostTimeout } from "./host-client.js";
5
5
  const API_TIMEOUT_MS = 60_000;
6
- async function withTimeout(promise, ms, label) {
7
- let timer;
8
- try {
9
- return await Promise.race([
10
- promise,
11
- new Promise((_, reject) => {
12
- timer = setTimeout(() => reject(new Error(`${label} timed out after ${ms}ms`)), ms);
13
- }),
14
- ]);
15
- }
16
- finally {
17
- clearTimeout(timer);
18
- }
19
- }
20
6
  /**
21
7
  * Global session discovery through the official API:
22
8
  * `GET /experimental/session?roots=true` (Session.listGlobal) — one call across
@@ -41,22 +27,14 @@ export async function listRecentSessions(limit = SCAN_LIMIT) {
41
27
  now - discoveryCache.at < DISCOVERY_CACHE_MS) {
42
28
  return discoveryCache.rows.slice(0, limit);
43
29
  }
44
- const get = pluginHttpGet(getPluginInput()?.client);
45
- if (!get) {
30
+ const client = getPluginInput()?.client;
31
+ if (!pluginHttpGet(client)) {
46
32
  recordDiscoveryStatus({ ok: false, count: 0, error: "plugin HTTP client unavailable" });
47
33
  return [];
48
34
  }
49
35
  try {
50
- // Opencode's SDK client injects `directory` from x-opencode-directory on
51
- // every GET (sdk client rewrite). The experimental session handler treats
52
- // any present directory query as "filter to this instance's project",
53
- // which would hide every other project's idle sessions — memory is global.
54
- // Pass an empty directory so rewrite does not re-inject, and the handler
55
- // sees a falsy value → listGlobal without a directory filter.
56
- const res = await withTimeout(get({
57
- url: "/experimental/session",
58
- query: { roots: true, limit, directory: "" },
59
- }), API_TIMEOUT_MS, "experimental.session.list");
36
+ const controller = new AbortController();
37
+ const res = await withHostTimeout(hostListSessionsGlobal(client, { limit, signal: controller.signal }), API_TIMEOUT_MS, "experimental.session.list", controller);
60
38
  if (!res || res.error || !Array.isArray(res.data)) {
61
39
  throw new Error(`experimental.session.list failed: ${JSON.stringify(res?.error ?? {})}`);
62
40
  }
@@ -89,7 +67,8 @@ export async function listRecentSessions(limit = SCAN_LIMIT) {
89
67
  }
90
68
  /** Official transcript surface: GET /session/{id}/message via the plugin's authenticated client. */
91
69
  async function fetchMessagesViaApi(sessionId) {
92
- const res = await withTimeout(hostSessionMessages(getPluginInput()?.client, sessionId), API_TIMEOUT_MS, "session.messages");
70
+ const controller = new AbortController();
71
+ const res = await withHostTimeout(hostSessionMessages(getPluginInput()?.client, sessionId, controller.signal), API_TIMEOUT_MS, "session.messages", controller);
93
72
  if (!res || res.error || !Array.isArray(res.data)) {
94
73
  throw new Error(`session.messages failed: ${JSON.stringify(res?.error ?? {})}`);
95
74
  }
@@ -6,7 +6,7 @@
6
6
  *
7
7
  * Current gaps (as of @opencode-ai/plugin ~1.18.10):
8
8
  * - session.create body: metadata + title typing
9
- * - session.prompt body: `format` (json_schema) omitted from PromptInput
9
+ * - session.prompt body: `format` (json_schema) and `variant` omitted from PromptInput
10
10
  * - AssistantMessage.info.structured: structured extraction result
11
11
  * - client._client.get: experimental routes (no experimental.* namespace on V1)
12
12
  * - mcp.status: present on host client, weakly typed in plugin package
@@ -22,6 +22,25 @@ export type HostHttpGet = (opts: {
22
22
  }>;
23
23
  /** Hey-api transport on PluginInput.client for routes the V1 surface lags on. */
24
24
  export declare function pluginHttpGet(client: PluginInput["client"] | null | undefined): HostHttpGet | null;
25
+ /**
26
+ * Host-wide session list. The SDK injects `directory` from
27
+ * x-opencode-directory on every GET; a truthy value makes the experimental
28
+ * handler filter to that instance's project. Pass `directory:""` so rewrite
29
+ * does not re-inject and the handler sees a falsy value → listGlobal.
30
+ * Used by discovery and helper-session cleanup (memory is global).
31
+ */
32
+ export declare function hostListSessionsGlobal(client: PluginInput["client"] | null | undefined, opts: {
33
+ limit: number;
34
+ cursor?: number;
35
+ search?: string;
36
+ signal?: AbortSignal;
37
+ }): Promise<{
38
+ error?: unknown;
39
+ data?: unknown;
40
+ }>;
41
+ /** Attach a no-op catch so a raced-away promise cannot become unhandled. */
42
+ export declare function ignoreLateRejection(promise: Promise<unknown>): void;
43
+ export declare function withHostTimeout<T>(promise: Promise<T>, ms: number, label: string, abort?: AbortController): Promise<T>;
25
44
  export interface SessionCreateBody {
26
45
  title?: string;
27
46
  metadata?: Record<string, unknown>;
@@ -30,6 +49,7 @@ export interface SessionCreateBody {
30
49
  export declare function hostSessionCreate(client: PluginInput["client"], opts: {
31
50
  directory: string;
32
51
  body: SessionCreateBody;
52
+ signal?: AbortSignal;
33
53
  }): Promise<{
34
54
  error?: unknown;
35
55
  data?: {
@@ -44,6 +64,7 @@ export interface HostPromptBody {
44
64
  modelID: string;
45
65
  };
46
66
  format?: Record<string, unknown>;
67
+ variant?: string;
47
68
  parts: {
48
69
  type: "text";
49
70
  text: string;
@@ -64,9 +85,12 @@ export declare function hostMcpStatus(client: PluginInput["client"] | null, sign
64
85
  data?: unknown;
65
86
  } | null>;
66
87
  /** session.messages — weakly typed parts; centralize the cast. */
67
- export declare function hostSessionMessages(client: PluginInput["client"] | null | undefined, sessionId: string): Promise<{
88
+ export declare function hostSessionMessages(client: PluginInput["client"] | null | undefined, sessionId: string, signal?: AbortSignal): Promise<{
68
89
  error?: unknown;
69
90
  data?: unknown;
70
91
  }>;
71
92
  export declare function hostPartType(part: unknown): string;
93
+ export type SessionLiveness = "live" | "gone" | "unknown";
94
+ /** Codex re-checks the live threads table; 404 = gone, anything else stays. */
95
+ export declare function hostSessionLiveness(client: PluginInput["client"] | null | undefined, id: string, timeoutMs?: number): Promise<SessionLiveness>;
72
96
  export declare function hostSessionDeletionConfirmed(client: PluginInput["client"], id: string, timeoutMs: number): Promise<boolean>;
@@ -6,7 +6,7 @@
6
6
  *
7
7
  * Current gaps (as of @opencode-ai/plugin ~1.18.10):
8
8
  * - session.create body: metadata + title typing
9
- * - session.prompt body: `format` (json_schema) omitted from PromptInput
9
+ * - session.prompt body: `format` (json_schema) and `variant` omitted from PromptInput
10
10
  * - AssistantMessage.info.structured: structured extraction result
11
11
  * - client._client.get: experimental routes (no experimental.* namespace on V1)
12
12
  * - mcp.status: present on host client, weakly typed in plugin package
@@ -18,11 +18,58 @@ export function pluginHttpGet(client) {
18
18
  return null;
19
19
  return http.get.bind(http);
20
20
  }
21
+ /**
22
+ * Host-wide session list. The SDK injects `directory` from
23
+ * x-opencode-directory on every GET; a truthy value makes the experimental
24
+ * handler filter to that instance's project. Pass `directory:""` so rewrite
25
+ * does not re-inject and the handler sees a falsy value → listGlobal.
26
+ * Used by discovery and helper-session cleanup (memory is global).
27
+ */
28
+ export async function hostListSessionsGlobal(client, opts) {
29
+ const get = pluginHttpGet(client);
30
+ if (!get)
31
+ throw new Error("plugin HTTP client unavailable");
32
+ return get({
33
+ url: "/experimental/session",
34
+ query: {
35
+ roots: true,
36
+ limit: opts.limit,
37
+ directory: "",
38
+ ...(opts.cursor !== undefined ? { cursor: opts.cursor } : {}),
39
+ ...(opts.search !== undefined ? { search: opts.search } : {}),
40
+ },
41
+ signal: opts.signal,
42
+ });
43
+ }
44
+ /** Attach a no-op catch so a raced-away promise cannot become unhandled. */
45
+ export function ignoreLateRejection(promise) {
46
+ void promise.catch(() => { });
47
+ }
48
+ export async function withHostTimeout(promise, ms, label, abort) {
49
+ ignoreLateRejection(promise);
50
+ let timer;
51
+ try {
52
+ return await Promise.race([
53
+ promise,
54
+ new Promise((_, reject) => {
55
+ timer = setTimeout(() => {
56
+ abort?.abort();
57
+ reject(new Error(`${label} timed out after ${ms}ms`));
58
+ }, ms);
59
+ timer.unref?.();
60
+ }),
61
+ ]);
62
+ }
63
+ finally {
64
+ clearTimeout(timer);
65
+ }
66
+ }
21
67
  /** session.create with metadata (SDK body type is incomplete). */
22
68
  export async function hostSessionCreate(client, opts) {
23
69
  return client.session.create({
24
70
  query: { directory: opts.directory },
25
71
  body: opts.body,
72
+ ...(opts.signal ? { signal: opts.signal } : {}),
26
73
  });
27
74
  }
28
75
  /** session.prompt including `format` for json_schema structured output. */
@@ -34,6 +81,7 @@ export async function hostSessionPrompt(client, opts) {
34
81
  ...(opts.body.system ? { system: opts.body.system } : {}),
35
82
  ...(opts.body.model ? { model: opts.body.model } : {}),
36
83
  ...(opts.body.format ? { format: opts.body.format } : {}),
84
+ ...(opts.body.variant ? { variant: opts.body.variant } : {}),
37
85
  parts: opts.body.parts,
38
86
  },
39
87
  });
@@ -55,15 +103,50 @@ export async function hostMcpStatus(client, signal) {
55
103
  return (await mcp.status({ signal }));
56
104
  }
57
105
  /** session.messages — weakly typed parts; centralize the cast. */
58
- export async function hostSessionMessages(client, sessionId) {
106
+ export async function hostSessionMessages(client, sessionId, signal) {
59
107
  if (!client || typeof client.session?.messages !== "function") {
60
108
  throw new Error("plugin client unavailable; cannot load transcript");
61
109
  }
62
- return client.session.messages({ path: { id: sessionId } });
110
+ return client.session.messages({
111
+ path: { id: sessionId },
112
+ ...(signal ? { signal } : {}),
113
+ });
63
114
  }
64
115
  export function hostPartType(part) {
65
116
  return typeof part?.type === "string" ? part.type : "unknown";
66
117
  }
118
+ function isNotFoundStatus(res) {
119
+ if (res?.response?.status === 404)
120
+ return true;
121
+ const err = res?.error;
122
+ if (err && typeof err === "object") {
123
+ const rec = err;
124
+ if (rec.status === 404)
125
+ return true;
126
+ if (typeof rec.name === "string" && rec.name.toLowerCase().includes("notfound"))
127
+ return true;
128
+ }
129
+ return false;
130
+ }
131
+ const SESSION_LIVE_TIMEOUT_MS = 1_000;
132
+ /** Codex re-checks the live threads table; 404 = gone, anything else stays. */
133
+ export async function hostSessionLiveness(client, id, timeoutMs = SESSION_LIVE_TIMEOUT_MS) {
134
+ const session = client?.session;
135
+ if (typeof session?.get !== "function")
136
+ return "unknown";
137
+ const controller = new AbortController();
138
+ try {
139
+ const res = await withHostTimeout(session.get({ path: { id }, signal: controller.signal }), timeoutMs, "session.get", controller);
140
+ if (isNotFoundStatus(res))
141
+ return "gone";
142
+ if (res?.error)
143
+ return "unknown";
144
+ return "live";
145
+ }
146
+ catch {
147
+ return "unknown";
148
+ }
149
+ }
67
150
  export async function hostSessionDeletionConfirmed(client, id, timeoutMs) {
68
151
  const session = client.session;
69
152
  if (typeof session?.get !== "function")
package/dist/src/llm.d.ts CHANGED
@@ -6,7 +6,15 @@ export interface ExtractionResult {
6
6
  }
7
7
  export declare function setPluginInput(input: PluginInput): void;
8
8
  export declare function getPluginInput(): PluginInput | null;
9
+ /** Test seam. */
10
+ export declare function setSubSessionCreateTimeoutForTest(ms?: number): void;
11
+ /** Test seam. */
12
+ export declare function setConfigGetTimeoutForTest(ms?: number): void;
13
+ /** Test seam. */
14
+ export declare function setStaleDeleteBatchTimeoutForTest(ms?: number): void;
9
15
  export declare function isMemorySubSession(sessionId: string): boolean;
16
+ export declare function resolveExtractionModel(configured?: string): Promise<string | undefined>;
17
+ export declare function resolveConsolidationModel(configured?: string): Promise<string | undefined>;
10
18
  /**
11
19
  * Thrown when a sub-agent prompt exceeds its budget. A distinct type (rather
12
20
  * than matching on the message text) is what tells the catch below that the
package/dist/src/llm.js CHANGED
@@ -1,12 +1,17 @@
1
1
  import fs from "fs";
2
2
  import path from "path";
3
3
  import { memoryRoot } from "./paths.js";
4
- import { hostSessionCreate, hostSessionDeletionConfirmed, hostSessionPrompt, hostStructuredOutput, } from "./host-client.js";
5
- import { pluginShutdownSignal } from "./lifecycle.js";
4
+ import { hostListSessionsGlobal, hostSessionCreate, hostSessionDeletionConfirmed, hostSessionPrompt, hostStructuredOutput, ignoreLateRejection, pluginHttpGet, withHostTimeout, } from "./host-client.js";
5
+ import { isPluginShuttingDown, pluginShutdownSignal } from "./lifecycle.js";
6
+ import { SCAN_LIMIT } from "./store.js";
7
+ import { isProviderCapacityError, ProviderCapacityError } from "./ratelimit.js";
6
8
  let inputRef = null;
9
+ let inputGeneration = 0;
7
10
  export function setPluginInput(input) {
8
11
  inputRef = input;
12
+ inputGeneration++;
9
13
  configModels = null;
14
+ configModelsInFlight = null;
10
15
  }
11
16
  export function getPluginInput() {
12
17
  return inputRef;
@@ -20,6 +25,25 @@ const SUBSESSION_LIST_TIMEOUT_MS = 5_000;
20
25
  const SUBSESSION_ABORT_TIMEOUT_MS = 1_000;
21
26
  const SUBSESSION_CONFIRM_TIMEOUT_MS = 1_000;
22
27
  const SUBSESSION_DELETE_TIMEOUT_MS = 10_000;
28
+ const SUBSESSION_CREATE_TIMEOUT_MS = 10_000;
29
+ const CONFIG_GET_TIMEOUT_MS = 5_000;
30
+ const SUBSESSION_DELETE_CONCURRENCY = 8;
31
+ const SUBSESSION_DELETE_BATCH_TIMEOUT_MS = 30_000;
32
+ let createTimeoutMs = SUBSESSION_CREATE_TIMEOUT_MS;
33
+ let configGetTimeoutMs = CONFIG_GET_TIMEOUT_MS;
34
+ let staleDeleteBatchTimeoutMs = SUBSESSION_DELETE_BATCH_TIMEOUT_MS;
35
+ /** Test seam. */
36
+ export function setSubSessionCreateTimeoutForTest(ms) {
37
+ createTimeoutMs = ms ?? SUBSESSION_CREATE_TIMEOUT_MS;
38
+ }
39
+ /** Test seam. */
40
+ export function setConfigGetTimeoutForTest(ms) {
41
+ configGetTimeoutMs = ms ?? CONFIG_GET_TIMEOUT_MS;
42
+ }
43
+ /** Test seam. */
44
+ export function setStaleDeleteBatchTimeoutForTest(ms) {
45
+ staleDeleteBatchTimeoutMs = ms ?? SUBSESSION_DELETE_BATCH_TIMEOUT_MS;
46
+ }
23
47
  export function isMemorySubSession(sessionId) {
24
48
  return activeSubSessions.has(sessionId);
25
49
  }
@@ -39,16 +63,20 @@ async function createSession(agent, title) {
39
63
  const input = getPluginInput();
40
64
  if (!input)
41
65
  throw new Error("plugin input not initialized");
66
+ if (isPluginShuttingDown())
67
+ throw new SubagentCancelledError();
42
68
  const directory = resolveSubSessionDirectory();
43
69
  // directory is a query param (not body); without it the client inherits
44
70
  // PluginInput.directory, which may be a deleted project path.
45
- const res = await hostSessionCreate(input.client, {
71
+ const controller = new AbortController();
72
+ const res = await withHostTimeout(hostSessionCreate(input.client, {
46
73
  directory,
47
74
  body: {
48
75
  title: title ?? `codex-memory-${agent}`,
49
76
  metadata: { [SUBSESSION_METADATA_KEY]: true },
50
77
  },
51
- });
78
+ signal: controller.signal,
79
+ }), createTimeoutMs, "session.create", controller);
52
80
  if (!res.data)
53
81
  throw new Error(`session create failed: ${JSON.stringify(res.error ?? {})}`);
54
82
  const body = res.data;
@@ -66,23 +94,46 @@ async function createSession(agent, title) {
66
94
  * plugin instance — opencode reloads plugins on config change.
67
95
  */
68
96
  let configModels = null;
97
+ let configModelsInFlight = null;
69
98
  async function getConfigModels() {
70
99
  if (configModels)
71
100
  return configModels;
101
+ if (configModelsInFlight)
102
+ return configModelsInFlight;
72
103
  const input = getPluginInput();
73
104
  if (!input)
74
105
  return {};
106
+ const generation = inputGeneration;
107
+ const request = (async () => {
108
+ const controller = new AbortController();
109
+ try {
110
+ const res = await withHostTimeout(input.client.config.get({ signal: controller.signal }), configGetTimeoutMs, "config.get", controller);
111
+ if (res.error || !res.data || generation !== inputGeneration)
112
+ return {};
113
+ const resolved = { model: res.data.model, smallModel: res.data.small_model };
114
+ configModels = resolved;
115
+ return resolved;
116
+ }
117
+ catch {
118
+ // Config endpoint unavailable: leave models unset so the sub-agent runs
119
+ // on the session default. Do not cache failures: the next call can recover.
120
+ return {};
121
+ }
122
+ })();
123
+ configModelsInFlight = request;
75
124
  try {
76
- const res = await input.client.config.get();
77
- const cfg = res?.data;
78
- configModels = { model: cfg?.model, smallModel: cfg?.small_model };
125
+ return await request;
79
126
  }
80
- catch {
81
- // Config endpoint unavailable: leave models unset so the sub-agent runs
82
- // on the session default, the previous behavior.
83
- configModels = {};
127
+ finally {
128
+ if (configModelsInFlight === request)
129
+ configModelsInFlight = null;
84
130
  }
85
- return configModels;
131
+ }
132
+ export async function resolveExtractionModel(configured) {
133
+ return configured ?? (await getConfigModels()).smallModel;
134
+ }
135
+ export async function resolveConsolidationModel(configured) {
136
+ return configured ?? (await getConfigModels()).model;
86
137
  }
87
138
  // extract_model / consolidation model strings are "providerID/modelID".
88
139
  function parseModelRef(ref) {
@@ -163,9 +214,11 @@ async function runPrompt(sessionId, prompt, agent, opts = {}) {
163
214
  ...(opts.system ? { system: opts.system } : {}),
164
215
  ...(model ? { model } : {}),
165
216
  ...(opts.format ? { format: opts.format } : {}),
217
+ ...(opts.variant ? { variant: opts.variant } : {}),
166
218
  parts: [{ type: "text", text: prompt }],
167
219
  },
168
220
  });
221
+ ignoreLateRejection(promptPromise);
169
222
  let timer;
170
223
  let onAbort;
171
224
  try {
@@ -187,12 +240,21 @@ async function runPrompt(sessionId, prompt, agent, opts = {}) {
187
240
  timer = setTimeout(() => reject(new SubagentTimeoutError(timeoutMs)), timeoutMs);
188
241
  }),
189
242
  ]);
190
- if (!res.data)
191
- throw new Error(`prompt failed: ${JSON.stringify(res.error ?? {})}`);
243
+ if (!res.data) {
244
+ const message = `prompt failed: ${JSON.stringify(res.error ?? {})}`;
245
+ if (isProviderCapacityError(res.error))
246
+ throw new ProviderCapacityError(message);
247
+ throw new Error(message);
248
+ }
192
249
  const promptError = res.data.info?.error;
193
250
  if (promptError) {
194
251
  const detail = promptError.data?.message;
195
- throw new Error(`sub-agent prompt failed${promptError.name ? ` (${promptError.name})` : ""}${detail ? `: ${detail}` : ""}`);
252
+ const status = promptError.data?.statusCode;
253
+ const message = `sub-agent prompt failed${promptError.name ? ` (${promptError.name})` : ""}` +
254
+ `${detail ? `: ${detail}` : ""}${status !== undefined ? ` (HTTP ${status})` : ""}`;
255
+ if (isProviderCapacityError(promptError))
256
+ throw new ProviderCapacityError(message, status);
257
+ throw new Error(message);
196
258
  }
197
259
  return res.data;
198
260
  }
@@ -263,7 +325,7 @@ export async function extractViaSubagent(sessionId, transcript, opts = {}) {
263
325
  try {
264
326
  const prompt = buildExtractionInput(sessionId, opts.cwd ?? "unknown", transcript);
265
327
  // extract_model option > opencode small_model > session default.
266
- const model = opts.model ?? (await getConfigModels()).smallModel;
328
+ const model = await resolveExtractionModel(opts.model);
267
329
  const data = await runPrompt(subId, prompt, agent, {
268
330
  // Mirrors the stage-1 job lease (1h): codex has no per-request timeout,
269
331
  // and a near-600k-char transcript on a slow model can easily exceed a
@@ -276,6 +338,9 @@ export async function extractViaSubagent(sessionId, transcript, opts = {}) {
276
338
  // call (toolChoice: required) — which is why memorize-extract must allow
277
339
  // that one otherwise-denied tool.
278
340
  format: { type: "json_schema", schema: EXTRACTION_SCHEMA },
341
+ // Codex extraction ReasoningEffort::Low. Host maps variant → reasoningEffort;
342
+ // missing variant on the model is a no-op.
343
+ variant: "low",
279
344
  });
280
345
  // The captured JSON lands on AssistantMessage.structured (schema
281
346
  // v1/session.ts; absent from the generated SDK type — see host-client.ts).
@@ -306,8 +371,14 @@ export async function consolidateViaSubagent(memoryRoot, diffFileName, model, si
306
371
  try {
307
372
  const prompt = buildConsolidationPrompt(memoryRoot, diffFileName);
308
373
  // consolidation_model option > opencode model (main) > session default.
309
- const resolved = model ?? (await getConfigModels()).model;
310
- await promptSession(subId, prompt, agent, { model: resolved, timeoutMs: CONSOLIDATION_TIMEOUT_MS, signal });
374
+ const resolved = await resolveConsolidationModel(model);
375
+ await promptSession(subId, prompt, agent, {
376
+ model: resolved,
377
+ timeoutMs: CONSOLIDATION_TIMEOUT_MS,
378
+ signal,
379
+ // Codex consolidation ReasoningEffort::Medium.
380
+ variant: "medium",
381
+ });
311
382
  }
312
383
  catch (err) {
313
384
  promptError = err;
@@ -331,46 +402,92 @@ export async function cleanupOldSubSessions(maxAgeMinutes = 90, timeoutMs = SUBS
331
402
  const input = getPluginInput();
332
403
  if (!input)
333
404
  return;
334
- let timer;
405
+ if (!pluginHttpGet(input.client))
406
+ return;
407
+ const controller = new AbortController();
408
+ const staleSessionIds = [];
335
409
  try {
336
- if (typeof input.client?.session?.list !== "function")
337
- return;
338
- const res = await Promise.race([
339
- input.client.session.list(),
340
- new Promise((_, reject) => {
341
- timer = setTimeout(() => reject(new Error(`session.list timed out after ${timeoutMs}ms`)), timeoutMs);
342
- }),
343
- ]);
344
- if (!res.data)
345
- return;
346
- const list = res.data;
347
410
  const cutoff = Date.now() - maxAgeMinutes * 60 * 1000;
348
- for (const s of list) {
349
- if (!s.id)
350
- continue;
351
- const pluginTitle = isPluginSubSessionTitle(s.title);
352
- const owned = s.metadata?.[SUBSESSION_METADATA_KEY] === true && pluginTitle;
353
- const legacy = s.metadata?.[SUBSESSION_METADATA_KEY] !== true && pluginTitle;
354
- if (!owned && !legacy)
355
- continue;
356
- // Durable ownership requires marker + generated title; a legacy title
357
- // alone can reseed the skip set but never authorizes deletion.
358
- activeSubSessions.add(s.id);
359
- if (!owned)
411
+ const deadline = Date.now() + timeoutMs;
412
+ const seen = new Set();
413
+ let cursor;
414
+ let pageLimit = SCAN_LIMIT;
415
+ while (true) {
416
+ const remaining = deadline - Date.now();
417
+ if (remaining <= 0)
418
+ return;
419
+ const res = await withHostTimeout(hostListSessionsGlobal(input.client, {
420
+ limit: pageLimit,
421
+ cursor,
422
+ search: "codex-memory-",
423
+ signal: controller.signal,
424
+ }), remaining, "experimental.session.list", controller);
425
+ if (res.error || !Array.isArray(res.data))
426
+ return;
427
+ const list = res.data;
428
+ let newSessionCount = 0;
429
+ for (const s of list) {
430
+ if (!s.id || seen.has(s.id))
431
+ continue;
432
+ seen.add(s.id);
433
+ newSessionCount++;
434
+ const pluginTitle = isPluginSubSessionTitle(s.title);
435
+ const owned = s.metadata?.[SUBSESSION_METADATA_KEY] === true && pluginTitle;
436
+ const legacy = s.metadata?.[SUBSESSION_METADATA_KEY] !== true && pluginTitle;
437
+ if (!owned && !legacy)
438
+ continue;
439
+ // Durable ownership requires marker + generated title; a legacy title
440
+ // alone can reseed the skip set but never authorizes deletion.
441
+ activeSubSessions.add(s.id);
442
+ if (!owned)
443
+ continue;
444
+ const created = s.time?.created ?? 0;
445
+ if (created && created < cutoff) {
446
+ staleSessionIds.push(s.id);
447
+ }
448
+ }
449
+ if (list.length < pageLimit)
450
+ return;
451
+ const updates = list.map((s) => s.time?.updated).filter((updated) => typeof updated === "number" && Number.isFinite(updated) && updated >= 0);
452
+ if (updates.length === 0)
453
+ return;
454
+ // listGlobal uses `updated < cursor`. Add one millisecond so sessions
455
+ // tied at the page boundary remain visible, then dedupe repeated rows.
456
+ const nextCursor = updates.reduce((min, updated) => Math.min(min, updated), Infinity) + 1;
457
+ if (cursor !== undefined && (nextCursor >= cursor || newSessionCount === 0)) {
458
+ // A full page can consist entirely of the same timestamp. Increase the
459
+ // page size until unseen tied rows appear or the overall deadline wins.
460
+ pageLimit += SCAN_LIMIT;
360
461
  continue;
361
- const created = s.time?.created ?? 0;
362
- if (created && created < cutoff) {
363
- void deleteSession(s.id);
364
462
  }
463
+ cursor = nextCursor;
464
+ pageLimit = SCAN_LIMIT;
365
465
  }
366
466
  }
367
467
  catch {
368
468
  // best effort only
369
469
  }
370
470
  finally {
371
- clearTimeout(timer);
471
+ void deleteStaleSubSessions(staleSessionIds, input, staleDeleteBatchTimeoutMs);
372
472
  }
373
473
  }
474
+ async function deleteStaleSubSessions(sessionIds, input, timeoutMs) {
475
+ let cursor = 0;
476
+ const deadline = Date.now() + timeoutMs;
477
+ const workers = Array.from({ length: Math.min(SUBSESSION_DELETE_CONCURRENCY, sessionIds.length) }, async () => {
478
+ while (cursor < sessionIds.length && Date.now() < deadline && !isPluginShuttingDown()) {
479
+ const id = sessionIds[cursor++];
480
+ try {
481
+ await deleteSession(id, input, Math.max(1, Math.min(SUBSESSION_DELETE_TIMEOUT_MS, deadline - Date.now())));
482
+ }
483
+ catch {
484
+ // deleteSession is best-effort; one unexpected failure must not stop
485
+ // the remaining stale-helper cleanup.
486
+ }
487
+ }
488
+ });
489
+ await Promise.all(workers);
490
+ }
374
491
  function isPluginSubSessionTitle(title) {
375
492
  return title === "codex-memory-consolidate" || /^codex-memory-extract-ses_[A-Za-z0-9]+$/.test(title ?? "");
376
493
  }
@@ -383,20 +500,21 @@ function isPluginSubSessionTitle(title) {
383
500
  * really gone?) and only governs ownership tracking, never the shutdown result:
384
501
  * hosts without `session.get` would otherwise never report a clean shutdown.
385
502
  */
386
- async function deleteSession(id) {
387
- const input = getPluginInput();
503
+ async function deleteSession(id, input = getPluginInput(), timeoutMs = SUBSESSION_DELETE_TIMEOUT_MS) {
388
504
  if (!input)
389
505
  return false;
390
506
  const controller = new AbortController();
391
507
  let timer;
392
508
  try {
509
+ const deletePromise = input.client.session.delete({ path: { id }, signal: controller.signal });
510
+ ignoreLateRejection(deletePromise);
393
511
  const res = await Promise.race([
394
- input.client.session.delete({ path: { id }, signal: controller.signal }),
512
+ deletePromise,
395
513
  new Promise((_, reject) => {
396
514
  timer = setTimeout(() => {
397
515
  controller.abort();
398
- reject(new Error(`session.delete timed out after ${SUBSESSION_DELETE_TIMEOUT_MS}ms`));
399
- }, SUBSESSION_DELETE_TIMEOUT_MS);
516
+ reject(new Error(`session.delete timed out after ${timeoutMs}ms`));
517
+ }, timeoutMs);
400
518
  timer.unref?.();
401
519
  }),
402
520
  ]);