@nowcrew/daemon 0.6.64 → 0.6.65
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/dist/agent-ability/materializer.js +20 -1
- package/dist/agent-ability/runtime.js +3 -0
- package/dist/automatic-install-target.js +15 -10
- package/dist/computer-cli.js +10 -1
- package/dist/computer-service.js +41 -1
- package/dist/config.js +44 -0
- package/dist/daemon-migration-controller.js +16 -18
- package/dist/execution-runner.js +3 -1
- package/dist/local-execution-prompt.js +14 -9
- package/dist/local-executor.js +56 -3
- package/dist/local-memory/index-budget.js +74 -0
- package/dist/local-memory/pending.js +111 -0
- package/dist/local-memory/runtime.js +87 -0
- package/dist/local-memory/types.js +1 -0
- package/dist/local-memory/write.js +448 -0
- package/dist/machine-info.js +3 -2
- package/dist/main.js +83 -0
- package/dist/managed-service-lifecycle.js +47 -2
- package/dist/managed-service-startup.js +2 -0
- package/dist/profile-layout.js +10 -4
- package/dist/project-skills/controller.js +2 -0
- package/dist/prompt.js +19 -1
- package/dist/runner.js +3 -0
- package/dist/runtimes/codex-app-server-runner.js +54 -14
- package/dist/runtimes/codex-command-safety.js +75 -0
- package/dist/runtimes/codex-contract.js +20 -5
- package/dist/runtimes/codex-turn-retry.js +69 -0
- package/dist/runtimes/codex.js +4 -0
- package/dist/serve.js +68 -3
- package/dist/storage-config.js +175 -0
- package/dist/storage-control.js +193 -0
- package/dist/storage-copy.js +609 -0
- package/dist/storage-credential-guard.js +21 -0
- package/dist/storage-doctor.js +228 -0
- package/dist/storage-identity.js +29 -0
- package/dist/storage-layout.js +143 -0
- package/dist/storage-manifest.js +855 -0
- package/dist/storage-migration-controller.js +417 -0
- package/dist/storage-migration-manifest.js +105 -0
- package/dist/storage-migration-policy.js +100 -0
- package/dist/storage-runtime-controller.js +339 -0
- package/dist/storage-runtime-readiness.js +184 -0
- package/dist/storage-runtime.js +39 -0
- package/dist/storage-safe-fs.js +302 -0
- package/dist/storage-serve-adapter.js +65 -0
- package/dist/storage-status-delivery.js +209 -0
- package/dist/storage-status-replay.js +125 -0
- package/dist/storage-target-confirmation.js +129 -0
- package/dist/storage-worker-command.js +27 -0
- package/dist/storage-worker-executor.js +384 -0
- package/dist/storage-worker-handoff.js +151 -0
- package/dist/storage-worker-operation.js +284 -0
- package/dist/storage-worker-status.js +105 -0
- package/dist/supervised-runtime.js +3 -0
- package/dist/workspace.js +24 -1
- package/package.json +1 -1
package/dist/main.js
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
*/
|
|
8
8
|
import { parseArgs } from "node:util";
|
|
9
9
|
import { homedir } from "node:os";
|
|
10
|
+
import { join } from "node:path";
|
|
10
11
|
import { loadConfig, ConfigError } from "./config.js";
|
|
11
12
|
import { detectDaemonLang, formatDaemonText, translateDaemon } from "./i18n.js";
|
|
12
13
|
import { cliVersion, daemonVersion } from "./machine-info.js";
|
|
@@ -21,7 +22,18 @@ import { formatDaemonStartupError } from "./daemon-startup-error.js";
|
|
|
21
22
|
import { assertManagedServiceStartup } from "./managed-service-startup.js";
|
|
22
23
|
import { daemonGlobalInstallation } from "./daemon-installation.js";
|
|
23
24
|
import { acquireDaemonInstallationLease, runWithDaemonInstallationLease, } from "./daemon-installation-lease.js";
|
|
25
|
+
import { loadStorageRuntime } from "./storage-runtime.js";
|
|
26
|
+
import { readStorageMigrationReceipt } from "./storage-migration-controller.js";
|
|
27
|
+
import { storageMigrationStatusReplay } from "./storage-status-replay.js";
|
|
28
|
+
import { runStorageWorkerCommand } from "./storage-worker-command.js";
|
|
29
|
+
import { probeStorageMigrationRuntime } from "./storage-runtime-readiness.js";
|
|
30
|
+
import { createStorageTargetConfirmationHandler } from "./storage-target-confirmation.js";
|
|
24
31
|
async function main() {
|
|
32
|
+
const workerResult = await runStorageWorkerCommand(process.argv.slice(2));
|
|
33
|
+
if (workerResult !== null) {
|
|
34
|
+
process.exitCode = workerResult;
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
25
37
|
const computerResult = await runComputerCommand(process.argv.slice(2));
|
|
26
38
|
if (computerResult !== null) {
|
|
27
39
|
process.exitCode = computerResult;
|
|
@@ -57,8 +69,10 @@ async function main() {
|
|
|
57
69
|
// 不把 machine token 放进 argv / plist / systemd unit / scheduled task。
|
|
58
70
|
if (values["daemon-home"])
|
|
59
71
|
process.env.CREW_DAEMON_HOME = values["daemon-home"];
|
|
72
|
+
let activeDaemonHome;
|
|
60
73
|
if (values.profile) {
|
|
61
74
|
const home = daemonHome();
|
|
75
|
+
activeDaemonHome = home;
|
|
62
76
|
const profile = await loadProfile(values.profile, home);
|
|
63
77
|
if (cmd === "serve") {
|
|
64
78
|
await assertProfileAgentsRootUnique(profile, home, homedir());
|
|
@@ -71,6 +85,12 @@ async function main() {
|
|
|
71
85
|
agentsRoot: resolveAgentsRoot(profile.agentsRoot, homedir(), process.platform),
|
|
72
86
|
nodePath: process.execPath,
|
|
73
87
|
entryPath: builtDaemonEntry(),
|
|
88
|
+
environment: {
|
|
89
|
+
...(process.env.NOWWORK_HOME === undefined ? {} : { NOWWORK_HOME: process.env.NOWWORK_HOME }),
|
|
90
|
+
...(process.env.NOWWORK_ENV_TAG === undefined ? {} : { NOWWORK_ENV_TAG: process.env.NOWWORK_ENV_TAG }),
|
|
91
|
+
...(process.env.NOWWORK_STORAGE_POLICY_PATH === undefined
|
|
92
|
+
? {} : { NOWWORK_STORAGE_POLICY_PATH: process.env.NOWWORK_STORAGE_POLICY_PATH }),
|
|
93
|
+
},
|
|
74
94
|
});
|
|
75
95
|
}
|
|
76
96
|
applyProfileToEnv(profile, process.env);
|
|
@@ -100,9 +120,72 @@ async function main() {
|
|
|
100
120
|
const installationLease = installation === null
|
|
101
121
|
? null
|
|
102
122
|
: await acquireDaemonInstallationLease(installation.npmPrefix);
|
|
123
|
+
const storageRuntime = values.profile === undefined
|
|
124
|
+
? null
|
|
125
|
+
: await loadStorageRuntime({
|
|
126
|
+
profile: values.profile,
|
|
127
|
+
serverUrl: config.serverUrl,
|
|
128
|
+
userHome: homedir(),
|
|
129
|
+
platform: process.platform,
|
|
130
|
+
environment: process.env,
|
|
131
|
+
});
|
|
132
|
+
const migrationRuntime = values.profile === undefined || activeDaemonHome === undefined || storageRuntime !== null
|
|
133
|
+
? { ready: false, reason: "not_legacy_source" }
|
|
134
|
+
: await probeStorageMigrationRuntime({
|
|
135
|
+
profile: values.profile,
|
|
136
|
+
serverUrl: config.serverUrl,
|
|
137
|
+
currentDaemonHome: activeDaemonHome,
|
|
138
|
+
currentAgentsRoot: config.agentsRoot,
|
|
139
|
+
userHome: homedir(),
|
|
140
|
+
platform: process.platform,
|
|
141
|
+
...(process.getuid === undefined ? {} : { uid: process.getuid() }),
|
|
142
|
+
environment: process.env,
|
|
143
|
+
});
|
|
144
|
+
const targetConfirmation = storageRuntime?.identity.storageMigrationId === undefined
|
|
145
|
+
? null
|
|
146
|
+
: createStorageTargetConfirmationHandler({
|
|
147
|
+
backupsRoot: storageRuntime.local.backupsRoot,
|
|
148
|
+
identity: storageRuntime.identity,
|
|
149
|
+
profile: storageRuntime.installation.profile,
|
|
150
|
+
completionTimeoutMs: 180_000,
|
|
151
|
+
});
|
|
152
|
+
const storageMigration = storageRuntime !== null
|
|
153
|
+
? {
|
|
154
|
+
ready: false,
|
|
155
|
+
statusOutboxPath: join(storageRuntime.local.stateRoot, "storage-status-outbox.json"),
|
|
156
|
+
identity: storageRuntime.identity,
|
|
157
|
+
...(targetConfirmation === null ? {} : { confirm: targetConfirmation.handle }),
|
|
158
|
+
...(storageRuntime.identity.storageMigrationId === undefined ? {} : {
|
|
159
|
+
replayStatus: async (send) => {
|
|
160
|
+
const migrationId = storageRuntime.identity.storageMigrationId;
|
|
161
|
+
const receipt = await readStorageMigrationReceipt(storageRuntime.local.backupsRoot, migrationId);
|
|
162
|
+
if (receipt === null)
|
|
163
|
+
throw new Error(`storage migration receipt not found: ${migrationId}`);
|
|
164
|
+
for (const frame of storageMigrationStatusReplay(receipt, {
|
|
165
|
+
migrationId,
|
|
166
|
+
environment: storageRuntime.identity.storageEnvironment,
|
|
167
|
+
workspaceId: storageRuntime.identity.storageWorkspaceId,
|
|
168
|
+
profile: storageRuntime.installation.profile,
|
|
169
|
+
}))
|
|
170
|
+
send(frame);
|
|
171
|
+
},
|
|
172
|
+
}),
|
|
173
|
+
}
|
|
174
|
+
: migrationRuntime.ready
|
|
175
|
+
? {
|
|
176
|
+
ready: true,
|
|
177
|
+
statusOutboxPath: migrationRuntime.runtime.statusOutboxPath,
|
|
178
|
+
identity: migrationRuntime.runtime.identity,
|
|
179
|
+
controller: migrationRuntime.runtime.controller,
|
|
180
|
+
attach: migrationRuntime.runtime.attach,
|
|
181
|
+
bindServerIdentity: migrationRuntime.runtime.bindServerIdentity,
|
|
182
|
+
replayStatus: migrationRuntime.runtime.replayStatus,
|
|
183
|
+
}
|
|
184
|
+
: undefined;
|
|
103
185
|
await runWithDaemonInstallationLease(installationLease, async () => {
|
|
104
186
|
const service = serve(config, {
|
|
105
187
|
...(values.profile === undefined ? {} : { profileName: values.profile }),
|
|
188
|
+
...(storageMigration === undefined ? {} : { storageMigration }),
|
|
106
189
|
});
|
|
107
190
|
await runServeLifecycle(service);
|
|
108
191
|
});
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
2
|
import { acquireDaemonInstallationLease } from "./daemon-installation-lease.js";
|
|
3
|
-
import { installService, ensureServiceEnabled, readServiceDescriptor, serviceAction, uninstallService, } from "./computer-service.js";
|
|
3
|
+
import { installService, ensureServiceEnabled, readServiceDescriptor, serviceAction, replaceInstalledServiceDescriptor, uninstallService, } from "./computer-service.js";
|
|
4
4
|
import { ManagedServiceConflictError, ManagedServiceRegistryError, assertRegistryCoversDescriptors, listManagedServiceDescriptorPaths, managedServiceIdentityMatches, managedServiceConflict, normalizeManagedServiceRecord, serviceDescriptorSha256, withManagedServiceRegistryLock, } from "./managed-service-registry.js";
|
|
5
5
|
function requirePortableManagedSpec(spec) {
|
|
6
6
|
if ((spec.platform !== "darwin" && spec.platform !== "linux")
|
|
@@ -35,6 +35,17 @@ function sameManagedIdentity(left, right) {
|
|
|
35
35
|
const { generation: _generation, createdAt: _createdAt, ...expected } = right;
|
|
36
36
|
return managedServiceIdentityMatches(left, expected);
|
|
37
37
|
}
|
|
38
|
+
function sameManagedResource(left, right) {
|
|
39
|
+
const { generation: _leftGeneration, createdAt: _leftCreatedAt, descriptorSha256: _leftHash, ...leftResource } = left;
|
|
40
|
+
const { generation: _rightGeneration, createdAt: _rightCreatedAt, descriptorSha256: _rightHash, ...rightResource } = right;
|
|
41
|
+
return JSON.stringify(leftResource) === JSON.stringify(rightResource);
|
|
42
|
+
}
|
|
43
|
+
function descriptorDiffIsStorageEnvironmentOnly(previous, next) {
|
|
44
|
+
if (!next.includes("\n <key>EnvironmentVariables</key>\n"))
|
|
45
|
+
return false;
|
|
46
|
+
const strip = (descriptor) => descriptor.replace(/\n <key>EnvironmentVariables<\/key>\n <dict>\n(?: <key>(?:NOWWORK_HOME|NOWWORK_ENV_TAG|NOWWORK_STORAGE_POLICY_PATH)<\/key><string>[^<]*<\/string>\n)+ <\/dict>\n/u, "\n");
|
|
47
|
+
return strip(previous) === strip(next);
|
|
48
|
+
}
|
|
38
49
|
function assertRecordDescribesSpec(input) {
|
|
39
50
|
requirePortableManagedSpec(input.spec);
|
|
40
51
|
const { spec, record } = input;
|
|
@@ -51,8 +62,20 @@ function assertRecordDescribesSpec(input) {
|
|
|
51
62
|
}
|
|
52
63
|
}
|
|
53
64
|
export async function installManagedService(input) {
|
|
54
|
-
assertRecordDescribesSpec(input);
|
|
55
65
|
requirePortableManagedSpec(input.spec);
|
|
66
|
+
const requestedDescriptor = input.spec.descriptor;
|
|
67
|
+
const expectedDescriptorHash = serviceDescriptorSha256(input.spec.descriptor);
|
|
68
|
+
const expectedPath = normalizeManagedServiceRecord({
|
|
69
|
+
...input.record,
|
|
70
|
+
descriptorPath: input.spec.descriptorPath,
|
|
71
|
+
descriptorSha256: expectedDescriptorHash,
|
|
72
|
+
}).descriptorPath;
|
|
73
|
+
if (input.record.platform !== input.spec.platform
|
|
74
|
+
|| input.record.serviceId !== input.spec.id
|
|
75
|
+
|| input.record.profile !== input.spec.profile
|
|
76
|
+
|| input.record.descriptorPath !== expectedPath) {
|
|
77
|
+
throw new ManagedServiceRegistryError(`Managed service record does not describe '${input.spec.id}'`);
|
|
78
|
+
}
|
|
56
79
|
const acquire = input.acquireInstallationLease ?? acquireDaemonInstallationLease;
|
|
57
80
|
await withManagedServiceRegistryLock(input.registry ?? {}, async ({ services, replace }) => {
|
|
58
81
|
const exact = services.find((record) => sameManagedIdentity(record, input.record));
|
|
@@ -76,6 +99,28 @@ export async function installManagedService(input) {
|
|
|
76
99
|
}
|
|
77
100
|
return;
|
|
78
101
|
}
|
|
102
|
+
const registered = services.find((record) => record.serviceId === input.record.serviceId
|
|
103
|
+
&& record.profile === input.record.profile
|
|
104
|
+
&& record.daemonHome === input.record.daemonHome);
|
|
105
|
+
if (registered !== undefined && sameManagedResource(registered, input.record)) {
|
|
106
|
+
const current = await readServiceDescriptor(input.spec);
|
|
107
|
+
if (current === null || serviceDescriptorSha256(current) !== registered.descriptorSha256) {
|
|
108
|
+
throw new ManagedServiceRegistryError(`Installed descriptor for '${input.spec.id}' does not match its registry record`);
|
|
109
|
+
}
|
|
110
|
+
if (!descriptorDiffIsStorageEnvironmentOnly(current, requestedDescriptor)) {
|
|
111
|
+
throw new ManagedServiceRegistryError(`Installed descriptor for '${input.spec.id}' conflicts with the requested identity`);
|
|
112
|
+
}
|
|
113
|
+
await replaceInstalledServiceDescriptor(input.spec, current, input.runner);
|
|
114
|
+
try {
|
|
115
|
+
await replace(services.map((record) => record === registered ? input.record : record));
|
|
116
|
+
}
|
|
117
|
+
catch (error) {
|
|
118
|
+
const rollbackSpec = { ...input.spec, descriptor: current };
|
|
119
|
+
await replaceInstalledServiceDescriptor(rollbackSpec, requestedDescriptor, input.runner).catch(() => undefined);
|
|
120
|
+
throw error;
|
|
121
|
+
}
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
79
124
|
const conflict = managedServiceConflict(services, input.record);
|
|
80
125
|
if (conflict !== null) {
|
|
81
126
|
throw new ManagedServiceConflictError(conflict.resource, input.record, conflict.owner);
|
|
@@ -14,6 +14,7 @@ export async function assertManagedServiceStartup(input) {
|
|
|
14
14
|
nodePath: input.nodePath,
|
|
15
15
|
entryPath: input.entryPath,
|
|
16
16
|
profileHome: input.daemonHome,
|
|
17
|
+
...(input.environment === undefined ? {} : { environment: input.environment }),
|
|
17
18
|
});
|
|
18
19
|
const readRegistry = input.readManagedServices
|
|
19
20
|
?? (() => readManagedServiceRegistry({ userHome: input.userHome }));
|
|
@@ -42,6 +43,7 @@ export async function assertManagedServiceStartup(input) {
|
|
|
42
43
|
nodePath: input.nodePath,
|
|
43
44
|
entryPath: input.entryPath,
|
|
44
45
|
profileHome: input.daemonHome,
|
|
46
|
+
...(input.environment === undefined ? {} : { environment: input.environment }),
|
|
45
47
|
legacyServiceId: true,
|
|
46
48
|
});
|
|
47
49
|
const legacyDescriptor = await readDescriptor(legacySpec);
|
package/dist/profile-layout.js
CHANGED
|
@@ -2,6 +2,10 @@ import { posix, win32 } from "node:path";
|
|
|
2
2
|
export const PROFILE_LAYOUT_VERSION = 1;
|
|
3
3
|
const PROFILE_NAME = /^[a-z0-9][a-z0-9_-]{0,47}$/u;
|
|
4
4
|
const LOCAL_INSTANCE_NAME = /^[a-z0-9][a-z0-9_-]{0,39}$/u;
|
|
5
|
+
/** Accept the legacy `in` tag at the boundary, but never persist it in a new layout. */
|
|
6
|
+
export function normalizeProfileEnvironment(value) {
|
|
7
|
+
return value === "in" ? "production" : value;
|
|
8
|
+
}
|
|
5
9
|
export function validateLocalInstanceName(value) {
|
|
6
10
|
const normalized = value.trim().toLowerCase();
|
|
7
11
|
if (!LOCAL_INSTANCE_NAME.test(normalized) || normalized.includes("..")) {
|
|
@@ -10,19 +14,21 @@ export function validateLocalInstanceName(value) {
|
|
|
10
14
|
return normalized;
|
|
11
15
|
}
|
|
12
16
|
export function profileForLocalInstance(environment, localInstanceName) {
|
|
13
|
-
const
|
|
17
|
+
const canonicalEnvironment = normalizeProfileEnvironment(environment);
|
|
18
|
+
const profile = `nw-${canonicalEnvironment}-${validateLocalInstanceName(localInstanceName)}`;
|
|
14
19
|
if (!PROFILE_NAME.test(profile))
|
|
15
20
|
throw new Error("derived daemon profile exceeds 48 characters");
|
|
16
21
|
return profile;
|
|
17
22
|
}
|
|
18
23
|
export function profileLayout(input) {
|
|
24
|
+
const environment = normalizeProfileEnvironment(input.environment);
|
|
19
25
|
const localInstanceName = validateLocalInstanceName(input.localInstanceName);
|
|
20
|
-
const profile = profileForLocalInstance(
|
|
26
|
+
const profile = profileForLocalInstance(environment, localInstanceName);
|
|
21
27
|
const paths = input.platform === "win32" ? win32 : posix;
|
|
22
|
-
const base = `${
|
|
28
|
+
const base = `${environment}-${localInstanceName}`;
|
|
23
29
|
return Object.freeze({
|
|
24
30
|
layoutVersion: PROFILE_LAYOUT_VERSION,
|
|
25
|
-
environment
|
|
31
|
+
environment,
|
|
26
32
|
localInstanceName,
|
|
27
33
|
profile,
|
|
28
34
|
daemonHome: paths.join(input.userHome, ".crew", `daemon-${base}`),
|
|
@@ -183,6 +183,8 @@ export function createProjectSkillsController(deps) {
|
|
|
183
183
|
const parsed = ProjectCommandSchema.safeParse(input);
|
|
184
184
|
if (!parsed.success)
|
|
185
185
|
return { ok: false, error: "invalid_project_command" };
|
|
186
|
+
if (deps.isAdmissionClosed?.() === true)
|
|
187
|
+
return { ok: false, error: "storage_migration_quiescing" };
|
|
186
188
|
try {
|
|
187
189
|
assertCurrentEpoch(epochToken);
|
|
188
190
|
}
|
package/dist/prompt.js
CHANGED
|
@@ -305,7 +305,7 @@ ${memoryTaskReadRule}
|
|
|
305
305
|
- **跨任务协调看实时清单**:用 \`crew task list --mine\` 查你当前所有任务及其状态(谁在排队、谁在评审)。例:你一次只能测一个页面时,据此判断"正在测 #1、#2 排队",在 #2 的工作日志里标注"等 #1 完成"。
|
|
306
306
|
- 需要更多背景时再 \`Read $CREW_HOME/notes/<topic>.md\`;不要一次把所有笔记读进上下文。
|
|
307
307
|
|
|
308
|
-
${communicationSection}
|
|
308
|
+
${communicationSection}${ctx.collaborationToolsEnabled === true ? buildCollaborationToolsPrompt() : ""}
|
|
309
309
|
|
|
310
310
|
CLI 成功时打印人类可读文本到 stdout;失败时 stderr 给出错误,并用**退出码**告诉你下一步:
|
|
311
311
|
- \`4\` = 任务已被他人认领 / 不可认领 → 停手,别抢,转做别的。
|
|
@@ -385,3 +385,21 @@ export function buildOriginDecisionRetryPrompt(channelId, threadId) {
|
|
|
385
385
|
"普通内部消息不算外部回复决策;--send-draft 也不是外部回复开关。",
|
|
386
386
|
].join("\n");
|
|
387
387
|
}
|
|
388
|
+
/**
|
|
389
|
+
* Codex 原生 collaboration.* 工具的用法与边界(spec 2d;2026-08-28 对 Codex 0.149.1/0.150.1 实测)。
|
|
390
|
+
* 只在 daemon 开关打开且 runtime 为 codex 时注入;是本机 runtime 能力事实,不是产品策略。
|
|
391
|
+
* 契约来自实测,不编造 Codex 未声明的返回字段。
|
|
392
|
+
*/
|
|
393
|
+
export function buildCollaborationToolsPrompt() {
|
|
394
|
+
return `
|
|
395
|
+
|
|
396
|
+
## Codex 原生协作工具
|
|
397
|
+
本机 Codex 已开启 \`collaboration.*\` 子 agent 工具。它们完全在 Codex 进程内运行:子 agent 的工作对 NowWork 的频道、线程、任务、execution 记录和按 agent 的 token 统计**都不可见**,token 并入你本轮的用量。只用于你自己会汇总的内部交叉核对、并行调研;绝不用于人类或其它 NowWork 成员需要观察、审计或接手的事。
|
|
398
|
+
- **\`collaboration.spawn_agent\`** —— 必填 \`task_name\`(仅小写字母/数字/下划线)、\`message\`;可选 \`fork_turns\`(\`"none"\`|\`"all"\`|\`"3"\` 这样的轮数,默认 \`"all"\`)、\`model\`、\`reasoning_effort\`。立即启动独立 turn,你不阻塞;子 agent 跑完后 idle 而非销毁。新 agent 用 **canonical 任务名路径**引用,如 \`/root/api_review\`。含 root 最多 4 个活跃 agent。
|
|
399
|
+
- **\`collaboration.followup_task\`** —— 必填 \`target\`(canonical 路径,不是 UUID)、\`message\`。异步投递并把 idle 的目标唤醒进新 turn,可重复。
|
|
400
|
+
- **\`collaboration.send_message\`** —— 必填 \`target\`、\`message\`。只入队,**不**启动 turn;要触发工作用 followup_task。
|
|
401
|
+
- **\`collaboration.list_agents\`** —— 可选 \`path_prefix\`。只读,列出 live agents。
|
|
402
|
+
- **\`collaboration.wait_agent\`** —— 可选 \`timeout_ms\`(10000–3600000,默认 30000)。阻塞你直到任一子 agent 有更新、有新输入或超时。
|
|
403
|
+
- **没有任何返回值有保证的 schema**:不要去解析返回值里的 \`agent_id\`/\`id\`/\`accepted\` 之类字段;用 \`list_agents\` 和 \`wait_agent\` 获取状态。
|
|
404
|
+
- **不要调用 \`collaboration.interrupt_agent\`**:它会把你自己这轮 turn 一起中断(2026-08-10 线上事故),中断后本轮结果全部丢失且不会重跑。让子 agent 自然跑完或直接忽略其结果。`;
|
|
405
|
+
}
|
package/dist/runner.js
CHANGED
|
@@ -58,6 +58,7 @@ export async function runAgent(config, input, onActivity = defaultPrint, onConso
|
|
|
58
58
|
...(input.wakeOrigin ? { wakeOrigin: input.wakeOrigin } : {}),
|
|
59
59
|
memory: capMemoryForInject(workspace.memory),
|
|
60
60
|
...(resuming ? {} : { workLog: capWorkLogForInject(workspace.workLog) }),
|
|
61
|
+
collaborationToolsEnabled: config.codexCollaborationTools && runtime === "codex",
|
|
61
62
|
}),
|
|
62
63
|
wakePrompt: ({ resuming, rotatedForBudget, nearBudget }) => {
|
|
63
64
|
const distillHint = nearBudget
|
|
@@ -74,6 +75,7 @@ export async function runAgent(config, input, onActivity = defaultPrint, onConso
|
|
|
74
75
|
name: runtime,
|
|
75
76
|
...(providerConfig.model ? { model: providerConfig.model } : {}),
|
|
76
77
|
...(reasoning.success ? { reasoning: reasoning.data } : {}),
|
|
78
|
+
codexCollaborationTools: config.codexCollaborationTools,
|
|
77
79
|
},
|
|
78
80
|
effectivePermission: config.dangerous ? "full_access" : "workspace_write",
|
|
79
81
|
captureFinal: true,
|
|
@@ -105,6 +107,7 @@ export async function runAgent(config, input, onActivity = defaultPrint, onConso
|
|
|
105
107
|
softTokens: config.sessionSoftTokens,
|
|
106
108
|
maxTurns: config.sessionMaxTurns,
|
|
107
109
|
},
|
|
110
|
+
localMemoryV2Enabled: config.localMemoryV2Handles?.has(input.handle) === true,
|
|
108
111
|
}, {
|
|
109
112
|
onActivity,
|
|
110
113
|
onConsole,
|
|
@@ -6,7 +6,8 @@ import { pathToFileURL } from "node:url";
|
|
|
6
6
|
import spawn from "cross-spawn";
|
|
7
7
|
import { z } from "zod";
|
|
8
8
|
import { signalSupervisorTree } from "../execution-supervisor.js";
|
|
9
|
-
import { assertCodexNativeWorkspaceVersion, assertCodexProjectWorkspaceResponse, assertCodexSkillsListResponse, boundedCodexPlan, codexProjectWorkspaceUnsupported, } from "./codex-contract.js";
|
|
9
|
+
import { assertCodexCollaborationVersion, assertCodexNativeWorkspaceVersion, assertCodexProjectWorkspaceResponse, assertCodexSkillsListResponse, boundedCodexPlan, codexProjectWorkspaceUnsupported, } from "./codex-contract.js";
|
|
10
|
+
import { decideTurnRetry, itemMakesTurnUnsafe } from "./codex-turn-retry.js";
|
|
10
11
|
import { startFirstProgressWatchdog } from "./progress-watchdog.js";
|
|
11
12
|
const RunnerInputSchema = z.object({
|
|
12
13
|
systemPrompt: z.string().min(1),
|
|
@@ -20,6 +21,7 @@ const RunnerInputSchema = z.object({
|
|
|
20
21
|
cwd: z.string().min(1).optional(),
|
|
21
22
|
workspaceRoots: z.array(z.string().min(1)).optional(),
|
|
22
23
|
skillRoots: z.array(z.string().min(1)).optional(),
|
|
24
|
+
codexCollaborationTools: z.boolean().optional(),
|
|
23
25
|
resume: z.boolean(),
|
|
24
26
|
}).strict();
|
|
25
27
|
const RPC_TIMEOUT_MS = 30_000;
|
|
@@ -37,12 +39,17 @@ const MAX_TRANSIENT_TURN_RETRIES = 2;
|
|
|
37
39
|
const TRANSIENT_TURN_RETRY_DELAY_MS = 15_000;
|
|
38
40
|
const TRANSIENT_TURN_RETRY_BACKOFF_FACTOR = 3;
|
|
39
41
|
const PROCESS_TREE_STOP_TIMEOUT_MS = 1_000;
|
|
42
|
+
/** 进程级:一旦某个 collabAgentToolCall 的 item/completed 没有对应 item/started,本执行后续永不重试(spec R4-3)。 */
|
|
43
|
+
let collabStartedMissing = false;
|
|
40
44
|
const USER_INPUT_UNAVAILABLE_MESSAGE = "NowWork cannot collect interactive user input in this headless run; ask the user in the final response instead of assuming an answer.";
|
|
41
|
-
export function codexAppServerArgs(
|
|
45
|
+
export function codexAppServerArgs(input) {
|
|
42
46
|
return [
|
|
43
|
-
...(projectRootMarkers === undefined
|
|
47
|
+
...(input.projectRootMarkers === undefined
|
|
44
48
|
? []
|
|
45
|
-
: ["-c", `project_root_markers=${JSON.stringify(projectRootMarkers)}`]),
|
|
49
|
+
: ["-c", `project_root_markers=${JSON.stringify(input.projectRootMarkers)}`]),
|
|
50
|
+
// multi_agent 按开关;multi_agent_v2 无条件关,钉死已验证的 v1 契约(spec 2b)。
|
|
51
|
+
"-c", `features.multi_agent=${input.collaborationTools === true}`,
|
|
52
|
+
"-c", "features.multi_agent_v2=false",
|
|
46
53
|
"app-server", "--listen", "stdio://",
|
|
47
54
|
];
|
|
48
55
|
}
|
|
@@ -53,7 +60,8 @@ const TRANSIENT_TURN_ERROR_PATTERNS = [
|
|
|
53
60
|
/too many requests/i,
|
|
54
61
|
];
|
|
55
62
|
/** 模型侧瞬态失败(网关过载/限流)→ 换个时间整轮重试大概率成功。
|
|
56
|
-
* interrupted
|
|
63
|
+
* interrupted 单独归为 collab_interrupted:2026-08-10 实锤是模型自己调 collaboration.interrupt_agent
|
|
64
|
+
* 连带把父 turn 置为 interrupted。两类原因都只在本 turn 无白名单外工具活动时重试(codex-turn-retry.ts)。 */
|
|
57
65
|
export function isTransientTurnFailure(detail) {
|
|
58
66
|
return TRANSIENT_TURN_ERROR_PATTERNS.some((pattern) => pattern.test(detail));
|
|
59
67
|
}
|
|
@@ -413,7 +421,10 @@ async function stopChildTree(child) {
|
|
|
413
421
|
}
|
|
414
422
|
async function runCodexAppServerAttempt(bin, input, attempt, initializeTimeoutMs) {
|
|
415
423
|
const attemptStartedAt = Date.now();
|
|
416
|
-
const child = spawn(bin, codexAppServerArgs(
|
|
424
|
+
const child = spawn(bin, codexAppServerArgs({
|
|
425
|
+
...(input.projectRootMarkers === undefined ? {} : { projectRootMarkers: input.projectRootMarkers }),
|
|
426
|
+
...(input.codexCollaborationTools === undefined ? {} : { collaborationTools: input.codexCollaborationTools }),
|
|
427
|
+
}), {
|
|
417
428
|
cwd: process.cwd(),
|
|
418
429
|
env: process.env,
|
|
419
430
|
stdio: ["pipe", "pipe", "pipe"],
|
|
@@ -434,6 +445,8 @@ async function runCodexAppServerAttempt(bin, input, attempt, initializeTimeoutMs
|
|
|
434
445
|
let threadId = null;
|
|
435
446
|
let announcedThreadId = null;
|
|
436
447
|
let turnId = null;
|
|
448
|
+
let turnHasUnsafeSideEffect = false;
|
|
449
|
+
const startedItemIds = new Set();
|
|
437
450
|
let lastUsage;
|
|
438
451
|
let completionResolve;
|
|
439
452
|
let completionReject;
|
|
@@ -452,6 +465,20 @@ async function runCodexAppServerAttempt(bin, input, attempt, initializeTimeoutMs
|
|
|
452
465
|
|| method === "item/agentMessage/delta" || method === "item/completed") {
|
|
453
466
|
firstProgress.observe();
|
|
454
467
|
}
|
|
468
|
+
if (method === "item/started" || method === "item/completed") {
|
|
469
|
+
const item = params !== null && typeof params === "object"
|
|
470
|
+
? params.item
|
|
471
|
+
: undefined;
|
|
472
|
+
if (method === "item/started" && typeof item?.id === "string")
|
|
473
|
+
startedItemIds.add(item.id);
|
|
474
|
+
if (method === "item/completed" && item?.type === "collabAgentToolCall"
|
|
475
|
+
&& (typeof item.id !== "string" || !startedItemIds.has(item.id)) && !collabStartedMissing) {
|
|
476
|
+
collabStartedMissing = true;
|
|
477
|
+
process.stderr.write("[codex-app-server] collab_item_started_missing\n");
|
|
478
|
+
}
|
|
479
|
+
if (itemMakesTurnUnsafe(item))
|
|
480
|
+
turnHasUnsafeSideEffect = true;
|
|
481
|
+
}
|
|
455
482
|
for (const event of mapCodexNotification(method, params)) {
|
|
456
483
|
const eventThreadId = typeof event.thread_id === "string" ? event.thread_id : null;
|
|
457
484
|
if (event.type === "thread.started" && eventThreadId === announcedThreadId)
|
|
@@ -496,6 +523,8 @@ async function runCodexAppServerAttempt(bin, input, attempt, initializeTimeoutMs
|
|
|
496
523
|
if (input.workspaceRoots !== undefined || input.skillRoots !== undefined) {
|
|
497
524
|
assertCodexNativeWorkspaceVersion(initialized);
|
|
498
525
|
}
|
|
526
|
+
if (input.codexCollaborationTools === true)
|
|
527
|
+
assertCodexCollaborationVersion(initialized);
|
|
499
528
|
logStage(activeStage, "ok", attempt, stageStartedAt);
|
|
500
529
|
rpc.notify("initialized");
|
|
501
530
|
activeStage = input.resume ? "thread_resume" : "thread_start";
|
|
@@ -580,15 +609,19 @@ async function runCodexAppServerAttempt(bin, input, attempt, initializeTimeoutMs
|
|
|
580
609
|
}),
|
|
581
610
|
});
|
|
582
611
|
if (completed.turn?.status === "completed")
|
|
583
|
-
return { code: 0, initializeTimedOut: false };
|
|
612
|
+
return { code: 0, initializeTimedOut: false, unsafeToRetry: false };
|
|
584
613
|
const detail = completed.turn?.error?.message ?? `turn status ${completed.turn?.status ?? "unknown"}`;
|
|
585
614
|
process.stderr.write(sanitizeCodexDiagnostic(`Codex turn failed: ${detail.slice(0, ERROR_MESSAGE_CAP)}\n`, secrets));
|
|
615
|
+
const status = completed.turn?.status;
|
|
616
|
+
const retryReason = cancelling ? undefined
|
|
617
|
+
: status === "interrupted" ? "collab_interrupted"
|
|
618
|
+
: status === "failed" && isTransientTurnFailure(detail) ? "transient_turn_failure"
|
|
619
|
+
: undefined;
|
|
586
620
|
return {
|
|
587
|
-
code:
|
|
621
|
+
code: status === "interrupted" ? 130 : 1,
|
|
588
622
|
initializeTimedOut: false,
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
&& isTransientTurnFailure(detail),
|
|
623
|
+
...(retryReason === undefined ? {} : { retryReason }),
|
|
624
|
+
unsafeToRetry: turnHasUnsafeSideEffect || collabStartedMissing,
|
|
592
625
|
};
|
|
593
626
|
}
|
|
594
627
|
catch (error) {
|
|
@@ -601,7 +634,7 @@ async function runCodexAppServerAttempt(bin, input, attempt, initializeTimeoutMs
|
|
|
601
634
|
logStage(activeStage, "error", attempt, stageStartedAt);
|
|
602
635
|
}
|
|
603
636
|
process.stderr.write(`Codex app-server execution failed: ${safeErrorMessage(error, secrets)}\n`);
|
|
604
|
-
return { code: 1, initializeTimedOut };
|
|
637
|
+
return { code: 1, initializeTimedOut, unsafeToRetry: false };
|
|
605
638
|
}
|
|
606
639
|
finally {
|
|
607
640
|
firstProgress.stop();
|
|
@@ -633,13 +666,20 @@ export async function runCodexAppServer(bin, options = {}) {
|
|
|
633
666
|
process.stderr.write(`[codex-app-server] stage=retry status=start attempt=${attempt + 1} elapsed_ms=0 reason=initialize_timeout\n`);
|
|
634
667
|
continue;
|
|
635
668
|
}
|
|
636
|
-
|
|
669
|
+
const decision = decideTurnRetry(result, turnRetries, MAX_TRANSIENT_TURN_RETRIES);
|
|
670
|
+
if (decision.kind === "retry") {
|
|
637
671
|
const delayMs = turnRetryDelayMs * TRANSIENT_TURN_RETRY_BACKOFF_FACTOR ** turnRetries;
|
|
638
672
|
turnRetries += 1;
|
|
639
|
-
process.stderr.write(`[codex-app-server] stage=retry status=start attempt=${attempt + 1} elapsed_ms=0 reason
|
|
673
|
+
process.stderr.write(`[codex-app-server] stage=retry status=start attempt=${attempt + 1} elapsed_ms=0 reason=${decision.reason}\n`);
|
|
640
674
|
await new Promise((resolve) => setTimeout(resolve, delayMs));
|
|
641
675
|
continue;
|
|
642
676
|
}
|
|
677
|
+
if (decision.kind === "skipped") {
|
|
678
|
+
process.stderr.write(`[codex-app-server] stage=retry status=skipped attempt=${attempt} reason=${decision.reason}_unsafe\n`);
|
|
679
|
+
}
|
|
680
|
+
else if (decision.kind === "exhausted") {
|
|
681
|
+
process.stderr.write(`[codex-app-server] stage=retry status=exhausted attempt=${attempt} reason=${decision.reason}\n`);
|
|
682
|
+
}
|
|
643
683
|
return result.code;
|
|
644
684
|
}
|
|
645
685
|
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Codex app-server 重试门控用的「只读 crew 命令」判定(spec Part 1,R3-1/R4-1)。
|
|
3
|
+
*
|
|
4
|
+
* 白名单 + 封闭文法,不是黑名单:任何解析不确定都返回 false。只回答"这是不是白名单里某条
|
|
5
|
+
* crew 只读命令的一次干净调用",不做"读/写"语义判断。任何非 crew 命令(sed/rg/git/python…)
|
|
6
|
+
* 一律 false——白名单之外的一切都视为不可安全重试。零 IO,可独立单测。
|
|
7
|
+
*/
|
|
8
|
+
/** 封闭列表,由 cli/src/run.ts 命令面反推;新增 CLI 只读命令时同步维护。漏加只会更保守。 */
|
|
9
|
+
export const READ_ONLY_CREW_COMMANDS = new Set([
|
|
10
|
+
"whoami",
|
|
11
|
+
"server info",
|
|
12
|
+
"message read",
|
|
13
|
+
"message check",
|
|
14
|
+
"thread read",
|
|
15
|
+
"task list",
|
|
16
|
+
"schedule list",
|
|
17
|
+
"schedule runs",
|
|
18
|
+
"reminder list",
|
|
19
|
+
"reminder log",
|
|
20
|
+
"resolve",
|
|
21
|
+
"channel members",
|
|
22
|
+
"integration env",
|
|
23
|
+
"integration list",
|
|
24
|
+
]);
|
|
25
|
+
const SINGLE_VERB = new Set(["whoami", "resolve"]);
|
|
26
|
+
// 一层 shell 包装:bash/sh/zsh -c / -lc / -cl,后跟被单引号或双引号整体包裹的正文。
|
|
27
|
+
const WRAPPER = /^(?:\/usr\/bin\/|\/bin\/)?(?:bash|sh|zsh)\s+-(?:l?c|cl)\s+(.+)$/su;
|
|
28
|
+
// 整串禁字符:出现任一即拒绝(含单个 &、引号、$、括号、换行、制表、反斜杠)。
|
|
29
|
+
const FORBIDDEN = /[&;|<>$`(){}\\'"\n\r\t]/u;
|
|
30
|
+
const OPTION_TOKEN = /^--?[A-Za-z][A-Za-z0-9-]*(?:=[A-Za-z0-9._:/@#%+-]+)?$/u;
|
|
31
|
+
const VALUE_TOKEN = /^[A-Za-z0-9._:/@#%+][A-Za-z0-9._:/@#%+-]+$/u;
|
|
32
|
+
function unwrap(command) {
|
|
33
|
+
const match = WRAPPER.exec(command);
|
|
34
|
+
if (match === null)
|
|
35
|
+
return command;
|
|
36
|
+
const quoted = match[1] ?? "";
|
|
37
|
+
const quote = quoted[0];
|
|
38
|
+
if (quote !== "'" && quote !== "\"")
|
|
39
|
+
return null;
|
|
40
|
+
if (quoted.length < 2 || quoted[quoted.length - 1] !== quote)
|
|
41
|
+
return null;
|
|
42
|
+
const body = quoted.slice(1, -1);
|
|
43
|
+
if (body.includes(quote))
|
|
44
|
+
return null;
|
|
45
|
+
if (quote === "\"" && /[$\\]/u.test(body))
|
|
46
|
+
return null;
|
|
47
|
+
return body;
|
|
48
|
+
}
|
|
49
|
+
export function isReadOnlyCrewCommand(command) {
|
|
50
|
+
const body = unwrap(command);
|
|
51
|
+
if (body === null)
|
|
52
|
+
return false;
|
|
53
|
+
if (FORBIDDEN.test(body))
|
|
54
|
+
return false;
|
|
55
|
+
const trimmed = body.trim();
|
|
56
|
+
if (trimmed === "" || /\s{2,}/u.test(trimmed))
|
|
57
|
+
return false;
|
|
58
|
+
const tokens = trimmed.split(" ");
|
|
59
|
+
if (tokens[0] !== "crew")
|
|
60
|
+
return false;
|
|
61
|
+
const verb = tokens[1];
|
|
62
|
+
if (verb === undefined)
|
|
63
|
+
return false;
|
|
64
|
+
let consumed;
|
|
65
|
+
if (SINGLE_VERB.has(verb)) {
|
|
66
|
+
consumed = 2;
|
|
67
|
+
}
|
|
68
|
+
else {
|
|
69
|
+
const pair = `${verb} ${tokens[2] ?? ""}`;
|
|
70
|
+
if (!READ_ONLY_CREW_COMMANDS.has(pair))
|
|
71
|
+
return false;
|
|
72
|
+
consumed = 3;
|
|
73
|
+
}
|
|
74
|
+
return tokens.slice(consumed).every((token) => OPTION_TOKEN.test(token) || VALUE_TOKEN.test(token));
|
|
75
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import { CODEX_NATIVE_WORKSPACE_MIN_VERSION } from "./codex.js";
|
|
1
|
+
import { CODEX_COLLABORATION_MIN_VERSION, CODEX_NATIVE_WORKSPACE_MIN_VERSION } from "./codex.js";
|
|
2
2
|
import { z } from "zod";
|
|
3
3
|
export const CODEX_PROJECT_WORKSPACE_UNSUPPORTED = "codex_project_workspace_unsupported";
|
|
4
|
+
export const CODEX_COLLABORATION_UNSUPPORTED = "codex_collaboration_unsupported";
|
|
4
5
|
export const CODEX_PLAN_ENTRY_LIMIT = 16;
|
|
5
6
|
// 16 steps remain inside the server's 4,000 UTF-16-unit plan payload cap even
|
|
6
7
|
// when every retained code point is a surrogate pair (for example, emoji).
|
|
@@ -51,18 +52,32 @@ const versionTuple = (version) => {
|
|
|
51
52
|
const versionAtLeast = (actual, minimum) => actual[0] > minimum[0]
|
|
52
53
|
|| (actual[0] === minimum[0] && (actual[1] > minimum[1]
|
|
53
54
|
|| (actual[1] === minimum[1] && actual[2] >= minimum[2])));
|
|
54
|
-
|
|
55
|
-
export function assertCodexNativeWorkspaceVersion(response) {
|
|
55
|
+
function initializeVersion(response) {
|
|
56
56
|
const parsed = NativeInitializeResponseSchema.safeParse(response);
|
|
57
57
|
if (!parsed.success)
|
|
58
|
-
|
|
58
|
+
return null;
|
|
59
59
|
const match = /(?:^|[/\s])(\d+\.\d+\.\d+)(?=$|[\s(])/u.exec(parsed.data.userAgent);
|
|
60
|
-
|
|
60
|
+
return match === null ? null : versionTuple(match[1] ?? "");
|
|
61
|
+
}
|
|
62
|
+
/** Native roots require the app-server version returned by initialize, not a separate shell probe. */
|
|
63
|
+
export function assertCodexNativeWorkspaceVersion(response) {
|
|
64
|
+
const actual = initializeVersion(response);
|
|
61
65
|
const minimum = versionTuple(CODEX_NATIVE_WORKSPACE_MIN_VERSION);
|
|
62
66
|
if (actual === null || minimum === null || !versionAtLeast(actual, minimum)) {
|
|
63
67
|
throw codexProjectWorkspaceUnsupported();
|
|
64
68
|
}
|
|
65
69
|
}
|
|
70
|
+
/** Collaboration tools are exposed only on an app-server whose contract was verified (spec 2b). */
|
|
71
|
+
export function assertCodexCollaborationVersion(response) {
|
|
72
|
+
const actual = initializeVersion(response);
|
|
73
|
+
const minimum = versionTuple(CODEX_COLLABORATION_MIN_VERSION);
|
|
74
|
+
if (actual === null || minimum === null || !versionAtLeast(actual, minimum)) {
|
|
75
|
+
throw codexCollaborationUnsupported();
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
export function codexCollaborationUnsupported() {
|
|
79
|
+
return new Error(CODEX_COLLABORATION_UNSUPPORTED);
|
|
80
|
+
}
|
|
66
81
|
const PLAN_STATUSES = new Set(["pending", "inProgress", "completed"]);
|
|
67
82
|
const clipCharacters = (value, limit) => [...value.trim().replace(/\s+/gu, " ")].slice(0, limit).join("");
|
|
68
83
|
/** Treat plan updates as bounded user-visible data; any malformed retained step rejects the event. */
|