mixdog 0.9.86 → 0.9.87

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/package.json +2 -2
  2. package/src/runtime/agent/orchestrator/agent-trace-format.mjs +4 -2
  3. package/src/runtime/agent/orchestrator/providers/oauth-usage.mjs +125 -16
  4. package/src/runtime/agent/orchestrator/session/manager.mjs +3 -0
  5. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +9 -2
  6. package/src/runtime/agent/orchestrator/tools/env-scrub.mjs +8 -0
  7. package/src/runtime/agent/orchestrator/tools/patch-tool-defs.mjs +4 -3
  8. package/src/runtime/agent/orchestrator/tools/token-manifest.json +23 -2
  9. package/src/runtime/memory/lib/memory-action-handlers.mjs +4 -1
  10. package/src/runtime/memory/tool-defs.mjs +19 -3
  11. package/src/session-runtime/lifecycle-api.mjs +15 -0
  12. package/src/session-runtime/provider-auth-api.mjs +8 -0
  13. package/src/session-runtime/provider-usage.mjs +30 -5
  14. package/src/session-runtime/runtime-core.mjs +267 -481
  15. package/src/session-runtime/session-lifecycle.mjs +378 -0
  16. package/src/standalone/agent-shard/shard-pool.mjs +6 -5
  17. package/src/standalone/agent-tool/job-views.mjs +329 -0
  18. package/src/standalone/agent-tool/spawn-flow.mjs +629 -0
  19. package/src/standalone/agent-tool/tag-registry.mjs +340 -0
  20. package/src/standalone/agent-tool.mjs +100 -1151
  21. package/src/standalone/usage-dashboard.mjs +23 -1
  22. package/src/tui/App.jsx +362 -2576
  23. package/src/tui/app/app-view.jsx +504 -0
  24. package/src/tui/app/create-app-pickers.mjs +313 -0
  25. package/src/tui/app/prompt-submit.mjs +501 -0
  26. package/src/tui/app/shell-layout.mjs +563 -0
  27. package/src/tui/app/usage-context-panels.mjs +288 -0
  28. package/src/tui/app/use-copy-selection.mjs +56 -0
  29. package/src/tui/app/use-global-key-input.mjs +151 -0
  30. package/src/tui/app/use-pasted-buffers.mjs +115 -0
  31. package/src/tui/app/use-prompt-draft-flow.mjs +167 -0
  32. package/src/tui/app/use-prompt-hint.mjs +58 -0
  33. package/src/tui/app/use-prompt-queue-history.mjs +100 -0
  34. package/src/tui/app/use-terminal-chrome.mjs +72 -0
  35. package/src/tui/app/use-transcript-activity.mjs +131 -0
  36. package/src/tui/app/use-welcome-prompt-hint.mjs +96 -0
  37. package/src/tui/dist/index.mjs +9802 -9024
  38. package/src/tui/engine/session-api-ext.mjs +23 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mixdog",
3
- "version": "0.9.86",
3
+ "version": "0.9.87",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Standalone mixdog coding-agent CLI/TUI workspace.",
@@ -55,7 +55,7 @@
55
55
  "test:tool-contracts": "node scripts/tool-smoke.mjs",
56
56
  "smoke:patch": "node scripts/apply-patch-edit-smoke.mjs",
57
57
  "smoke:output": "node scripts/output-style-smoke.mjs",
58
- "smoke:tui": "node scripts/tui-render-smoke.mjs && npm run test:tui-input-render && npm run test:tui-streaming-window && npm run test:tui-queue",
58
+ "smoke:tui": "node scripts/build-tui.mjs && node scripts/tui-render-smoke.mjs && npm run test:tui-input-render && npm run test:tui-streaming-window && npm run test:tui-queue",
59
59
  "smoke:freevars": "node scripts/freevar-smoke.mjs",
60
60
  "smoke:logguard": "node scripts/log-writer-guard-smoke.mjs",
61
61
  "smoke:live-worker": "node scripts/live-worker-smoke.mjs",
@@ -282,12 +282,14 @@ function classifyToolFailure(resultText, toolName) {
282
282
  return 'command-exit';
283
283
  }
