@awak-app/simy-cli 0.6.1 → 0.6.4
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 +14 -0
- package/package.json +2 -2
- package/src/cli-contract.js +1 -1
- package/src/codex-plugins/simy/hooks/README.md +16 -22
- package/src/codex-plugins/simy/hooks/delivery-loop.mjs +76 -101
- package/src/codex-plugins/simy/hooks/outcome-progress.mjs +10 -0
- package/src/codex-plugins/simy/skills/delivery-loop/SKILL.md +1 -1
- package/src/delivery-hooks/command.js +12 -16
- package/src/delivery-hooks/desktop.js +6 -14
- package/src/delivery-hooks/supervisor.js +7 -31
- package/src/desktop-host.js +22 -16
- package/src/hook-runtime.js +29 -0
- package/src/login-shell-path.js +67 -0
- package/src/session-supervision/action-association-host.js +1 -1
- package/src/session-supervision/action-association.js +2 -2
- package/src/session-supervision/codex-app-server.js +4 -20
- package/src/session-supervision/handoff.js +2 -1
- package/src/session-supervision/index.js +11 -16
- package/src/session-supervision/stop-notifications.js +43 -0
- package/src/sqm/agent-skill.js +2 -12
- package/src/sqm/claude-plugin.js +2 -4
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { execFile } from "node:child_process";
|
|
2
2
|
import { promisify } from "node:util";
|
|
3
|
-
import { canReplanDelivery,
|
|
3
|
+
import { canReplanDelivery, deliveryStop, deliveryStopBlocksResume, reconcileRecords, rootDirectory } from '../codex-plugins/simy/hooks/delivery-loop.mjs';
|
|
4
4
|
import { originalSessionTarget } from '../session-supervision/original-session-target.js';
|
|
5
5
|
|
|
6
6
|
export const HOOK_SETUP = Object.freeze({
|
|
@@ -31,17 +31,11 @@ export async function deliveryHookStatus({ root = rootDirectory(), now = Date.no
|
|
|
31
31
|
runs: records.filter(r => r.run).sort((a, b) => b.run.updatedAt - a.run.updatedAt).slice(0, 30).map(r => ({
|
|
32
32
|
provider: r.provider, session: r.session, originalSessionTarget: originalSessionTarget(r.provider, r.session),
|
|
33
33
|
objective: r.run.objective, state: r.run.state, reason: r.run.reason,
|
|
34
|
-
continuations: r.run.continuations,
|
|
35
|
-
localExecutionLimits: false,
|
|
34
|
+
continuations: r.run.continuations,
|
|
36
35
|
continuationPolicy: r.run.managed?.continuationPolicy || 'bounded',
|
|
37
36
|
startedAt: r.run.startedAt,
|
|
38
|
-
|
|
39
|
-
canChangeContinuation: false,
|
|
40
|
-
hardDeadline: null, hardMaxContinuations: null,
|
|
41
|
-
budgetWarnings: deliveryBudgetState(r.run, now).warnings,
|
|
42
|
-
budgetCapability: r.run.managed?.budgetCapability || null,
|
|
37
|
+
stopCode: deliveryStop(r.run, now)?.code ?? null,
|
|
43
38
|
outcomeProgress: r.run.managed?.outcomeProgress || null,
|
|
44
|
-
budgetExtensionHistory: r.run.budgetExtensionHistory || [],
|
|
45
39
|
needsAttention: ['blocked', 'failed', 'recovery_required', 'budget_exhausted', 'verification_pending'].includes(r.run.state), updatedAt: r.run.updatedAt,
|
|
46
40
|
managed: Boolean(r.run.managed), taskId: r.run.managed?.taskId || null, actionId: r.run.managed?.actionId || null,
|
|
47
41
|
actionDueAt: r.run.managed?.actionDueAt ?? null,
|
|
@@ -56,8 +50,6 @@ export async function deliveryHookStatus({ root = rootDirectory(), now = Date.no
|
|
|
56
50
|
handoff: r.run.managed?.handoff || null, threadHistory: r.run.managed?.threadHistory || [],
|
|
57
51
|
progressBudget: r.run.managed ? { minutes: r.run.managed.maxNoProgressMinutes, lastVerifiedProgressAt: r.run.managed.lastVerifiedProgressAt,
|
|
58
52
|
attempts: r.run.managed.noProgress, maxAttempts: r.run.managed.maxNoProgress } : null,
|
|
59
|
-
tokenBudget: r.run.managed?.maxTokens ? { limit: r.run.managed.maxTokens, used: r.run.managed.tokensUsed,
|
|
60
|
-
usageAvailable: r.run.managed.usageAvailable, source: 'provider_observed', dollarCost: null } : null,
|
|
61
53
|
pendingApproval: r.run.managed?.pendingApproval || null,
|
|
62
54
|
autonomyPolicy: r.run.managed?.autonomyPolicy || null, plan: r.run.managed?.plan || null,
|
|
63
55
|
decision: r.run.managed?.decision || null, decisionHistory: r.run.managed?.decisionHistory || [],
|
|
@@ -77,14 +69,14 @@ export async function deliveryHookStatus({ root = rootDirectory(), now = Date.no
|
|
|
77
69
|
r.run.managed.resumeChannel === 'codex_app_server' && r.run.managed.autoRecovery),
|
|
78
70
|
canResume: ['blocked', 'recovery_required', 'paused'].includes(r.run.state) && r.run.managed?.phase !== 'needs_clarification' &&
|
|
79
71
|
!r.run.managed?.pendingApproval && !r.run.managed?.dependencyWait && !['pending', 'pending_received', 'transferred'].includes(r.run.managed?.handoff?.status) &&
|
|
80
|
-
!
|
|
72
|
+
!deliveryStopBlocksResume(r.run, now),
|
|
81
73
|
canApprove: r.run.state === 'blocked' && Boolean(r.run.managed?.pendingApproval) && !r.run.managed.pendingApproval.denied,
|
|
82
74
|
canReject: r.run.state === 'blocked' && Boolean(r.run.managed?.pendingApproval) && !r.run.managed.pendingApproval.denied,
|
|
83
75
|
canReplan: canReplanDelivery(r.run, r.provider, now),
|
|
84
76
|
canAnswer: r.run.managed?.phase === 'needs_clarification' && r.run.state === 'blocked' && Boolean(r.run.managed.understandingQuestion?.id) &&
|
|
85
|
-
!
|
|
77
|
+
!deliveryStopBlocksResume(r.run, now),
|
|
86
78
|
canResolveDependency: r.run.state === 'blocked' && r.run.blocker === 'external_dependency' && Boolean(r.run.managed?.dependencyWait?.id) &&
|
|
87
|
-
!
|
|
79
|
+
!deliveryStopBlocksResume(r.run, now) && !['pending', 'pending_received', 'transferred'].includes(r.run.managed?.handoff?.status),
|
|
88
80
|
canSetPolicy: Boolean(r.run.managed?.autonomyPolicy) && !['completed', 'reported_done', 'stopped', 'failed', 'budget_exhausted'].includes(r.run.state),
|
|
89
81
|
})),
|
|
90
82
|
};
|
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
import { spawn } from 'node:child_process';
|
|
2
2
|
import { randomUUID } from 'node:crypto';
|
|
3
3
|
import { dirname } from 'node:path';
|
|
4
|
-
import { automaticContinuationAllowed, assessedCheckpointContract, checkpointDelivery, decideStop,
|
|
4
|
+
import { automaticContinuationAllowed, assessedCheckpointContract, checkpointDelivery, decideStop, deliveryProgressContext, deliveryStop, listRecords, readRecord, managedBudgetReason, notifyState, processIdentity, reconcileRecords, updateRecord, verifyAutomaticCriteria } from '../codex-plugins/simy/hooks/delivery-loop.mjs';
|
|
5
5
|
import { resolveBackendExecutable } from '../backend-executable.js';
|
|
6
6
|
import { executeCodexAppServer } from '../session-supervision/codex-app-server.js';
|
|
7
7
|
import { acquireWorkspaceLease, leaseOwnerDead } from '../session-supervision/workspace-lease.js';
|
|
8
8
|
|
|
9
9
|
const MAX_OUTPUT = 64 * 1024;
|
|
10
10
|
const SAFE_PROVIDER_CODES = new Set(['identity', 'permission_narrowed', 'permission_restore', 'turn_failed', 'checkpoint',
|
|
11
|
-
'closed', 'timeout', 'unsupported', 'unavailable', 'manual_provider_action', 'checkpoint_rejected', 'provider_approval'
|
|
11
|
+
'closed', 'timeout', 'unsupported', 'unavailable', 'manual_provider_action', 'checkpoint_rejected', 'provider_approval']);
|
|
12
12
|
function safeProviderCode(code) { return SAFE_PROVIDER_CODES.has(code) ? code : 'provider_error'; }
|
|
13
13
|
function providerFailureReason(code) {
|
|
14
14
|
if (code === 'permission_restore') return 'Codex sandbox restoration could not be confirmed; inspect the original conversation before resuming.';
|
|
@@ -40,9 +40,8 @@ async function assertActiveLease(root, provider, session, leaseId, signal, curre
|
|
|
40
40
|
if (signal?.aborted || current?.run?.state !== 'auto_resuming' || current.run.managed?.resumeLease?.id !== leaseId) {
|
|
41
41
|
throw new Error('Managed recovery lease changed before provider dispatch.');
|
|
42
42
|
}
|
|
43
|
-
if (currentTime() >= managedDispatchDeadline(current.run)) throw new Error('Managed recovery deadline expired before provider dispatch.');
|
|
44
43
|
if (!automaticContinuationAllowed(current)) throw new Error('Automatic continuation was disabled before provider dispatch.');
|
|
45
|
-
if (managedBudgetReason(current.run, currentTime())) throw new Error('Managed
|
|
44
|
+
if (managedBudgetReason(current.run, currentTime())) throw new Error('Managed task stopped before provider dispatch.');
|
|
46
45
|
const managed = current.run.managed;
|
|
47
46
|
const approvedWait = managed.decision?.outcome === 'wait' && managed.approvedAction?.id === managed.decision.id && !managed.approvedAction.dispatchedAt;
|
|
48
47
|
if (managed.autonomyPolicy && ((managed.decision?.outcome === 'wait' && !approvedWait) || managed.resumeLease.decisionId !== managed.decision?.id)) throw new Error('Assessed action changed before provider dispatch.');
|
|
@@ -80,13 +79,11 @@ export async function executeCodexResume(record, { signal, root, leaseId, execut
|
|
|
80
79
|
lastDependencyResolution: run.managed.lastDependencyResolution,
|
|
81
80
|
remainingWork: run.managed.remainingWork, acceptanceCriteria: run.managed.acceptanceCriteria, proposedAction: run.managed.proposedAction,
|
|
82
81
|
currentDecision: run.managed.autonomyPolicy ? run.managed.decision : undefined, diagnosis: run.managed.plan?.diagnosis,
|
|
83
|
-
|
|
84
|
-
localExecutionLimits: false } };
|
|
82
|
+
progress: deliveryProgressContext(run) };
|
|
85
83
|
const prompt = run.managed.autonomyPolicy ? `Continue the existing SIMY managed task in this exact session. Context: ${JSON.stringify(context)}\nExecute ONLY currentDecision.action. Its expectedVerification and rollback apply to this turn. Stay within provider permissions. End with ONLY one JSON checkpoint. ${assessedCheckpointContract(run.managed.decision)} Do not write SIMY state directly.` : `Continue the existing SIMY managed task in this exact session. Context: ${JSON.stringify(context)}\nStay within the original task and provider permissions. End with ONLY one JSON object: {"status":"in_progress|completed|blocked|error","summary":"...","nextStep":"...","remainingWork":"...","proposedAction":{"kind":"read|local_edit|local_test|external|destructive|scope_change|unknown","scope":"...","summary":"..."},"evidence":[],"acceptanceMet":false}. For blocked include blocker (authorization, missing_information, external_dependency), and for authorization include approval {kind,scope,summary}. Do not claim completion without checking frozen acceptance. SIMY independently verifies supported criteria. Do not write SIMY state directly. If approval or input is needed, return blocked and stop.`;
|
|
86
84
|
const sandbox = run.managed.autonomyPolicy && run.managed.decision?.action.kind === 'read' ? 'read-only' : run.managed.autoRecoverySandbox;
|
|
87
85
|
const args = ['exec', '--json', '--skip-git-repo-check', '-s', sandbox, '-m', run.managed.resumeModel, 'resume', record.session, prompt];
|
|
88
86
|
if (signal?.aborted) throw new Error('Managed recovery cancelled before provider dispatch.');
|
|
89
|
-
if (currentTime() >= managedDispatchDeadline(run)) throw new Error('Managed recovery deadline expired before provider dispatch.');
|
|
90
87
|
await beforeDispatch();
|
|
91
88
|
if (signal?.aborted) throw new Error('Managed recovery cancelled before provider dispatch.');
|
|
92
89
|
|
|
@@ -196,9 +193,9 @@ export async function tickManagedTasks({ root, now = Date.now(), execute, inspec
|
|
|
196
193
|
const approved = live?.managed?.decision?.outcome === 'wait' && ['read', 'local_edit', 'local_test'].includes(live.managed.decision.action?.kind) &&
|
|
197
194
|
live.managed.approvedAction?.id === live.managed.decision.id && !live.managed.approvedAction.dispatchedAt;
|
|
198
195
|
if (!live?.managed?.autoRecovery || live.state !== 'recovery_required' || !allowedCause || live.managed.pendingApproval || live.managed.autonomyPolicy && (!live.managed.decision || live.managed.decision.outcome === 'wait' && !approved) || appChannel && live.managed.nextAttemptAt > currentTime()) return current;
|
|
199
|
-
const
|
|
200
|
-
if (
|
|
201
|
-
live.state =
|
|
196
|
+
const stop = deliveryStop(live, currentTime());
|
|
197
|
+
if (stop) {
|
|
198
|
+
live.state = stop.code === 'failure_limit' ? 'failed' : 'paused'; live.reason = stop.reason; live.updatedAt = currentTime(); notifyState(live, live.updatedAt); return current;
|
|
202
199
|
}
|
|
203
200
|
live.state = 'auto_resuming'; live.managed.resumeLease = { id: leaseId, claimedAt: currentTime(), checkpointRevision: live.revision, decisionId: live.managed.decision?.id || null,
|
|
204
201
|
owner: { pid: process.pid, identity: ownerIdentity } };
|
|
@@ -217,7 +214,6 @@ export async function tickManagedTasks({ root, now = Date.now(), execute, inspec
|
|
|
217
214
|
let result;
|
|
218
215
|
let resource;
|
|
219
216
|
let lastActivityAt = currentTime();
|
|
220
|
-
let usage = null;
|
|
221
217
|
let dispatched = false, waitingForTool = false;
|
|
222
218
|
const controller = new AbortController();
|
|
223
219
|
const abort = () => controller.abort();
|
|
@@ -262,10 +258,6 @@ export async function tickManagedTasks({ root, now = Date.now(), execute, inspec
|
|
|
262
258
|
const runner = execute || (appServer ? executeCodexAppServer : executeCodexResume);
|
|
263
259
|
result = await runner(claimed, { signal: controller.signal, root, leaseId, currentTime, leaseMonitored: true,
|
|
264
260
|
onActivity: observation => { lastActivityAt = currentTime(); waitingForTool = observation?.waitingForTool === true; },
|
|
265
|
-
onUsage: observed => {
|
|
266
|
-
usage = observed;
|
|
267
|
-
|
|
268
|
-
},
|
|
269
261
|
beforeDispatch: async providerSandbox => {
|
|
270
262
|
await assertActiveLease(root, record.provider, record.session, leaseId, controller.signal, currentTime);
|
|
271
263
|
await updateRecord(root, record.provider, record.session, current => {
|
|
@@ -298,9 +290,6 @@ export async function tickManagedTasks({ root, now = Date.now(), execute, inspec
|
|
|
298
290
|
errorCode: error?.code || 'provider_error', restorationUnverified: error?.restorationUnverified === true }; }
|
|
299
291
|
finally { stopWatch(); signal?.removeEventListener('abort', abort); }
|
|
300
292
|
if (controller.signal.aborted) result = { ...result, code: -2, cancelled: true };
|
|
301
|
-
if (deliveryBudgetState(claimed.run).tokenCeiling && !usage && !result.busy && dispatched) {
|
|
302
|
-
result = { ...result, usageUnavailable: true };
|
|
303
|
-
}
|
|
304
293
|
if (result.code === 0) {
|
|
305
294
|
try {
|
|
306
295
|
await checkpointDelivery({ root, provider: record.provider, session: record.session, cwd: record.cwd,
|
|
@@ -327,14 +316,6 @@ export async function tickManagedTasks({ root, now = Date.now(), execute, inspec
|
|
|
327
316
|
const live = current?.run;
|
|
328
317
|
if (live?.managed?.resumeLease?.id !== leaseId) return current;
|
|
329
318
|
delete live.managed.resumeLease;
|
|
330
|
-
delete live.managed.usageBlocked;
|
|
331
|
-
if (usage) {
|
|
332
|
-
live.managed.tokensUsed = (live.managed.tokensUsed || 0) + usage.tokens;
|
|
333
|
-
live.managed.usageAvailable = true;
|
|
334
|
-
live.managed.lastUsage = { ...usage, attemptId: leaseId };
|
|
335
|
-
} else if (!result.busy && deliveryBudgetState(live).tokenCeiling && dispatched) {
|
|
336
|
-
live.managed.usageAvailable = false; live.managed.usageBlocked = true;
|
|
337
|
-
}
|
|
338
319
|
const attempt = live.managed.executionAttempt;
|
|
339
320
|
if (!result.busy && attempt?.phase === 'dispatching' && attempt.action?.kind !== 'read') live.managed.executionUncertain = {
|
|
340
321
|
attemptId: leaseId, decisionId: attempt.decisionId, action: attempt.action, at: Date.now() };
|
|
@@ -351,13 +332,8 @@ export async function tickManagedTasks({ root, now = Date.now(), execute, inspec
|
|
|
351
332
|
if (live.state !== 'auto_resuming') live.reason = `${String(live.reason || '').slice(0, 400)} ${live.managed.providerWarning}`.trim();
|
|
352
333
|
}
|
|
353
334
|
if (live.state !== 'auto_resuming') {
|
|
354
|
-
if (live.managed.usageBlocked && !['stopped', 'paused', 'completed', 'reported_done'].includes(live.state)) {
|
|
355
|
-
live.managed.autoRecovery = false;
|
|
356
|
-
live.reason = `${live.reason} Provider token usage is unavailable; further budgeted turns are blocked.`;
|
|
357
|
-
}
|
|
358
335
|
live.updatedAt = Date.now(); notifyState(live, live.updatedAt); return current;
|
|
359
336
|
}
|
|
360
|
-
live.budgetWarnings = deliveryBudgetState(live).warnings;
|
|
361
337
|
if (result.busy) {
|
|
362
338
|
if (result.accessDenied && dispatched) {
|
|
363
339
|
// The final guard rejected before the provider call. Keep its one-shot approval unused.
|
package/src/desktop-host.js
CHANGED
|
@@ -9,6 +9,7 @@ import { deliveryHookStatus, openHookSetup } from "./delivery-hooks/desktop.js";
|
|
|
9
9
|
import { controlDelivery, verifyDelivery, createRecordReader } from "./codex-plugins/simy/hooks/delivery-loop.mjs";
|
|
10
10
|
import { createManagedSupervisor } from "./delivery-hooks/supervisor.js";
|
|
11
11
|
import { createActionAssociationHost } from "./session-supervision/action-association-host.js";
|
|
12
|
+
import { createStopNotifier } from "./session-supervision/stop-notifications.js";
|
|
12
13
|
import { answerSession, continueSession, discoverSessions, enrollSession, monitorSession, trackSession, setSessionAutoContinue } from "./session-supervision/index.js";
|
|
13
14
|
import { createContinuationAnalysisManager } from "./session-supervision/analysis-manager.js";
|
|
14
15
|
import { analyzeContinuation, readContinuationContext } from "./session-supervision/continuation-analysis.js";
|
|
@@ -23,7 +24,8 @@ import { dirname, join } from "node:path";
|
|
|
23
24
|
import { homedir } from "node:os";
|
|
24
25
|
import { chmod, mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
25
26
|
import { startAgent } from "./agent.js";
|
|
26
|
-
import { createRuntimeDiagnostics } from "./runtime-diagnostics.js";
|
|
27
|
+
import { createRuntimeDiagnostics, captureRuntimeOutput } from "./runtime-diagnostics.js";
|
|
28
|
+
import { applyLoginShellPath } from "./login-shell-path.js";
|
|
27
29
|
import { createCliAutoUpdater, readCliPackageInfo } from "./auto-update.js";
|
|
28
30
|
import { createAgentShutdown } from "./shutdown.js";
|
|
29
31
|
import { resolveWebOrigin } from "./web-origin.js";
|
|
@@ -94,6 +96,7 @@ export async function startDesktopHost({ input = process.stdin, output = process
|
|
|
94
96
|
const makeActionAssociations = () => (dependencies.createActionAssociationHost || createActionAssociationHost)({ root: join(root, "delivery-hooks"), sessionRoot: root, webOrigin, list: () => recordReader.list(), onChange: () => { if (!closed) setImmediate(() => { void publish().catch(() => {}); }); } });
|
|
95
97
|
let actionAssociations = makeActionAssociations();
|
|
96
98
|
const statisticsCache = new Map();
|
|
99
|
+
const stopNotifier = createStopNotifier({ emit: event => { if (!closed && !relay) emit(event); } });
|
|
97
100
|
let currentAccess = { state: 'checking', organizationId: null, organizationName: null };
|
|
98
101
|
const accessScope = access => JSON.stringify([access?.revision ?? null, access?.organizationId ?? null]);
|
|
99
102
|
const setAccessSnapshot = async access => {
|
|
@@ -104,7 +107,7 @@ export async function startDesktopHost({ input = process.stdin, output = process
|
|
|
104
107
|
discoveryController?.abort();
|
|
105
108
|
sessionRefreshAgain = Boolean(sessionRefresh) && desktopAccessReady(currentAccess);
|
|
106
109
|
for (const item of sessionStatus.sessions) automaticContinuation?.revoke(item.provider, item.session);
|
|
107
|
-
sessionStatus = { sessions: [], providers: {}, refreshedAt: null }; sessionRefreshedAt = 0;
|
|
110
|
+
sessionStatus = { sessions: [], providers: {}, refreshedAt: null, initialLoading: true }; sessionRefreshedAt = 0;
|
|
108
111
|
await analysisManager?.update([]);
|
|
109
112
|
}
|
|
110
113
|
return currentAccess;
|
|
@@ -133,7 +136,7 @@ export async function startDesktopHost({ input = process.stdin, output = process
|
|
|
133
136
|
beforeClaim: record => actionAssociations.prepareCached?.(record) || record });
|
|
134
137
|
let shuttingDown = null;
|
|
135
138
|
let commands;
|
|
136
|
-
let sessionStatus = { sessions: [], providers: { codex: { discovery: "loading", reason: null }, claude: { discovery: "loading", reason: null } }, refreshedAt: null };
|
|
139
|
+
let sessionStatus = { sessions: [], providers: { codex: { discovery: "loading", reason: null }, claude: { discovery: "loading", reason: null } }, refreshedAt: null, initialLoading: true };
|
|
137
140
|
let sessionRefresh = null, sessionRefreshedAt = 0, sessionRefreshAgain = false, discoveryController = null, discoveryStopped = false;
|
|
138
141
|
const stopDiscovery = async ({ drain = false } = {}) => {
|
|
139
142
|
discoveryController?.abort();
|
|
@@ -157,8 +160,8 @@ export async function startDesktopHost({ input = process.stdin, output = process
|
|
|
157
160
|
sessionRefreshAgain = desktopAccessReady(latestAccess);
|
|
158
161
|
return;
|
|
159
162
|
}
|
|
160
|
-
await analysisManager.update(value.sessions); sessionStatus = value; sessionRefreshedAt = Date.now(); })
|
|
161
|
-
.catch(() => { if (closed || relay || controller.signal.aborted) return; sessionStatus = { ...sessionStatus, providers: { codex: { discovery: "unavailable", reason: "Conversation discovery unavailable." }, claude: { discovery: "unavailable", reason: "Conversation discovery unavailable." } } }; sessionRefreshedAt = Date.now(); })
|
|
163
|
+
await analysisManager.update(value.sessions); sessionStatus = { ...value, initialLoading: false }; sessionRefreshedAt = Date.now(); })
|
|
164
|
+
.catch(() => { if (closed || relay || controller.signal.aborted) return; sessionStatus = { ...sessionStatus, initialLoading: false, providers: { codex: { discovery: "unavailable", reason: "Conversation discovery unavailable." }, claude: { discovery: "unavailable", reason: "Conversation discovery unavailable." } } }; sessionRefreshedAt = Date.now(); })
|
|
162
165
|
.finally(() => { if (discoveryController === controller) discoveryController = null; sessionRefresh = null; if (sessionRefreshAgain && !closed && !relay) { sessionRefreshAgain = false; refreshSessions(true); }
|
|
163
166
|
if (!closed) setImmediate(() => { void publish().catch(() => {}); }); });
|
|
164
167
|
}
|
|
@@ -243,6 +246,7 @@ export async function startDesktopHost({ input = process.stdin, output = process
|
|
|
243
246
|
}
|
|
244
247
|
await analysisManager.updateProviderStatuses?.(status.providers);
|
|
245
248
|
const records = await recordReader.list();
|
|
249
|
+
stopNotifier.observe(records);
|
|
246
250
|
const projection = await actionAssociations.beginProjection?.({ records });
|
|
247
251
|
status.deliveryHooks = await (dependencies.deliveryHookStatus || deliveryHookStatus)({ root: deliveryRoot, records: projection?.records || records }).catch(() => ({ error: "Delivery hook status unavailable", providers: {}, runs: [] }));
|
|
248
252
|
await actionAssociations.decorate(status.deliveryHooks, projection);
|
|
@@ -572,9 +576,9 @@ export async function startDesktopHost({ input = process.stdin, output = process
|
|
|
572
576
|
else if (action === "codex_hooks" || action === "claude_hooks") {
|
|
573
577
|
const provider = action === "codex_hooks" ? "codex" : "claude";
|
|
574
578
|
await (dependencies.openHookSettings || openHookSetup)(provider);
|
|
575
|
-
} else if (["delivery_stop", "delivery_resume", "delivery_pause", "delivery_approve", "delivery_reject", "delivery_replan", "delivery_set_policy", "delivery_dependency_ready", "
|
|
579
|
+
} else if (["delivery_stop", "delivery_resume", "delivery_pause", "delivery_approve", "delivery_reject", "delivery_replan", "delivery_set_policy", "delivery_dependency_ready", "delivery_continuation_policy"].includes(action)) {
|
|
576
580
|
await controlDelivery({ root: join(root, "delivery-hooks"), provider: command.provider, session: command.session,
|
|
577
|
-
taskId: command.taskId, approvalId: command.approvalId, autonomyPolicy: command.autonomyPolicy,
|
|
581
|
+
taskId: command.taskId, approvalId: command.approvalId, autonomyPolicy: command.autonomyPolicy, continuationPolicy: command.continuationPolicy, waitId: command.waitId, answer: command.answer, action: action.slice("delivery_".length) });
|
|
578
582
|
} else if (action === "delivery_verify") {
|
|
579
583
|
await verifyDelivery({ root: join(root, "delivery-hooks"), provider: command.provider, session: command.session,
|
|
580
584
|
taskId: command.taskId, criterionId: command.criterionId });
|
|
@@ -590,7 +594,7 @@ export async function startDesktopHost({ input = process.stdin, output = process
|
|
|
590
594
|
}
|
|
591
595
|
if (command.enabled) await authorizeWork(commandScope);
|
|
592
596
|
await (dependencies.setSessionAutoContinue || setSessionAutoContinue)({ root: join(root, 'delivery-hooks'),
|
|
593
|
-
provider: command.provider, session: command.session, enabled: command.enabled, continuationPolicy: command.continuationPolicy
|
|
597
|
+
provider: command.provider, session: command.session, enabled: command.enabled, continuationPolicy: command.continuationPolicy });
|
|
594
598
|
if (!command.enabled) automaticContinuation.revoke(command.provider, command.session);
|
|
595
599
|
// Acknowledge the durable preference before discovery or analysis.
|
|
596
600
|
emit({ type: "result", id: command.id, ok: true });
|
|
@@ -625,8 +629,7 @@ export async function startDesktopHost({ input = process.stdin, output = process
|
|
|
625
629
|
} else if (action === "session_enroll") {
|
|
626
630
|
await (dependencies.enrollSession || enrollSession)({ root: join(root, "delivery-hooks"), provider: command.provider,
|
|
627
631
|
session: command.session, objective: command.objective, acceptanceCriteria: command.acceptanceCriteria,
|
|
628
|
-
autonomyPolicy: command.autonomyPolicy,
|
|
629
|
-
maxContinuations: command.maxContinuations, maxFailures: command.maxFailures, maxNoProgressMinutes: command.maxNoProgressMinutes, maxTokens: command.maxTokens, continuationPolicy: command.continuationPolicy, hardMaxMinutes: command.hardMaxMinutes, hardMaxContinuations: command.hardMaxContinuations, providerTokenCeiling: command.providerTokenCeiling });
|
|
632
|
+
autonomyPolicy: command.autonomyPolicy, maxFailures: command.maxFailures, maxNoProgressMinutes: command.maxNoProgressMinutes, continuationPolicy: command.continuationPolicy });
|
|
630
633
|
await refreshSessions(true);
|
|
631
634
|
} else if (action === "session_analyze") {
|
|
632
635
|
const candidate = await selectedSession(command, { reuseDiscovery: true });
|
|
@@ -638,9 +641,8 @@ export async function startDesktopHost({ input = process.stdin, output = process
|
|
|
638
641
|
await continueAnalyzedSession({ root: join(root, 'delivery-hooks'), candidate, manager,
|
|
639
642
|
readContext: dependencies.readContinuationContext || readContinuationContext,
|
|
640
643
|
enroll: async options => { await authorizeWork(commandScope); return guardedEnroll(options); }, beforeEnroll: () => authorizeWork(commandScope),
|
|
641
|
-
settings: { autonomyPolicy: command.autonomyPolicy,
|
|
642
|
-
|
|
643
|
-
maxNoProgressMinutes: command.maxNoProgressMinutes, maxTokens: command.maxTokens, continuationPolicy: command.continuationPolicy, hardMaxMinutes: command.hardMaxMinutes, hardMaxContinuations: command.hardMaxContinuations, providerTokenCeiling: command.providerTokenCeiling } });
|
|
644
|
+
settings: { autonomyPolicy: command.autonomyPolicy, maxFailures: command.maxFailures,
|
|
645
|
+
maxNoProgressMinutes: command.maxNoProgressMinutes, continuationPolicy: command.continuationPolicy } });
|
|
644
646
|
await refreshSessions(true);
|
|
645
647
|
} else if (action === "session_answer") {
|
|
646
648
|
await (dependencies.answerSession || answerSession)({ root: join(root, "delivery-hooks"), provider: command.provider,
|
|
@@ -735,7 +737,7 @@ export async function openProviderLogin(provider, root, {
|
|
|
735
737
|
const directory = await mkdtemp(join(root, `${provider}-login-`));
|
|
736
738
|
const script = join(directory, `Sign in to ${label}.command`);
|
|
737
739
|
const quote = (s) => `'${s.replaceAll("'", "'\\''")}'`;
|
|
738
|
-
await writeFile(script, `#!/bin/bash\nexport PATH
|
|
740
|
+
await writeFile(script, `#!/bin/bash\nexport PATH=${quote(env.PATH || "/usr/bin:/bin")}\n${quote(executable.executable)} ${args.join(" ")}\nresult=$?\nrm -f -- ${quote(script)}\nrmdir -- ${quote(directory)}\nexit "$result"\n`, { mode: 0o700 });
|
|
739
741
|
await chmod(script, 0o700);
|
|
740
742
|
try { await execFileImpl("/usr/bin/open", ["-a", "Terminal", script], { timeout: 10_000 }); }
|
|
741
743
|
catch (error) { await rm(directory, { recursive: true, force: true }); throw error; }
|
|
@@ -750,8 +752,12 @@ if (isDesktopHostEntry()) {
|
|
|
750
752
|
let host;
|
|
751
753
|
const stop = () => { void host?.close().finally(() => process.exit(0)); };
|
|
752
754
|
process.once("SIGTERM", stop); process.once("SIGINT", stop);
|
|
753
|
-
|
|
754
|
-
|
|
755
|
+
const diagnostics = createRuntimeDiagnostics(process.env.SIMY_HOME?.trim() || join(homedir(), ".simy"), { name: "desktop-host" });
|
|
756
|
+
// stdout is the native JSONL channel; stderr is the only unstructured output
|
|
757
|
+
// and the native window does not display it, so keep it for support.
|
|
758
|
+
captureRuntimeOutput(diagnostics, { stderr: process.stderr });
|
|
759
|
+
applyLoginShellPath({ diagnostics }).catch(() => {}).then(() => startDesktopHost()).then((value) => { host = value; }).catch((error) => {
|
|
760
|
+
diagnostics.write(`startup failed: ${error?.stack || error}`);
|
|
755
761
|
const conflict = error?.code === PROCESS_CONFLICT_CODE;
|
|
756
762
|
process.stdout.write(`${JSON.stringify({
|
|
757
763
|
type: "error",
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
|
|
3
|
+
// Version-specific runtimes are replaced by the stable alias that follows
|
|
4
|
+
// upgrades: the installer's `current` release link and Homebrew's `opt` link.
|
|
5
|
+
const STABLE_RUNTIME_ALIASES = [
|
|
6
|
+
[/^(.*)\/installer\/releases\/[^/]+\/[^/]+\.app\/(Contents\/Resources\/runtime\/bin\/node)$/, "$1/installer/current/$2"],
|
|
7
|
+
[/^(.*)\/Cellar\/([^/]+)\/[^/]+\/(bin\/node)$/, "$1/opt/$2/$3"],
|
|
8
|
+
];
|
|
9
|
+
|
|
10
|
+
// Provider hooks are persisted into Codex/Claude configuration and run long
|
|
11
|
+
// after this process exits, often from GUI apps without the user's shell PATH.
|
|
12
|
+
// They must therefore name an absolute runtime that survives SIMY and Node updates.
|
|
13
|
+
export function stableHookRuntime({ execPath = process.execPath, fileExists = existsSync } = {}) {
|
|
14
|
+
for (const [pattern, replacement] of STABLE_RUNTIME_ALIASES) {
|
|
15
|
+
if (!pattern.test(execPath)) continue;
|
|
16
|
+
const alias = execPath.replace(pattern, replacement);
|
|
17
|
+
if (fileExists(alias)) return alias;
|
|
18
|
+
}
|
|
19
|
+
return execPath;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function hookRuntimeCommand(command, { platform = process.platform, ...options } = {}) {
|
|
23
|
+
if (typeof command !== "string" || !command.startsWith("node ")) return command;
|
|
24
|
+
return `${quoteRuntime(stableHookRuntime(options), platform)} ${command.slice("node ".length)}`;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function quoteRuntime(value, platform) {
|
|
28
|
+
return platform === "win32" ? `"${value.replaceAll('"', '""')}"` : `'${value.replaceAll("'", "'\\''")}'`;
|
|
29
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
|
|
5
|
+
const START = "__SIMY_LOGIN_PATH_START__";
|
|
6
|
+
const END = "__SIMY_LOGIN_PATH_END__";
|
|
7
|
+
|
|
8
|
+
// Apps opened from Finder or the Dock inherit launchd's minimal PATH, not the
|
|
9
|
+
// user's shell profile. Provider CLIs installed through nvm, volta, fnm, asdf,
|
|
10
|
+
// pnpm or custom prefixes are therefore invisible unless we ask the user's own
|
|
11
|
+
// login shell once. A slow or broken profile must never prevent startup.
|
|
12
|
+
export async function resolveLoginShellPath({
|
|
13
|
+
platform = process.platform,
|
|
14
|
+
env = process.env,
|
|
15
|
+
execFileImpl = execFile,
|
|
16
|
+
fileExists = existsSync,
|
|
17
|
+
timeoutMs = 5_000,
|
|
18
|
+
} = {}) {
|
|
19
|
+
if (platform === "win32") return null;
|
|
20
|
+
const shell = loginShell({ platform, env, fileExists });
|
|
21
|
+
if (!shell) return null;
|
|
22
|
+
const output = await new Promise((resolve) => {
|
|
23
|
+
// Read the exported environment rather than expanding $PATH in the shell:
|
|
24
|
+
// fish expands a quoted $PATH with spaces, but exports it colon-separated.
|
|
25
|
+
const child = execFileImpl(shell, ["-l", "-i", "-c", `echo ${START}; /usr/bin/env; echo ${END}`], {
|
|
26
|
+
env: { ...env, TERM: "dumb", DISABLE_AUTO_UPDATE: "true" },
|
|
27
|
+
timeout: timeoutMs,
|
|
28
|
+
killSignal: "SIGKILL",
|
|
29
|
+
maxBuffer: 1024 * 1024,
|
|
30
|
+
}, (_error, stdout) => resolve(String(stdout || "")));
|
|
31
|
+
child?.stdin?.end();
|
|
32
|
+
});
|
|
33
|
+
const start = output.lastIndexOf(START);
|
|
34
|
+
const end = output.indexOf(END, start);
|
|
35
|
+
if (start < 0 || end < 0) return null;
|
|
36
|
+
const line = output.slice(start + START.length, end).split(/\r?\n/).find((item) => item.startsWith("PATH="));
|
|
37
|
+
return line?.slice("PATH=".length) || null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Login-shell entries take priority, matching what the user sees in Terminal.
|
|
41
|
+
// Existing entries (including the launcher's fallback directories) are kept.
|
|
42
|
+
export function mergePathEntries(...values) {
|
|
43
|
+
const entries = [];
|
|
44
|
+
for (const value of values) {
|
|
45
|
+
for (const entry of String(value || "").split(path.delimiter)) {
|
|
46
|
+
if (path.isAbsolute(entry) && !entries.includes(entry)) entries.push(entry);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return entries.join(path.delimiter);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export async function applyLoginShellPath({ env = process.env, platform = process.platform, diagnostics, ...options } = {}) {
|
|
53
|
+
if (platform === "win32") return false;
|
|
54
|
+
const loginPath = await resolveLoginShellPath({ env, platform, ...options });
|
|
55
|
+
if (!loginPath) {
|
|
56
|
+
diagnostics?.write("login shell PATH unavailable; using launcher PATH");
|
|
57
|
+
return false;
|
|
58
|
+
}
|
|
59
|
+
env.PATH = mergePathEntries(loginPath, env.PATH);
|
|
60
|
+
diagnostics?.write(`login shell PATH applied (${env.PATH.split(path.delimiter).length} entries)`);
|
|
61
|
+
return true;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function loginShell({ platform, env, fileExists }) {
|
|
65
|
+
const candidates = [env.SHELL, platform === "darwin" ? "/bin/zsh" : null, "/bin/sh"];
|
|
66
|
+
return candidates.find((candidate) => candidate && path.isAbsolute(candidate) && fileExists(candidate)) || null;
|
|
67
|
+
}
|
|
@@ -160,7 +160,7 @@ export function createActionAssociationHost({ root, sessionRoot, webOrigin, api
|
|
|
160
160
|
if (closed) return;
|
|
161
161
|
const managed = record.run.managed, job = managed.actionAssociation?.job;
|
|
162
162
|
if (job && !managed.resumeLease && (!expected || TERMINAL.has(record.run.state) || ['paused', 'blocked'].includes(record.run.state) || record.run.state === 'recovery_required' && record.run.recoveryCause !== 'session_supervision' ||
|
|
163
|
-
managedBudgetReason(record.run, now()) ||
|
|
163
|
+
managedBudgetReason(record.run, now()) ||
|
|
164
164
|
managed.understandingRevision !== job.understandingRevision || record.run.revision !== job.runRevision || managed.actionAssociation.invalidated)) {
|
|
165
165
|
await cancelActionAssociation({ root, ...identity(record), expectedJobId: job.id, now: now() });
|
|
166
166
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto';
|
|
2
|
-
import { listRecords, updateRecord, TERMINAL } from '../codex-plugins/simy/hooks/delivery-loop.mjs';
|
|
2
|
+
import { listRecords, managedBudgetReason, updateRecord, TERMINAL } from '../codex-plugins/simy/hooks/delivery-loop.mjs';
|
|
3
3
|
import { associationSignals, associationRevision } from '../codex-plugins/simy/hooks/action-association.mjs';
|
|
4
4
|
export { associationSignals, associationRevision, associationContract, validateAssociationResult } from '../codex-plugins/simy/hooks/action-association.mjs';
|
|
5
5
|
|
|
@@ -25,7 +25,7 @@ export async function queueActionAssociation({ root, provider, session, taskId,
|
|
|
25
25
|
await assertScope(validate);
|
|
26
26
|
const run = record?.run, managed = run?.managed;
|
|
27
27
|
if (!managed || managed.taskId !== taskId || run.revision !== expectedRevision) return record;
|
|
28
|
-
if (!stable(run) ||
|
|
28
|
+
if (!stable(run) || managedBudgetReason(run, now)) {
|
|
29
29
|
if (!managed.actionAssociation?.job && !managed.actionAssociation?.outbox && !managed.actionAssociation?.manualOverride) managed.actionAssociation = { ...managed.actionAssociation, status: 'waiting_boundary', retryOn: 'original_session_safe_boundary', updatedAt: now };
|
|
30
30
|
return record;
|
|
31
31
|
}
|
|
@@ -2,7 +2,7 @@ import { associationContract } from '../codex-plugins/simy/hooks/action-associat
|
|
|
2
2
|
import { spawn } from 'node:child_process';
|
|
3
3
|
import { realpath } from 'node:fs/promises';
|
|
4
4
|
import { CODEX_WRITER_LOCK_MINIMUM_VERSION, compareVersions, inspectBackendExecutable } from '../backend-executable.js';
|
|
5
|
-
import { assessedCheckpointContract,
|
|
5
|
+
import { assessedCheckpointContract, deliveryProgressContext, understandingCheckpointContract } from '../codex-plugins/simy/hooks/delivery-loop.mjs';
|
|
6
6
|
import { boundConversation, boundProviderGoal, textContent } from './conversation-context.js';
|
|
7
7
|
import { JsonDataUrlFilter } from './json-data-url-filter.js';
|
|
8
8
|
|
|
@@ -394,7 +394,7 @@ export function isCodexWriterBusy(error) {
|
|
|
394
394
|
return error instanceof CodexAppServerError && error.code === -32600 && /already has an active writer/i.test(error.message);
|
|
395
395
|
}
|
|
396
396
|
|
|
397
|
-
export async function executeCodexAppServer(record, { openClient = openCodexAppServer, signal, beforeDispatch = async () => {}, onActivity = () => {}
|
|
397
|
+
export async function executeCodexAppServer(record, { openClient = openCodexAppServer, signal, beforeDispatch = async () => {}, onActivity = () => {} } = {}) {
|
|
398
398
|
const run = record.run, decision = run.managed?.decision;
|
|
399
399
|
if (!decision || !run.managed.autonomyPolicy) throw new CodexAppServerError('decision', 'An assessed action is required before Codex dispatch.');
|
|
400
400
|
if (!['read', 'local_edit', 'local_test'].includes(decision.action?.kind)) throw new CodexAppServerError('manual_provider_action', 'This action must be completed in the original provider conversation.');
|
|
@@ -405,7 +405,6 @@ export async function executeCodexAppServer(record, { openClient = openCodexAppS
|
|
|
405
405
|
const client = await openClient({ signal });
|
|
406
406
|
let originalSandbox = null, overrideStarted = false, activeTurnId = null, turnCompleted = false, turnStart = null;
|
|
407
407
|
let primaryError = null;
|
|
408
|
-
let usage = null, lastTotal = null;
|
|
409
408
|
try {
|
|
410
409
|
const metadata = await readCodexSession(client, record.session);
|
|
411
410
|
if (await realpath(metadata.cwd) !== record.cwd || metadata.model !== run.managed.resumeModel) throw new CodexAppServerError('identity', 'Codex session workspace or model changed.');
|
|
@@ -422,8 +421,7 @@ export async function executeCodexAppServer(record, { openClient = openCodexAppS
|
|
|
422
421
|
enrolledScopes: run.managed.approvalScopes,
|
|
423
422
|
lastDependencyResolution: run.managed.lastDependencyResolution,
|
|
424
423
|
...(authorizedWait ? { approvedAction: { approvalId: approved.id, action: approved.action, oneShot: true } } : {}),
|
|
425
|
-
|
|
426
|
-
localExecutionLimits: false } };
|
|
424
|
+
progress: deliveryProgressContext(run) };
|
|
427
425
|
const approvalInstruction = authorizedWait ? `SIMY recorded a matching, one-shot scoped user approval ${approved.id} for this exact selected action. The decision outcome "wait" describes its pre-approval assessment; the approved action is authorized now. Execute it once, then report what actually happened. ` : '';
|
|
428
426
|
const prompt = run.managed.phase === 'action_association' ? associationContract(run.managed.actionAssociation.job) : run.managed.phase === 'understanding' ? `Understand the ORIGINAL user request in this exact Codex conversation. This first/clarifying turn is read-only and must not perform task work. Context: ${JSON.stringify({ ...context, priorUnderstanding: run.managed.sessionUnderstanding, userAnswer: run.managed.understandingAnswer })}\n${understandingCheckpointContract(decision)} Do not write SIMY state directly.` : `Continue this existing SIMY managed task in the exact Codex conversation. Context: ${JSON.stringify(context)}\n${approvalInstruction}Execute ONLY the selected decision action. Do not perform any action from the NEXT plan in this turn. For a next local action within the enrolled task use the exact scope "task"; do not relabel external, broad, or destructive work as local. Preserve provider permissions. End with one JSON checkpoint. ${assessedCheckpointContract(decision)} Do not write SIMY state directly.`;
|
|
429
427
|
const initialSandbox = run.managed.providerSandbox || resumed.sandbox;
|
|
@@ -439,21 +437,12 @@ export async function executeCodexAppServer(record, { openClient = openCodexAppS
|
|
|
439
437
|
let turnId = null, completion = null, settled = false;
|
|
440
438
|
const messages = new Map();
|
|
441
439
|
const activeTools = new Set();
|
|
442
|
-
const earlyUsage = [];
|
|
443
440
|
const earlyActivity = [];
|
|
444
441
|
const observeActivity = (method, params) => {
|
|
445
442
|
if (method === 'item/started' && ['commandExecution', 'mcpToolCall', 'webSearch', 'fileChange'].includes(params.item?.type)) activeTools.add(params.item.id);
|
|
446
443
|
if (method === 'item/completed') activeTools.delete(params.item?.id);
|
|
447
444
|
onActivity({ waitingForTool: activeTools.size > 0 });
|
|
448
445
|
};
|
|
449
|
-
const observeUsage = params => {
|
|
450
|
-
const total = params.tokenUsage?.total?.totalTokens, last = params.tokenUsage?.last?.totalTokens;
|
|
451
|
-
if (!Number.isSafeInteger(total) || total < 0 || !Number.isSafeInteger(last) || last < 0 || last > total) return;
|
|
452
|
-
if (lastTotal === null) usage = { source: 'codex_app_server', tokens: last, observedAt: Date.now() };
|
|
453
|
-
else usage.tokens += Math.max(0, total - lastTotal);
|
|
454
|
-
lastTotal = Math.max(lastTotal ?? 0, total);
|
|
455
|
-
onUsage({ ...usage });
|
|
456
|
-
};
|
|
457
446
|
const cleanup = () => { unsubscribe(); signal?.removeEventListener('abort', cancelled); client.child.removeListener('close', closed); };
|
|
458
447
|
const finish = (error, value) => { if (settled) return; settled = true; cleanup(); if (error) reject(error); else resolve(value); };
|
|
459
448
|
const cancelled = () => finish(new CodexAppServerError('cancelled', 'Codex turn was cancelled.'));
|
|
@@ -474,10 +463,6 @@ export async function executeCodexAppServer(record, { openClient = openCodexAppS
|
|
|
474
463
|
if (turnId) observeActivity(method, params);
|
|
475
464
|
} else if (!turnId && typeof params.turnId === 'string' && earlyActivity.length < 100) earlyActivity.push({ method, turnId: params.turnId,
|
|
476
465
|
item: { id: params.item?.id, type: params.item?.type } });
|
|
477
|
-
if (method === 'thread/tokenUsage/updated') {
|
|
478
|
-
if (turnId && params.turnId === turnId) observeUsage(params);
|
|
479
|
-
else if (!turnId && earlyUsage.length < 100) earlyUsage.push({ turnId: params.turnId, tokenUsage: params.tokenUsage });
|
|
480
|
-
}
|
|
481
466
|
if (method === 'item/completed' && params.item?.type === 'agentMessage' && typeof params.item.text === 'string' && typeof params.turnId === 'string') messages.set(params.turnId, params.item.text);
|
|
482
467
|
if (method === 'turn/completed') { completion = params; if (params.turn?.id === turnId) turnCompleted = true; complete(); }
|
|
483
468
|
});
|
|
@@ -487,14 +472,13 @@ export async function executeCodexAppServer(record, { openClient = openCodexAppS
|
|
|
487
472
|
turnStart = client.request('turn/start', { threadId: record.session, input: [{ type: 'text', text: prompt }], cwd: record.cwd,
|
|
488
473
|
model: run.managed.resumeModel, sandboxPolicy })
|
|
489
474
|
.then(response => { turnId = response?.turn?.id; activeTurnId = turnId; if (!turnId) finish(new CodexAppServerError('turn_failed', 'Codex did not start a turn.')); else {
|
|
490
|
-
for (const params of earlyUsage) if (params.turnId === turnId) observeUsage(params);
|
|
491
475
|
for (const params of earlyActivity) if (params.turnId === turnId) observeActivity(params.method, params);
|
|
492
476
|
complete();
|
|
493
477
|
} })
|
|
494
478
|
.catch(error => finish(error));
|
|
495
479
|
});
|
|
496
480
|
const checkpoint = await done;
|
|
497
|
-
return { code: 0, checkpoint
|
|
481
|
+
return { code: 0, checkpoint };
|
|
498
482
|
} catch (error) {
|
|
499
483
|
primaryError = error;
|
|
500
484
|
throw error;
|
|
@@ -12,7 +12,8 @@ function unsettled(managed) {
|
|
|
12
12
|
}
|
|
13
13
|
function exactSource(record, taskId, targetSession, now, allowStoppedPending = false) {
|
|
14
14
|
if (!record?.run?.managed || !record.admission || record.admission.session !== record.session || record.admission.cwd !== record.cwd || record.run.managed.taskId !== taskId) throw new Error('Exact managed task is unavailable.');
|
|
15
|
-
|
|
15
|
+
const stopReason = now === undefined ? null : managedBudgetReason(record.run, now);
|
|
16
|
+
if (stopReason) throw new Error(`${stopReason} Resume it in its original conversation before handing it off.`);
|
|
16
17
|
const prior = record.run.managed.handoff;
|
|
17
18
|
const transfer = prior?.status === 'received' ? null : prior;
|
|
18
19
|
if (transfer && transfer.targetSession !== targetSession) throw new Error('A different handoff is already recorded.');
|
|
@@ -144,7 +144,7 @@ export async function discoverSessions({ root, openClient = openCodexAppServer,
|
|
|
144
144
|
}
|
|
145
145
|
|
|
146
146
|
// Explicit execution authority: legacy monitor/tracked records stay read-only.
|
|
147
|
-
export async function setSessionAutoContinue({ root, provider, session, enabled, continuationPolicy,
|
|
147
|
+
export async function setSessionAutoContinue({ root, provider, session, enabled, continuationPolicy, ...options }) {
|
|
148
148
|
if (!['codex', 'claude'].includes(provider) || typeof enabled !== 'boolean') throw new Error('Unsupported automatic continuation request.');
|
|
149
149
|
if (enabled) await trackSession({ root, provider, session, tracked: true, ...options });
|
|
150
150
|
return updateRecord(root, provider, session, async previous => {
|
|
@@ -155,18 +155,13 @@ export async function setSessionAutoContinue({ root, provider, session, enabled,
|
|
|
155
155
|
if (enabled && provider === 'claude') await requireClaudeChannel(previous,
|
|
156
156
|
{ session, cwd: previous.cwd, observation: { source: previous.admission?.source } }, options.inspectProcess || processIdentity);
|
|
157
157
|
const epoch = previous.autoContinue === enabled && previous.autoContinueEpoch ? previous.autoContinueEpoch : randomUUID();
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
...(
|
|
163
|
-
...(providerTokenCeiling === undefined ? {} : { providerTokenCeiling }) };
|
|
158
|
+
// Settings persisted by older versions may carry removed time, turn and
|
|
159
|
+
// token caps; only the fields that still govern a task are forwarded.
|
|
160
|
+
const { autonomyPolicy = 'balanced', maxFailures = 3, continuationPolicy: savedPolicy } = previous.autoContinueSettings || {};
|
|
161
|
+
const settings = { autonomyPolicy, maxFailures,
|
|
162
|
+
...(continuationPolicy ?? savedPolicy) === undefined ? {} : { continuationPolicy: continuationPolicy ?? savedPolicy } };
|
|
164
163
|
if (settings.continuationPolicy !== undefined && !['bounded', 'progress'].includes(settings.continuationPolicy)) throw new Error('Choose a continuation policy.');
|
|
165
|
-
|
|
166
|
-
if (settings[key] !== undefined && (!Number.isSafeInteger(settings[key]) || settings[key] < 1 || settings[key] > maximum)) throw new Error('Invalid continuation ceiling.');
|
|
167
|
-
}
|
|
168
|
-
if (providerTokenCeiling !== undefined && provider !== 'codex') throw new Error('Token ceilings require host-observed Codex usage.');
|
|
169
|
-
if (previous.run && [continuationPolicy, hardMaxMinutes, hardMaxContinuations, providerTokenCeiling].some(value => value !== undefined)) throw new Error('Change an existing task through its explicit continuation controls.');
|
|
164
|
+
if (previous.run && continuationPolicy !== undefined) throw new Error('Change an existing task through its explicit continuation controls.');
|
|
170
165
|
const next = { ...previous, autoContinue: enabled, autoContinueEpoch: epoch,
|
|
171
166
|
...(enabled ? { tracked: true, monitor: true, autoContinueSettings: settings } : { tracked: false, monitor: false }) };
|
|
172
167
|
// Adopt existing supervision without restarting it or renewing its budgets.
|
|
@@ -224,7 +219,7 @@ export async function monitorSession({ root, provider, session, monitor = true,
|
|
|
224
219
|
}
|
|
225
220
|
|
|
226
221
|
export async function enrollSession({ root, provider, session, objective, acceptanceCriteria, contextBootstrap = false, autonomyPolicy = 'balanced',
|
|
227
|
-
|
|
222
|
+
maxFailures = 3, maxNoProgressMinutes, continuationPolicy, autoContinueEpoch, openClient = openCodexAppServer,
|
|
228
223
|
readClaude = readClaudeSession, inspectProcess = processIdentity, now = Date.now() }) {
|
|
229
224
|
if (!['codex', 'claude'].includes(provider)) throw new Error('This provider does not have a verified exact-session enrollment channel.');
|
|
230
225
|
if (typeof objective !== 'string' || !objective.trim() || objective.length > 4000) throw new Error('A concrete objective is required.');
|
|
@@ -249,13 +244,13 @@ export async function enrollSession({ root, provider, session, objective, accept
|
|
|
249
244
|
const admission = provider === 'codex' ? { source: 'codex_app_server', provider, session, cwd, model: metadata.model } : undefined;
|
|
250
245
|
const liveRecord = provider === 'claude' ? await readRecord(root, provider, session) : null;
|
|
251
246
|
const run = await startDelivery({ root, provider, session, cwd, objective: objective.trim(), nextStep: 'Inspect current evidence and propose the next assessed action.',
|
|
252
|
-
|
|
247
|
+
maxFailures, continuationPolicy, now, admission, inspectProcess, autoContinueEpoch,
|
|
253
248
|
liveHookAdmission: liveRecord ? { hookStartedAt: liveRecord.hookStartedAt, pid: liveRecord.process.pid, identity: liveRecord.process.identity } : undefined,
|
|
254
|
-
managed: { acceptanceCriteria, contextBootstrap, autonomyPolicy, maxNoProgressMinutes,
|
|
249
|
+
managed: { acceptanceCriteria, contextBootstrap, autonomyPolicy, maxNoProgressMinutes, approvalScopes: ['task'], approvalPolicy: 'wait',
|
|
255
250
|
initialAction: initialPlan.alternatives[0].action, initialPlan, remainingWork: objective.trim(),
|
|
256
251
|
autoRecovery: provider === 'codex', resumeChannel: provider === 'codex' ? 'codex_app_server' : null,
|
|
257
252
|
resumeModel: metadata.model } });
|
|
258
|
-
return { provider, session, taskId: run.run.managed.taskId, state: run.run.state
|
|
253
|
+
return { provider, session, taskId: run.run.managed.taskId, state: run.run.state };
|
|
259
254
|
}
|
|
260
255
|
|
|
261
256
|
export async function continueSession(options) {
|