284
284
  if (/\[tool-input-validation\]|compacted-history placeholder/.test(text)) return 'schema/args';
285
- if (/requires either|invalid arguments|unknown parameter|must be|schema|expected|required|old_string is .*>=/.test(text)) return 'schema/args';
285
+ if (/hunk rejected|patch failed|context mismatch|context not found/.test(text)
286
+ || /expected first old(?:\/context| line)/.test(text)) return 'patch/context';
287
+ if (/requires either|invalid arguments|unknown parameter|unknown memory action/.test(text)
288
+ || /must be|schema|required|old_string is .*>?=/.test(text)) return 'schema/args';
286
289
  if (/not in allow-list|not allowed/.test(text)) return 'permission';
287
290
  if (String(toolName || '') === 'shell' || /^\s*\[exit code:\s*\d+\]/i.test(raw)) return 'command-exit';
288
291
  if (/enoent|cannot find|not found at this path|path does not exist|no such file|file not found in graph|unreadable/.test(text)) return 'path/enoent';
289
292
  if (/timed out|timeout|interrupted|aborted/.test(text)) return 'timeout/abort';
290
- if (/hunk rejected|patch failed|context mismatch|expected first old\/context|context not found/.test(text)) return 'patch/context';
291
293
  if (/permission|denied|forbidden/.test(text)) return 'permission';
292
294
  if (/unknown tool|tool.*not.*available|missing.*tool/.test(text)) return 'tool-surface';
293
295
  return 'runtime/failure';
@@ -2,6 +2,7 @@ import {
2
2
  existsSync,
3
3
  readFileSync,
4
4
  } from 'fs';
5
+ import { createHash } from 'crypto';
5
6
  import { join } from 'path';
6
7
  import { updateJsonAtomicSync } from '../../../shared/atomic-file.mjs';
7
8
  import { resolvePluginData } from '../../../shared/plugin-paths.mjs';
@@ -15,6 +16,8 @@ const STALE_DISK_CACHE_TTL_MS = 7 * 24 * 60 * 60_000;
15
16
  const NEGATIVE_CACHE_TTL_MS = 5 * 60_000;
16
17
  const FETCH_TIMEOUT_MS = 4500;
17
18
  const WARN_TTL_MS = 5 * 60_000;
19
+ const CODEX_RESET_CREDITS_URL = 'https://chatgpt.com/backend-api/wham/rate-limit-reset-credits';
20
+ const CODEX_RESET_CONSUME_URL = `${CODEX_RESET_CREDITS_URL}/consume`;
18
21
 
19
22
  const memoryCache = new Map();
20
23
  const inflight = new Map();
@@ -199,6 +202,110 @@ function resetAtMs(value, fallbackSeconds = null) {
199
202
  return secs > 0 ? Date.now() + secs * 1000 : null;
200
203
  }
201
204
 
205
+ function codexAuthShape(auth) {
206
+ const token = auth?.access_token || auth?.accessToken;
207
+ if (!token) return null;
208
+ return {
209
+ token,
210
+ accountId: cleanString(auth?.account_id || auth?.accountId),
211
+ };
212
+ }
213
+
214
+ function codexHeaders(auth, beta = 'codex-1') {
215
+ return {
216
+ Authorization: `Bearer ${auth.token}`,
217
+ originator: 'Codex Desktop',
218
+ ...(auth.accountId ? { 'chatgpt-account-id': auth.accountId } : {}),
219
+ 'OpenAI-Beta': beta,
220
+ Accept: 'application/json',
221
+ };
222
+ }
223
+
224
+ function normalizedResetCreditRows(value) {
225
+ return (Array.isArray(value) ? value : []).map((credit) => ({
226
+ status: cleanString(credit?.status).toLowerCase(),
227
+ expiresAt: resetAtMs(credit?.expires_at ?? credit?.expiresAt),
228
+ grantedAt: resetAtMs(credit?.granted_at ?? credit?.grantedAt),
229
+ }));
230
+ }
231
+
232
+ function normalizeOpenAICodexResetCredits(data, accountId = '') {
233
+ if (!data || typeof data !== 'object') return null;
234
+ const credits = normalizedResetCreditRows(data.credits);
235
+ const explicitCount = num(data.available_count ?? data.availableCount, null);
236
+ const availableRows = credits.filter((credit) => credit.status === 'available');
237
+ if (explicitCount === null && !credits.length) return null;
238
+ const availableCount = Math.max(0, Math.floor(explicitCount ?? availableRows.length));
239
+ const expiryCandidates = availableRows
240
+ .map((credit) => credit.expiresAt)
241
+ .filter((value) => Number.isFinite(value) && value > 0);
242
+ const nextExpiresAt = resetAtMs(data.next_expires_at ?? data.nextExpiresAt)
243
+ || (expiryCandidates.length ? Math.min(...expiryCandidates) : null);
244
+ const offerRevision = `v1:${createHash('sha256').update(JSON.stringify({
245
+ accountId,
246
+ availableCount,
247
+ nextExpiresAt,
248
+ credits,
249
+ })).digest('hex')}`;
250
+ return {
251
+ availableCount,
252
+ ...(nextExpiresAt ? { nextExpiresAt } : {}),
253
+ offerRevision,
254
+ };
255
+ }
256
+
257
+ async function resolveOpenAICodexAuth(providerObj) {
258
+ return codexAuthShape(await providerObj?.ensureAuth?.({ reason: 'usage' }));
259
+ }
260
+
261
+ async function fetchOpenAICodexResetCreditsWithAuth(auth) {
262
+ const response = await fetch(CODEX_RESET_CREDITS_URL, fetchOptions(codexHeaders(auth)));
263
+ if (!response.ok) return null;
264
+ return normalizeOpenAICodexResetCredits(await response.json(), auth.accountId);
265
+ }
266
+
267
+ export async function fetchOpenAICodexResetCredits(providerObj) {
268
+ const auth = await resolveOpenAICodexAuth(providerObj);
269
+ return auth ? await fetchOpenAICodexResetCreditsWithAuth(auth) : null;
270
+ }
271
+
272
+ function codexResetOutcome(code) {
273
+ if (code === 'reset') return 'reset';
274
+ if (code === 'nothing_to_reset') return 'nothingToReset';
275
+ if (code === 'no_credit') return 'noCredit';
276
+ if (code === 'already_redeemed') return 'alreadyRedeemed';
277
+ throw new Error(`Unknown Codex reset outcome: ${cleanString(code) || 'missing'}`);
278
+ }
279
+
280
+ export async function consumeOpenAICodexResetCredit(providerObj, options = {}) {
281
+ const expectedOfferRevision = cleanString(options?.expectedOfferRevision);
282
+ const idempotencyKey = cleanString(options?.idempotencyKey);
283
+ if (!/^v1:[a-f0-9]{64}$/i.test(expectedOfferRevision)) {
284
+ throw new TypeError('Codex reset offer revision is invalid');
285
+ }
286
+ if (!/^[a-f0-9]{8}-[a-f0-9]{4}-[1-5][a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/i.test(idempotencyKey)) {
287
+ throw new TypeError('Codex reset idempotency key is invalid');
288
+ }
289
+ const auth = await resolveOpenAICodexAuth(providerObj);
290
+ if (!auth) throw new Error('Codex is not signed in');
291
+ const current = await fetchOpenAICodexResetCreditsWithAuth(auth);
292
+ if (!current || current.availableCount < 1 || current.offerRevision !== expectedOfferRevision) {
293
+ return { status: 'offerChanged', resetCredits: current };
294
+ }
295
+ const response = await fetch(CODEX_RESET_CONSUME_URL, {
296
+ ...fetchOptions({
297
+ ...codexHeaders(auth),
298
+ 'Content-Type': 'application/json',
299
+ }, 15_000),
300
+ method: 'POST',
301
+ body: JSON.stringify({ redeem_request_id: idempotencyKey }),
302
+ });
303
+ if (!response.ok) throw new Error(`Codex reset failed: HTTP ${response.status}`);
304
+ const outcome = codexResetOutcome((await response.json())?.code);
305
+ const resetCredits = await fetchOpenAICodexResetCreditsWithAuth(auth).catch(() => null);
306
+ return { outcome, resetCredits };
307
+ }
308
+
202
309
  function labelForDuration(seconds, fallback) {
203
310
  const s = num(seconds, 0);
204
311
  if (s > 0) {
@@ -482,19 +589,18 @@ function latestClaudeStatuslineUsage() {
482
589
  }
483
590
 
484
591
  async function fetchOpenAICodexUsage(providerObj) {
485
- const auth = await providerObj?.ensureAuth?.({ reason: 'usage' });
486
- const token = auth?.access_token || auth?.accessToken;
487
- if (!token) return null;
488
- const res = await fetch('https://chatgpt.com/backend-api/wham/usage', fetchOptions({
489
- Authorization: `Bearer ${token}`,
490
- originator: 'codex_cli_rs',
491
- 'chatgpt-account-id': auth.account_id || auth.accountId || '',
492
- 'OpenAI-Beta': 'responses=experimental',
493
- Accept: 'application/json',
494
- }));
592
+ const auth = await resolveOpenAICodexAuth(providerObj);
593
+ if (!auth) return null;
594
+ const [res, resetCredits] = await Promise.all([
595
+ fetch('https://chatgpt.com/backend-api/wham/usage', fetchOptions(
596
+ codexHeaders(auth, 'responses=experimental'),
597
+ )),
598
+ fetchOpenAICodexResetCreditsWithAuth(auth).catch(() => null),
599
+ ]);
495
600
  if (!res.ok) throw new Error(`openai-oauth usage ${res.status}`);
496
601
  const data = await res.json();
497
- return normalizeOpenAIWhamUsage(data);
602
+ const usage = normalizeOpenAIWhamUsage(data);
603
+ return usage && resetCredits ? { ...usage, resetCredits } : usage;
498
604
  }
499
605
 
500
606
  async function fetchAnthropicUsage(providerObj) {
@@ -589,15 +695,18 @@ async function fetchGrokUsage(providerObj, routeInfo) {
589
695
  return null;
590
696
  }
591
697
 
592
- export async function fetchOAuthUsageSnapshot(routeInfo, providerObj, log = () => {}) {
698
+ export async function fetchOAuthUsageSnapshot(routeInfo, providerObj, log = () => {}, options = {}) {
593
699
  const provider = providerKey(routeInfo);
594
700
  if (!provider.includes('oauth')) return null;
595
701
  const key = routeKey(routeInfo);
596
702
  const providerOnly = providerKey(routeInfo);
597
- const cached = freshSnapshot(memoryCache.get(key), LIVE_CACHE_TTL_MS)
598
- || freshSnapshot(memoryCache.get(providerOnly), LIVE_CACHE_TTL_MS);
599
- if (cached) return cached;
600
- if (negativeFresh(key) || negativeFresh(providerOnly)) return null;
703
+ const force = options?.force === true;
704
+ if (!force) {
705
+ const cached = freshSnapshot(memoryCache.get(key), LIVE_CACHE_TTL_MS)
706
+ || freshSnapshot(memoryCache.get(providerOnly), LIVE_CACHE_TTL_MS);
707
+ if (cached) return cached;
708
+ if (negativeFresh(key) || negativeFresh(providerOnly)) return null;
709
+ }
601
710
  if (inflight.has(key)) return inflight.get(key);
602
711
 
603
712
  const task = (async () => {
@@ -123,5 +123,8 @@ export {
123
123
  flushSessionMetrics,
124
124
  } from './manager/session-crud.mjs';
125
125
  export { deleteSession } from './store.mjs';
126
+ // Read-only parsed-session access (desktop pane peek): no resume, no
127
+ // ownership, no liveness side effects.
128
+ export { loadSession } from './store.mjs';
126
129
  export { closeSession, abortSessionTurn } from './manager/session-close.mjs';
127
130
  export { sweepTombstones, startIdleCleanup, stopIdleCleanup } from './manager/idle-cleanup.mjs';
@@ -74,13 +74,20 @@ export const BUILTIN_TOOLS = [
74
74
  name: 'shell',
75
75
  title: 'Mixdog Shell',
76
76
  annotations: { title: 'Mixdog Shell', readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true, compressible: true },
77
- description: `Run programs or change system state; not for reading, listing or searching. Independent calls in one turn run in parallel; order-dependent commands go in one command or later turns.${_shellSyntaxCheat} ${TOOL_ASYNC_EXECUTION_CONTRACT}`,
77
+ description: 'Run programs/change state; not file inspection. Calls in one turn run parallel; '
78
+ + 'combine order-dependent commands. Use async for sleep/watch/dev loops.'
79
+ + `${_shellSyntaxCheat} ${TOOL_ASYNC_EXECUTION_CONTRACT}`,
78
80
  inputSchema: {
79
81
  type: 'object',
80
82
  properties: {
81
83
  command: { type: 'string', description: 'Command.' },
82
84
  cwd: { type: 'string', description: 'Working directory; persists across calls. Omit to reuse; absolute path changes it.' },
83
- timeout: { type: 'number', description: `Timeout ms; default ${_shellDefaultTimeoutMs()}. On sync timeout the command is promoted to a background task_id and keeps running, with any explicit timeout enforced as a background deadline. Sleep-like commands skip promotion: they block the full timeout, then are killed with a [timeout] marker. async runs until done/cancelled unless a timeout is set.` },
85
+ timeout: {
86
+ type: 'number',
87
+ description: `Timeout ms; default ${_shellDefaultTimeoutMs()}. `
88
+ + 'Sync timeout may return task_id; explicit timeout becomes its deadline. '
89
+ + 'Sleeps are killed, not promoted.',
90
+ },
84
91
  merge_stderr: { type: 'boolean', description: 'Merge stderr.' },
85
92
  mode: { type: 'string', enum: ['sync', 'async'], description: executionModeSchemaDescription('sync') },
86
93
  shell: { type: 'string', enum: ['bash', 'powershell'], description: 'Force shell. Windows defaults to PowerShell; bash = Git Bash/POSIX.' },
@@ -110,5 +110,13 @@ export function scrubProviderSecrets(env) {
110
110
  export function scrubRuntimeRootVars(env) {
111
111
  if (!env || typeof env !== 'object') return env;
112
112
  delete env.MIXDOG_ROOT;
113
+ // Host-runtime leak: a packaged Electron/daemon host runs with
114
+ // NODE_ENV=production, and inheriting it silently corrupts model-spawned
115
+ // shells — `npm install` prunes devDependencies, React resolves its
116
+ // production build (no `act`), test suites flip red on developer machines
117
+ // while CI stays green. The value is OUR process's, not the user's OS
118
+ // env, so shells must not see it. A command that needs NODE_ENV sets it
119
+ // explicitly.
120
+ delete env.NODE_ENV;
113
121
  return env;
114
122
  }
@@ -19,14 +19,15 @@ eof_line: "*** End of File" LF
19
19
  %import common.LF
20
20
  `;
21
21
 
22
- const APPLY_PATCH_FREEFORM_DESCRIPTION = 'Use the `apply_patch` tool to edit files. This is a FREEFORM tool, so do not wrap the patch in JSON.';
22
+ const APPLY_PATCH_FREEFORM_DESCRIPTION =
23
+ 'Apply an atomic FREEFORM patch, not JSON. Use exact current context; roll back on failure.';
23
24
 
24
25
  export const PATCH_TOOL_DEFS = [
25
26
  {
26
27
  name: 'apply_patch',
27
28
  title: 'Mixdog Apply Patch',
28
29
  annotations: { title: 'Mixdog Apply Patch', readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false, compressible: false, compressibleLossless: true },
29
- description: 'Apply file edits in one patch; sections run in listed order and all touched paths roll back if any section fails. Do not split a dependent edit across turns.',
30
+ description: 'Apply one atomic patch; roll back all touched paths on failure.',
30
31
  freeformDescription: APPLY_PATCH_FREEFORM_DESCRIPTION,
31
32
  freeform: {
32
33
  type: 'grammar',
@@ -36,7 +37,7 @@ export const PATCH_TOOL_DEFS = [
36
37
  inputSchema: {
37
38
  type: 'object',
38
39
  properties: {
39
- patch: { type: 'string', description: 'Patch text. V4A preferred; one file block per target file, 3 lines of exact context per hunk, @@ anchors when ambiguous; include all new edits in listed order. On failure, the tool rolls all earlier writes back.' },
40
+ patch: { type: 'string', description: 'V4A patch text; use exact current context; atomic rollback.' },
40
41
  format: { type: 'string', enum: ['unified', 'v4a'], description: 'Auto-detected.' },
41
42
  base_path: { type: 'string', description: 'Repo root.' },
42
43
  dry_run: { type: 'boolean', description: 'Default false. true = validate only, no write.' },
@@ -1,5 +1,26 @@
1
1
  {
2
2
  "version": "0.1.0",
3
- "_comment": "Synced from immutable token-v release assets. Empty until the first token-v release is published; the runtime falls back to a local cargo build or the in-process WASM worker.",
4
- "assets": {}
3
+ "_comment": "Synced from immutable token-v release assets.",
4
+ "assets": {
5
+ "darwin-arm64": {
6
+ "url": "https://github.com/tribgames/mixdog/releases/download/token-v0.1.0/mixdog-token-darwin-arm64",
7
+ "sha256": "1d5259803207dd5ace466c506d8db4087460a20c1e3f4f6cc249832a8a996f6b"
8
+ },
9
+ "darwin-x64": {
10
+ "url": "https://github.com/tribgames/mixdog/releases/download/token-v0.1.0/mixdog-token-darwin-x64",
11
+ "sha256": "2212a434a89419ce0a4122ce4647e2feb2b4642e26d3e74e11b4de2819a6c2e3"
12
+ },
13
+ "linux-arm64": {
14
+ "url": "https://github.com/tribgames/mixdog/releases/download/token-v0.1.0/mixdog-token-linux-arm64",
15
+ "sha256": "332839da398050218fbed4b6700a3e34dbdcba800893b9a3ba16ebca3e88b9ee"
16
+ },
17
+ "linux-x64": {
18
+ "url": "https://github.com/tribgames/mixdog/releases/download/token-v0.1.0/mixdog-token-linux-x64",
19
+ "sha256": "ac79201259985705cf378d225a25a779591d1f75fefd626e470b398e3d8fdd9c"
20
+ },
21
+ "win32-x64": {
22
+ "url": "https://github.com/tribgames/mixdog/releases/download/token-v0.1.0/mixdog-token-win32-x64.exe",
23
+ "sha256": "da6e39974811123b3e0c72f539c9e5b424261b66a3f2a30eb0e9cc837036d1ba"
24
+ }
25
+ }
5
26
  }
@@ -877,7 +877,10 @@ export function createMemoryActionHandlers({
877
877
  return { text: `retro_eval_active: total=${total} archived=${archived} kept=${kept} updated=${updated} merged=${merged} errors=${errors}` }
878
878
  }
879
879
 
880
- return { text: `unknown memory action: ${action}`, isError: true }
880
+ return {
881
+ text: `unknown memory action: ${action}; valid: core, status. Mutation verbs belong in op.`,
882
+ isError: true,
883
+ }
881
884
  }
882
885
 
883
886
  const handleToolCall = createToolCallHandler({ handleSearch, handleMemoryAction })
@@ -8,12 +8,24 @@ export const TOOL_DEFS = [
8
8
  name: 'memory',
9
9
  title: 'Memory Cycle',
10
10
  annotations: { title: 'Memory Cycle', readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false },
11
- description: 'Core-memory mutation and status; use recall for retrieval. Store durable rules/preferences/facts, one compact ENGLISH clause each — never transient task state. add requires project_id+summary; edit works by id alone.',
11
+ description: [
12
+ 'Core-memory mutation/status; recall retrieves.',
13
+ 'Mutations use action=core with op; status uses action=status.',
14
+ 'Store only durable compact ENGLISH facts/rules/preferences.',
15
+ ].join(' '),
12
16
  inputSchema: {
13
17
  type: 'object',
14
18
  properties: {
15
- action: { type: 'string', enum: ['core','status'], description: 'Operation.' },
16
- op: { type: 'string', enum: ['add','edit','delete','list','candidates','promote','dismiss'], description: 'Mutation op. candidates/promote/dismiss drive core-memory proposal approval.' },
19
+ action: {
20
+ type: 'string',
21
+ enum: ['core','status'],
22
+ description: 'core for mutations; status otherwise. Mutation verbs belong in op.',
23
+ },
24
+ op: {
25
+ type: 'string',
26
+ enum: ['add','edit','delete','list','candidates','promote','dismiss'],
27
+ description: 'Required for action=core.',
28
+ },
17
29
  id: { type: 'number', description: 'Exact memory id.' },
18
30
  element: { type: 'string', maxLength: 40, description: 'Memory key/title. Defaults to the first 40 chars of summary. Max 40 chars.' },
19
31
  summary: { type: 'string', maxLength: 100, description: 'Memory content: one short English clause, max 100 chars.' },
@@ -22,6 +34,10 @@ export const TOOL_DEFS = [
22
34
  confirm: { type: 'string', description: 'Exact confirmation phrase for destructive actions.' },
23
35
  project_id: { type: 'string', description: 'Core pool: explicit common or slug. Required for core add only (edit uses the id\'s stored pool); there is no default pool.' },
24
36
  },
37
+ anyOf: [
38
+ { properties: { action: { const: 'status' } }, required: ['action'] },
39
+ { properties: { action: { const: 'core' } }, required: ['action', 'op'] },
40
+ ],
25
41
  additionalProperties: false,
26
42
  required: ['action'],
27
43
  },
@@ -407,6 +407,21 @@ export function createLifecycleApi(deps) {
407
407
  prefetchSession(id) {
408
408
  return mgr.prefetchSession?.(id, toolSpecForMode(getMode())) === true;
409
409
  },
410
+ // Read-only session peek (desktop split panes treat every visible pane as
411
+ // foreground): the parsed session object without resuming, attaching, or
412
+ // touching ownership/liveness. The current session returns live memory.
413
+ peekSession(id) {
414
+ const sessionId = clean(id);
415
+ if (!sessionId || !/^[A-Za-z0-9_-]+$/.test(sessionId)) return null;
416
+ const current = getSession();
417
+ if (current?.id === sessionId) return current;
418
+ const session = mgr.loadSession?.(sessionId);
419
+ // `closed` means the previous live owner released this durable session;
420
+ // it is still a valid historical transcript. Desktop pane peeks are
421
+ // strictly read-only, so they may inspect it without resuming or
422
+ // changing ownership. Deleted/tombstoned sessions have no load result.
423
+ return session || null;
424
+ },
410
425
  async resume(id) {
411
426
  clearRoutePreparation?.();
412
427
  const prev = getSession();
@@ -31,6 +31,7 @@ export function createProviderAuthApi({
31
31
  refreshProviderCatalogs,
32
32
  cachedProviderSetup,
33
33
  getUsageDashboard,
34
+ consumeCodexRateLimitResetCredit,
34
35
  collectProviderModels,
35
36
  }) {
36
37
  function refreshProviderCatalogsSoon() {
@@ -79,6 +80,13 @@ export function createProviderAuthApi({
79
80
  async getUsageDashboard(options = {}) {
80
81
  return await getUsageDashboard(options);
81
82
  },
83
+ async consumeCodexRateLimitResetCredit(options = {}) {
84
+ await awaitKeychainPrewarm();
85
+ if (typeof consumeCodexRateLimitResetCredit !== 'function') {
86
+ throw new Error('Codex reset is unavailable');
87
+ }
88
+ return await consumeCodexRateLimitResetCredit(options);
89
+ },
82
90
  async authenticateProvider(providerId, secret) {
83
91
  await awaitKeychainPrewarm();
84
92
  const result = String(secret || '').trim()
@@ -14,6 +14,7 @@ export function createProviderUsage({
14
14
  providerSetup,
15
15
  createUsageDashboard,
16
16
  fetchOAuthUsageSnapshot,
17
+ consumeOpenAICodexResetCredit,
17
18
  isCloseRequested,
18
19
  getProviderSetupWarmupTimer,
19
20
  scheduleProviderSetupWarmup,
@@ -64,8 +65,9 @@ export function createProviderUsage({
64
65
  }
65
66
 
66
67
  async function getUsageDashboard(options = {}) {
67
- const forceSetup = options?.force === true || options?.refresh === true;
68
- if (!forceSetup && caches.usageDashboardCache.dashboard) {
68
+ const refreshUsage = options?.refresh === true;
69
+ const forceSetup = options?.force === true || (refreshUsage && options?.refreshSetup !== false);
70
+ if (!forceSetup && !refreshUsage && caches.usageDashboardCache.dashboard) {
69
71
  const cached = {
70
72
  ...caches.usageDashboardCache.dashboard,
71
73
  refresh: false,
@@ -78,7 +80,7 @@ export function createProviderUsage({
78
80
  }
79
81
  return cached;
80
82
  }
81
- if (!forceSetup && caches.usageDashboardPromise) return await caches.usageDashboardPromise;
83
+ if (!forceSetup && !refreshUsage && caches.usageDashboardPromise) return await caches.usageDashboardPromise;
82
84
  const quickSetup = options?.quickSetup !== false;
83
85
  const getProvider = (providerId) => reg().getProvider(providerId);
84
86
  const log = (message) => {
@@ -98,16 +100,26 @@ export function createProviderUsage({
98
100
  });
99
101
  }
100
102
  const buildDashboard = async () => {
103
+ let setup;
104
+ try {
105
+ setup = await cachedProviderSetup({ force: forceSetup, quick: false });
106
+ } catch {
107
+ // One unavailable keychain/provider descriptor must not take down the
108
+ // whole dashboard. The no-secrets/no-local snapshot still lets cached
109
+ // and provider-native quota windows refresh.
110
+ log('provider setup failed; falling back to quick setup');
111
+ setup = await cachedProviderSetup({ force: forceSetup, quick: true });
112
+ }
101
113
  const dashboard = await createUsageDashboard(displayConfig(), {
102
114
  ...(options || {}),
103
- setup: await cachedProviderSetup({ force: forceSetup, quick: false }),
115
+ setup,
104
116
  getProvider,
105
117
  log,
106
118
  });
107
119
  caches.usageDashboardCache = { dashboard, at: Date.now() };
108
120
  return dashboard;
109
121
  };
110
- if (forceSetup) return await buildDashboard();
122
+ if (forceSetup || refreshUsage) return await buildDashboard();
111
123
  caches.usageDashboardPromise = buildDashboard()
112
124
  .finally(() => {
113
125
  caches.usageDashboardPromise = null;
@@ -115,6 +127,18 @@ export function createProviderUsage({
115
127
  return await caches.usageDashboardPromise;
116
128
  }
117
129
 
130
+ async function consumeCodexRateLimitResetCredit(options = {}) {
131
+ if (typeof consumeOpenAICodexResetCredit !== 'function') {
132
+ throw new Error('Codex reset is unavailable');
133
+ }
134
+ const providerObj = reg().getProvider('openai-oauth');
135
+ if (!providerObj) throw new Error('Codex is not signed in');
136
+ const result = await consumeOpenAICodexResetCredit(providerObj, options);
137
+ caches.usageDashboardCache = {};
138
+ const dashboard = await getUsageDashboard({ refresh: true, refreshSetup: false });
139
+ return { ...result, dashboard };
140
+ }
141
+
118
142
  return {
119
143
  refreshStatuslineUsageSnapshot,
120
144
  cachedProviderSetup,
@@ -122,5 +146,6 @@ export function createProviderUsage({
122
146
  // (settings hydration) can serve the quick snapshot until then.
123
147
  hasProviderSetupCached: () => Boolean(caches.providerSetupCache.setup),
124
148
  getUsageDashboard,
149
+ consumeCodexRateLimitResetCredit,
125
150
  };
126
151
  }