@nowcrew/daemon 0.6.8 → 0.6.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -378,7 +378,7 @@ CREW_EXECUTION_MAX_QUEUED_TOTAL=128
378
378
  CREW_EXECUTION_MAX_STARTING_TOTAL=1
379
379
  CREW_EXECUTION_MAX_STARTING_PER_RUNTIME=1
380
380
  CREW_EXECUTION_START_GAP_MS=3000
381
- CREW_EXECUTION_STARTUP_TIMEOUT_MS=60000
381
+ CREW_EXECUTION_STARTUP_TIMEOUT_MS=120000
382
382
  ```
383
383
 
384
384
  `MAX_PARALLEL_TOTAL` is the machine-wide process cap shared by protocol-v1 and legacy work.
@@ -0,0 +1,54 @@
1
+ const STAGE_LINE_PREFIX_CAP = 512;
2
+ const STAGE_PATTERN = /^\[codex-app-server\] stage=(spawn|initialize|thread_start|thread_resume|model_init) status=(start|ok|error|timeout) attempt=(\d+) elapsed_ms=(\d+)(?:\s|$)/;
3
+ function parseBoundedStageLine(line) {
4
+ const match = STAGE_PATTERN.exec(line);
5
+ if (match === null)
6
+ return null;
7
+ const [, rawStage, status, rawAttempt, rawStageMs] = match;
8
+ const attempt = Number(rawAttempt);
9
+ const stageMs = Number(rawStageMs);
10
+ if (!Number.isSafeInteger(attempt) || attempt < 1
11
+ || !Number.isSafeInteger(stageMs) || stageMs < 0)
12
+ return null;
13
+ if (rawStage === "thread_resume") {
14
+ return { stage: "thread_start", status: status, attempt, stageMs, resumed: true };
15
+ }
16
+ return {
17
+ stage: rawStage,
18
+ status: status,
19
+ attempt,
20
+ stageMs,
21
+ ...(rawStage === "thread_start" ? { resumed: false } : {}),
22
+ };
23
+ }
24
+ export class CodexStartupStageParser {
25
+ onStage;
26
+ pendingPrefix = "";
27
+ constructor(onStage) {
28
+ this.onStage = onStage;
29
+ }
30
+ push(chunk) {
31
+ let remaining = chunk;
32
+ while (remaining.length > 0) {
33
+ const newline = remaining.indexOf("\n");
34
+ const segment = newline < 0 ? remaining : remaining.slice(0, newline);
35
+ const available = STAGE_LINE_PREFIX_CAP - this.pendingPrefix.length;
36
+ if (available > 0)
37
+ this.pendingPrefix += segment.slice(0, available);
38
+ if (newline < 0)
39
+ return;
40
+ this.emitPending();
41
+ remaining = remaining.slice(newline + 1);
42
+ }
43
+ }
44
+ finish() {
45
+ if (this.pendingPrefix.length > 0)
46
+ this.emitPending();
47
+ }
48
+ emitPending() {
49
+ const stage = parseBoundedStageLine(this.pendingPrefix.replace(/\r$/, ""));
50
+ this.pendingPrefix = "";
51
+ if (stage !== null)
52
+ this.onStage(stage);
53
+ }
54
+ }
package/dist/config.js CHANGED
@@ -17,7 +17,7 @@ export const DEFAULT_EXECUTION_LIMITS = Object.freeze({
17
17
  maxStartingTotal: 1,
18
18
  maxStartingPerRuntime: 1,
19
19
  startupGapMs: 3_000,
20
- startupTimeoutMs: 60_000,
20
+ startupTimeoutMs: 120_000,
21
21
  });
22
22
  // Fits the largest mandatory v1 lifecycle envelope (UUID + timestamps + outcome facts) with margin.
23
23
  export const MIN_EXECUTION_EVENT_BYTES = 512;
@@ -0,0 +1,14 @@
1
+ export const DIAGNOSTIC_JSON_STRING_LIMIT = 3_800;
2
+ export function boundedDiagnosticJsonArray(items, limit = DIAGNOSTIC_JSON_STRING_LIMIT) {
3
+ const selected = [];
4
+ for (const item of items) {
5
+ const candidate = JSON.stringify([...selected, item]);
6
+ if (candidate.length > limit)
7
+ break;
8
+ selected.push(item);
9
+ }
10
+ return {
11
+ json: JSON.stringify(selected),
12
+ truncated: selected.length < items.length,
13
+ };
14
+ }
@@ -18,11 +18,13 @@ import { routeRuntimeAttachments, runtimeCapability, } from "./runtime-capabilit
18
18
  import { awaitWithCancellation, RuntimeCancelledError, } from "./runtime-cancellation.js";
19
19
  import { isRuntimeReadyEvent, } from "./runtime-startup-gate.js";
20
20
  import { dslog } from "./slog.js";
21
- import { evaluateMemoryPrunePostcondition, inspectMemoryPruneFilesWithinDeadline, parseMemoryPruneTraceId, } from "./memory-prune-diagnostics.js";
21
+ import { boundedDiagnosticJsonArray } from "./diagnostic-json.js";
22
+ import { diffMemoryPruneNotes, evaluateMemoryPrunePostcondition, inspectMemoryPruneFilesWithinDeadline, parseMemoryPruneTraceId, } from "./memory-prune-diagnostics.js";
22
23
  import { createLocalMemoryTelemetry, logLocalMemoryContextPrepareFailure, logLocalMemoryDiagnosticsFailure, } from "./local-memory-telemetry.js";
23
- function memoryPruneSnapshotFields(snapshot) {
24
+ import { CodexStartupStageParser } from "./codex-startup-stage.js";
25
+ function memoryPruneFileFactFields(fileFacts) {
24
26
  const fields = {};
25
- for (const [label, fact] of Object.entries(snapshot)) {
27
+ for (const [label, fact] of Object.entries(fileFacts)) {
26
28
  fields[`${label}_exists`] = fact.exists;
27
29
  fields[`${label}_size`] = fact.size;
28
30
  fields[`${label}_mtime_ms`] = fact.mtime_ms;
@@ -35,6 +37,9 @@ function memoryPruneSnapshotFields(snapshot) {
35
37
  }
36
38
  return fields;
37
39
  }
40
+ function memoryPruneSnapshotFields({ notes_manifest: _notesManifest, ...fileFacts }) {
41
+ return memoryPruneFileFactFields(fileFacts);
42
+ }
38
43
  function logMemoryPruneDiagnosticsFailure(input, traceId, phase, error) {
39
44
  const errorCode = error?.code;
40
45
  dslog("memory_prune.diagnostics_failed", "长期记忆收尾诊断失败", {
@@ -615,10 +620,30 @@ async function executeLocalUnlocked(input, callbacks, dependencies) {
615
620
  callbacks.onConsole?.(chunk);
616
621
  });
617
622
  let stderrTail = "";
623
+ const codexStartupStageState = { last: null };
624
+ const codexStartupStageParser = runtime.name === "codex"
625
+ ? new CodexStartupStageParser((stage) => {
626
+ codexStartupStageState.last = stage;
627
+ dslog("runtime.start_stage", "Codex runtime 启动阶段更新", {
628
+ level: stage.status === "error" || stage.status === "timeout" ? "ERROR" : "INFO",
629
+ execution_id: input.executionId,
630
+ runtime: runtime.name,
631
+ stage: stage.stage,
632
+ status: stage.status,
633
+ attempt: stage.attempt,
634
+ stage_ms: stage.stageMs,
635
+ startup_ms: Date.now() - runtimeLaunchAt,
636
+ ...(stage.stage === "thread_start" ? { resumed: stage.resumed } : {}),
637
+ });
638
+ })
639
+ : null;
618
640
  child.stderr.on("data", (data) => {
619
641
  process.stderr.write(data);
620
- stderrTail = (stderrTail + String(data)).slice(-STDERR_TAIL_CAP);
642
+ const text = String(data);
643
+ stderrTail = (stderrTail + text).slice(-STDERR_TAIL_CAP);
644
+ codexStartupStageParser?.push(text);
621
645
  });
646
+ child.stderr.on("end", () => codexStartupStageParser?.finish());
622
647
  let runtimeExit;
623
648
  try {
624
649
  if (startupReservation !== null) {
@@ -629,7 +654,7 @@ async function executeLocalUnlocked(input, callbacks, dependencies) {
629
654
  runtimeReadySignal.then(() => ({ kind: "ready" })),
630
655
  child.exit.then((exit) => ({ kind: "exit", exit })),
631
656
  new Promise((resolve) => {
632
- startupTimer = setTimeout(() => resolve({ kind: "timeout" }), dependencies.startupTimeoutMs ?? 60_000);
657
+ startupTimer = setTimeout(() => resolve({ kind: "timeout" }), dependencies.startupTimeoutMs ?? 120_000);
633
658
  }),
634
659
  ]), dependencies.cancellation);
635
660
  }
@@ -639,11 +664,18 @@ async function executeLocalUnlocked(input, callbacks, dependencies) {
639
664
  }
640
665
  if (startupOutcome.kind !== "ready") {
641
666
  if (startupOutcome.kind === "timeout") {
667
+ const lastCodexStartupStage = codexStartupStageState.last;
642
668
  dslog("runtime.start_timeout", "runtime 启动超时", {
643
669
  level: "ERROR",
644
670
  execution_id: input.executionId,
645
671
  runtime: runtime.name,
646
- startup_timeout_ms: dependencies.startupTimeoutMs ?? 60_000,
672
+ startup_timeout_ms: dependencies.startupTimeoutMs ?? 120_000,
673
+ ...(lastCodexStartupStage === null ? {} : {
674
+ last_stage: lastCodexStartupStage.stage,
675
+ last_stage_status: lastCodexStartupStage.status,
676
+ last_stage_ms: lastCodexStartupStage.stageMs,
677
+ attempt: lastCodexStartupStage.attempt,
678
+ }),
647
679
  });
648
680
  await child.cancel?.();
649
681
  }
@@ -744,6 +776,20 @@ async function executeLocalUnlocked(input, callbacks, dependencies) {
744
776
  ...memoryPruneSnapshotFields(snapshot),
745
777
  });
746
778
  if (memoryPruneBeforeSnapshot !== null) {
779
+ const noteDiff = diffMemoryPruneNotes(memoryPruneBeforeSnapshot.notes_manifest, snapshot.notes_manifest);
780
+ const serializedChanges = boundedDiagnosticJsonArray(noteDiff.changes);
781
+ const changesTruncated = noteDiff.truncated || serializedChanges.truncated;
782
+ dslog("memory_prune.notes_changed", "长期记忆功能文件变化已评估", {
783
+ execution_id: input.executionId,
784
+ agent_handle: input.handle,
785
+ task_key: input.taskKey,
786
+ prune_trace_id: memoryPruneTraceId,
787
+ change_count: noteDiff.change_count,
788
+ changes_truncated: changesTruncated,
789
+ evidence_complete: noteDiff.evidence_complete
790
+ && !changesTruncated,
791
+ changes: serializedChanges.json,
792
+ });
747
793
  const postcondition = evaluateMemoryPrunePostcondition(memoryPruneBeforeSnapshot, snapshot, {
748
794
  executorCompleted: memoryPruneExecutorCompleted,
749
795
  ...(memoryPruneRuntimeExitCode === undefined
@@ -1,6 +1,6 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { readFile, stat } from "node:fs/promises";
3
- import { isAbsolute, join, normalize, resolve } from "node:path";
3
+ import { isAbsolute, join, normalize, relative, resolve } from "node:path";
4
4
  import { capMemoryForInject, MEMORY_INJECT_CAP } from "./prompt.js";
5
5
  const INJECTION_MARKERS = [
6
6
  "## Injected MEMORY.md (bounded local context)\n",
@@ -134,50 +134,71 @@ function classifyExplicitPath(path, paths) {
134
134
  .replace(/^\$CREW_HOME/, paths.homeDir);
135
135
  const absolute = normalized(isAbsolute(replaced) ? replaced : resolve(paths.runDir, replaced));
136
136
  if (absolute === paths.sharedMemory)
137
- return "shared_memory";
137
+ return { target: "shared_memory" };
138
138
  if (absolute === paths.sharedLessons)
139
- return "shared_lessons";
140
- if (absolute === paths.cwdMemory && paths.cwdMemory !== paths.sharedMemory)
141
- return "cwd_shadow_memory";
142
- if (absolute === paths.cwdLessons && paths.cwdLessons !== paths.sharedLessons)
143
- return "cwd_shadow_lessons";
144
- if (absolute.startsWith(`${paths.sharedNotes}/`))
145
- return "other_shared_note";
146
- return null;
147
- }
148
- function commandTargets(command, paths) {
149
- const targets = new Set();
150
- const expandedCommand = command
151
- .replaceAll("${CREW_HOME}", paths.homeDir)
152
- .replaceAll("$CREW_HOME", paths.homeDir)
153
- .replace(/["']/g, "");
154
- const references = [
155
- { values: [paths.sharedMemory], target: "shared_memory" },
156
- { values: [paths.sharedLessons], target: "shared_lessons" },
157
- { values: [paths.cwdMemory], target: "cwd_shadow_memory" },
158
- { values: [paths.cwdLessons], target: "cwd_shadow_lessons" },
159
- ];
160
- for (const reference of references) {
161
- if (reference.values.some((value) => expandedCommand.includes(value)))
162
- targets.add(reference.target);
139
+ return { target: "shared_lessons" };
140
+ if (absolute === paths.cwdMemory && paths.cwdMemory !== paths.sharedMemory) {
141
+ return { target: "cwd_shadow_memory" };
163
142
  }
164
- const withoutKnownPaths = references.flatMap((reference) => reference.values)
165
- .reduce((value, reference) => value.replaceAll(reference, ""), expandedCommand);
166
- if (/(?:^|[\s'"`])notes[\\/]lessons\.md(?:$|[\s'"`;|&])/i.test(withoutKnownPaths)) {
167
- targets.add("cwd_shadow_lessons");
143
+ if (absolute === paths.cwdLessons && paths.cwdLessons !== paths.sharedLessons) {
144
+ return { target: "cwd_shadow_lessons" };
168
145
  }
169
- else if (/(?:^|[\s'"`])MEMORY\.md(?:$|[\s'"`;|&])/i.test(withoutKnownPaths)) {
170
- targets.add("cwd_shadow_memory");
146
+ const relativePath = normalized(relative(paths.homeDir, absolute));
147
+ const relativeSegments = relativePath.split("/");
148
+ if (relativeSegments.length === 2
149
+ && relativeSegments[0] === "notes"
150
+ && relativeSegments[1]?.toLowerCase().endsWith(".md")) {
151
+ return { target: "other_shared_note", relative_path: relativePath };
171
152
  }
172
- return [...targets];
153
+ return null;
173
154
  }
174
155
  function commandOperation(command) {
175
- if (/(?:^|[;&|]\s*|\s)(?:rm|unlink)\s/i.test(command))
156
+ if (/[;|&\n]/.test(command))
157
+ return null;
158
+ const executable = /^\s*(?:[A-Za-z_][A-Za-z0-9_]*=\S+\s+)*([^\s]+)/
159
+ .exec(command)?.[1]?.split("/").at(-1)?.toLowerCase();
160
+ if (executable === undefined)
161
+ return null;
162
+ if (["rm", "unlink"].includes(executable))
176
163
  return "delete";
177
- if (/(?:>>?|\btee\b|\btouch\b|\btruncate\b|\bsed\s+-i\b|\bperl\b[^\n]*\s-i\b|\bapply_patch\b)/i.test(command)) {
164
+ if (["tee", "touch", "truncate"].includes(executable))
178
165
  return "write";
166
+ if (executable === "sed" || executable === "perl") {
167
+ return /(?:^|\s)-i\S*(?:\s|$)/.test(command)
168
+ ? "write"
169
+ : "read";
170
+ }
171
+ return ["cat", "rg", "grep", "head", "tail", "less", "more", "wc", "awk"].includes(executable)
172
+ ? "read"
173
+ : null;
174
+ }
175
+ function commandAccesses(command, paths) {
176
+ if (/[;|&\n]/.test(command))
177
+ return [];
178
+ const accesses = new Map();
179
+ const expanded = command
180
+ .replaceAll("${CREW_HOME}", paths.homeDir)
181
+ .replaceAll("$CREW_HOME", paths.homeDir)
182
+ .replace(/["']/g, "");
183
+ const redirect = /^(.*?)\s+>>?\s*([^\s]+)\s*$/.exec(expanded);
184
+ const sourceCommand = redirect?.[1] ?? expanded;
185
+ const operation = commandOperation(sourceCommand);
186
+ if (operation !== null) {
187
+ for (const path of sourceCommand.split(/[\s<>]+/)) {
188
+ const classified = classifyExplicitPath(path, paths);
189
+ if (classified === null)
190
+ continue;
191
+ accesses.set(`${classified.target}:${classified.relative_path ?? ""}:${operation}`, { ...classified, operation });
192
+ }
193
+ }
194
+ const redirectTarget = redirect?.[2];
195
+ if (redirectTarget !== undefined) {
196
+ const classified = classifyExplicitPath(redirectTarget, paths);
197
+ if (classified !== null) {
198
+ accesses.set(`${classified.target}:${classified.relative_path ?? ""}:write`, { ...classified, operation: "write" });
199
+ }
179
200
  }
180
- return "read";
201
+ return [...accesses.values()];
181
202
  }
182
203
  function toolOperation(name) {
183
204
  const lowered = name.toLowerCase();
@@ -224,9 +245,9 @@ export function createLocalMemoryAccessObserver(options) {
224
245
  continue;
225
246
  const operation = toolOperation(block.name);
226
247
  const path = toolPath(block.input);
227
- const target = operation === null || path === null ? null : classifyExplicitPath(path, paths);
228
- if (operation !== null && target !== null)
229
- pending.set(block.id, { operation, target });
248
+ const classified = operation === null || path === null ? null : classifyExplicitPath(path, paths);
249
+ if (operation !== null && classified !== null)
250
+ pending.set(block.id, { operation, ...classified });
230
251
  }
231
252
  }
232
253
  if (record.type === "user") {
@@ -252,11 +273,11 @@ export function createLocalMemoryAccessObserver(options) {
252
273
  ? toolOperation(record.title)
253
274
  : null);
254
275
  const path = toolPath(record.input);
255
- const target = fallbackOperation === null || path === null
276
+ const classified = fallbackOperation === null || path === null
256
277
  ? null
257
278
  : classifyExplicitPath(path, paths);
258
- if (fallbackOperation !== null && target !== null) {
259
- pending.set(record.id, { operation: fallbackOperation, target });
279
+ if (fallbackOperation !== null && classified !== null) {
280
+ pending.set(record.id, { operation: fallbackOperation, ...classified });
260
281
  }
261
282
  }
262
283
  if (record.type === "kimi.acp.tool_result" && typeof record.id === "string") {
@@ -276,7 +297,6 @@ export function createLocalMemoryAccessObserver(options) {
276
297
  ? record.item
277
298
  : null;
278
299
  if (item?.type === "command_execution" && typeof item.command === "string") {
279
- const operation = commandOperation(item.command);
280
300
  const outcome = typeof item.exit_code === "number"
281
301
  ? item.exit_code === 0 ? "succeeded" : "failed"
282
302
  : item.status === "completed"
@@ -284,8 +304,12 @@ export function createLocalMemoryAccessObserver(options) {
284
304
  : item.status === "failed" || item.status === "declined"
285
305
  ? "failed"
286
306
  : "unknown";
287
- for (const target of commandTargets(item.command, paths)) {
288
- recordObservation(observations, counts, { target, operation, outcome, evidence: "shell_command" });
307
+ for (const access of commandAccesses(item.command, paths)) {
308
+ recordObservation(observations, counts, {
309
+ ...access,
310
+ outcome,
311
+ evidence: "shell_command",
312
+ });
289
313
  }
290
314
  }
291
315
  const fileChange = record.type === "diagnostic.file_change" && Array.isArray(record.changes)
@@ -305,11 +329,11 @@ export function createLocalMemoryAccessObserver(options) {
305
329
  const detail = change;
306
330
  if (typeof detail.path !== "string")
307
331
  continue;
308
- const target = classifyExplicitPath(detail.path, paths);
309
- if (target === null)
332
+ const classified = classifyExplicitPath(detail.path, paths);
333
+ if (classified === null)
310
334
  continue;
311
335
  recordObservation(observations, counts, {
312
- target,
336
+ ...classified,
313
337
  operation: detail.kind === "delete" ? "delete" : "write",
314
338
  outcome,
315
339
  evidence: "file_change",
@@ -86,6 +86,7 @@ export function createLocalMemoryTelemetry(options) {
86
86
  let runtimeReady = false;
87
87
  let runtimeExitCode;
88
88
  let executorCompleted = false;
89
+ let sharedMutationObserved = false;
89
90
  return {
90
91
  async captureBefore() {
91
92
  const startedAt = Date.now();
@@ -140,6 +141,14 @@ export function createLocalMemoryTelemetry(options) {
140
141
  observe(event) {
141
142
  try {
142
143
  for (const observation of accessObserver.observe(event)) {
144
+ const isSharedMutation = observation.outcome === "succeeded"
145
+ && (observation.operation === "write" || observation.operation === "delete")
146
+ && (observation.target === "shared_memory"
147
+ || observation.target === "shared_lessons"
148
+ || observation.target === "other_shared_note");
149
+ if (isSharedMutation) {
150
+ sharedMutationObserved = true;
151
+ }
143
152
  accessSequence += 1;
144
153
  logAccess(options, observation, accessSequence);
145
154
  }
@@ -178,8 +187,6 @@ export function createLocalMemoryTelemetry(options) {
178
187
  const sharedFilesChanged = memoryChanged === undefined || lessonsChanged === undefined
179
188
  ? undefined
180
189
  : memoryChanged || lessonsChanged;
181
- const currentExecutionSharedWriteObserved = accessSummary.shared_memory_write_succeeded > 0
182
- || accessSummary.shared_lessons_write_succeeded > 0;
183
190
  safeLog("local_memory.execution_observed", "本地记忆执行观察已完成", {
184
191
  execution_id: options.executionId,
185
192
  agent_handle: options.agentHandle,
@@ -196,7 +203,7 @@ export function createLocalMemoryTelemetry(options) {
196
203
  shared_files_changed_during_execution: sharedFilesChanged,
197
204
  unexpected_shared_write: options.isMemoryPrune
198
205
  ? undefined
199
- : currentExecutionSharedWriteObserved
206
+ : sharedMutationObserved
200
207
  ? true
201
208
  : sharedFilesChanged === false ? false : undefined,
202
209
  access_observation_count: accessSequence,
@@ -1,17 +1,22 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { createReadStream } from "node:fs";
3
- import { stat } from "node:fs/promises";
3
+ import { lstat, readdir } from "node:fs/promises";
4
4
  import { join } from "node:path";
5
5
  import { StringDecoder } from "node:string_decoder";
6
6
  const TRACE_PATTERN = /\[memory-prune trace_id=([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\]/i;
7
7
  export const MEMORY_PRUNE_HASH_LIMIT_BYTES = 10 * 1024 * 1024;
8
+ export const MEMORY_PRUNE_NOTES_MANIFEST_LIMIT = 128;
9
+ export const MEMORY_PRUNE_NOTE_CHANGE_LIMIT = 128;
10
+ function compareCodeUnits(left, right) {
11
+ return left < right ? -1 : left > right ? 1 : 0;
12
+ }
8
13
  export function parseMemoryPruneTraceId(wakePrompt) {
9
14
  return TRACE_PATTERN.exec(wakePrompt)?.[1] ?? null;
10
15
  }
11
16
  async function inspectFile(path, markdownFacts = false, sameAsSharedPath, signal) {
12
17
  let metadata;
13
18
  try {
14
- metadata = await stat(path);
19
+ metadata = await lstat(path);
15
20
  }
16
21
  catch (error) {
17
22
  const code = error.code;
@@ -32,6 +37,8 @@ async function inspectFile(path, markdownFacts = false, sameAsSharedPath, signal
32
37
  mtime_ms: metadata.mtimeMs,
33
38
  ...(sameAsSharedPath === undefined ? {} : { same_as_shared_path: sameAsSharedPath }),
34
39
  };
40
+ if (metadata.isSymbolicLink())
41
+ return { ...fact, error: "symbolic_link" };
35
42
  if (!metadata.isFile())
36
43
  return { ...fact, hash_skipped_reason: "not_regular_file" };
37
44
  if (metadata.size > MEMORY_PRUNE_HASH_LIMIT_BYTES) {
@@ -91,13 +98,106 @@ async function inspectFile(path, markdownFacts = false, sameAsSharedPath, signal
91
98
  return { ...fact, error: code ?? (error instanceof Error ? error.name : "unknown") };
92
99
  }
93
100
  }
94
- export async function inspectMemoryPruneFiles(homeDir, runDir, workLogPath, signal) {
101
+ function diagnosticError(error) {
102
+ const code = error.code;
103
+ return code ?? (error instanceof Error ? error.name : "unknown");
104
+ }
105
+ async function inspectLessonsFile(rootDir, sameAsSharedPath, signal) {
106
+ const pathFact = sameAsSharedPath === undefined ? {} : { same_as_shared_path: sameAsSharedPath };
107
+ try {
108
+ const notesMetadata = await lstat(join(rootDir, "notes"));
109
+ if (notesMetadata.isSymbolicLink()) {
110
+ return { exists: false, ...pathFact, error: "symbolic_link" };
111
+ }
112
+ if (!notesMetadata.isDirectory())
113
+ return { exists: false, ...pathFact, error: "ENOTDIR" };
114
+ }
115
+ catch (error) {
116
+ const code = diagnosticError(error);
117
+ return code === "ENOENT"
118
+ ? { exists: false, ...pathFact }
119
+ : { exists: false, ...pathFact, error: code };
120
+ }
121
+ return inspectFile(join(rootDir, "notes", "lessons.md"), true, sameAsSharedPath, signal);
122
+ }
123
+ export async function inspectMemoryPruneNotes(homeDir, signal) {
124
+ const notesDir = join(homeDir, "notes");
125
+ try {
126
+ const metadata = await lstat(notesDir);
127
+ if (metadata.isSymbolicLink()) {
128
+ return {
129
+ files: [], total_count: 0, omitted_count: 0, truncated: false, error: "symbolic_link",
130
+ };
131
+ }
132
+ if (!metadata.isDirectory()) {
133
+ return {
134
+ files: [], total_count: 0, omitted_count: 0, truncated: false, error: "ENOTDIR",
135
+ };
136
+ }
137
+ }
138
+ catch (error) {
139
+ if (error.code === "ENOENT") {
140
+ return { files: [], total_count: 0, omitted_count: 0, truncated: false };
141
+ }
142
+ return {
143
+ files: [],
144
+ total_count: 0,
145
+ omitted_count: 0,
146
+ truncated: false,
147
+ error: diagnosticError(error),
148
+ };
149
+ }
150
+ let noteNames;
151
+ try {
152
+ noteNames = (await readdir(notesDir, { withFileTypes: true }))
153
+ .filter((entry) => (entry.isFile() || entry.isSymbolicLink())
154
+ && entry.name.toLowerCase().endsWith(".md"))
155
+ .map((entry) => entry.name)
156
+ .sort(compareCodeUnits);
157
+ }
158
+ catch (error) {
159
+ return {
160
+ files: [], total_count: 0, omitted_count: 0, truncated: false,
161
+ error: diagnosticError(error),
162
+ };
163
+ }
164
+ const totalCount = noteNames.length;
165
+ const selected = noteNames.slice(0, MEMORY_PRUNE_NOTES_MANIFEST_LIMIT);
166
+ const files = [];
167
+ for (const name of selected) {
168
+ if (signal?.aborted)
169
+ break;
170
+ const path = join(notesDir, name);
171
+ try {
172
+ if ((await lstat(path)).isSymbolicLink()) {
173
+ files.push({ relative_path: `notes/${name}`, exists: false, error: "symbolic_link" });
174
+ continue;
175
+ }
176
+ }
177
+ catch (error) {
178
+ files.push({ relative_path: `notes/${name}`, exists: false, error: diagnosticError(error) });
179
+ continue;
180
+ }
181
+ files.push({
182
+ relative_path: `notes/${name}`,
183
+ ...await inspectFile(path, false, undefined, signal),
184
+ });
185
+ }
186
+ return {
187
+ files,
188
+ total_count: totalCount,
189
+ omitted_count: Math.max(0, totalCount - selected.length),
190
+ truncated: totalCount > selected.length,
191
+ ...(signal?.aborted ? { error: "ABORT_ERR" } : {}),
192
+ };
193
+ }
194
+ async function inspectMemoryPruneFixedFiles(homeDir, runDir, workLogPath, signal) {
95
195
  const cwdIsHome = runDir === homeDir;
96
196
  const [memory, lessons, cwdMemory, cwdLessons, workLog] = await Promise.all([
97
197
  inspectFile(join(homeDir, "MEMORY.md"), true, undefined, signal),
98
- inspectFile(join(homeDir, "notes", "lessons.md"), true, undefined, signal),
198
+ inspectLessonsFile(homeDir, undefined, signal),
99
199
  inspectFile(join(runDir, "MEMORY.md"), true, cwdIsHome, signal),
100
- inspectFile(join(runDir, "notes", "lessons.md"), true, cwdIsHome, signal),
200
+ inspectLessonsFile(runDir, cwdIsHome, signal),
101
201
  inspectFile(workLogPath, false, undefined, signal),
102
202
  ]);
103
203
  return {
@@ -108,8 +208,29 @@ export async function inspectMemoryPruneFiles(homeDir, runDir, workLogPath, sign
108
208
  work_log: workLog,
109
209
  };
110
210
  }
211
+ export async function inspectMemoryPruneFiles(homeDir, runDir, workLogPath, signal) {
212
+ const [fixedFiles, notesManifest] = await Promise.all([
213
+ inspectMemoryPruneFixedFiles(homeDir, runDir, workLogPath, signal),
214
+ inspectMemoryPruneNotes(homeDir, signal),
215
+ ]);
216
+ return { ...fixedFiles, notes_manifest: notesManifest };
217
+ }
111
218
  export async function inspectMemoryPruneFilesWithinDeadline(homeDir, runDir, workLogPath, timeoutMs) {
112
- return withinMemoryPruneDiagnosticsDeadline((signal) => inspectMemoryPruneFiles(homeDir, runDir, workLogPath, signal), timeoutMs);
219
+ const fixedFiles = await withinMemoryPruneDiagnosticsDeadline((signal) => inspectMemoryPruneFixedFiles(homeDir, runDir, workLogPath, signal), timeoutMs);
220
+ let notesManifest;
221
+ try {
222
+ notesManifest = await withinMemoryPruneDiagnosticsDeadline((signal) => inspectMemoryPruneNotes(homeDir, signal), timeoutMs);
223
+ }
224
+ catch (error) {
225
+ notesManifest = {
226
+ files: [],
227
+ total_count: 0,
228
+ omitted_count: 0,
229
+ truncated: false,
230
+ error: diagnosticError(error),
231
+ };
232
+ }
233
+ return { ...fixedFiles, notes_manifest: notesManifest };
113
234
  }
114
235
  export async function withinMemoryPruneDiagnosticsDeadline(operation, timeoutMs) {
115
236
  const controller = new AbortController();
@@ -145,6 +266,61 @@ function fileChanged(before, after) {
145
266
  }
146
267
  return before.size !== after.size || before.mtime_ms !== after.mtime_ms;
147
268
  }
269
+ export function diffMemoryPruneNotes(before, after) {
270
+ const beforeByPath = new Map(before.files
271
+ .filter((file) => file.exists)
272
+ .map((file) => [file.relative_path, file]));
273
+ const afterByPath = new Map(after.files
274
+ .filter((file) => file.exists)
275
+ .map((file) => [file.relative_path, file]));
276
+ const paths = [...new Set([...beforeByPath.keys(), ...afterByPath.keys()])]
277
+ .sort(compareCodeUnits);
278
+ const allChanges = [];
279
+ for (const relativePath of paths) {
280
+ const beforeFile = beforeByPath.get(relativePath);
281
+ const afterFile = afterByPath.get(relativePath);
282
+ if (beforeFile === undefined && afterFile !== undefined) {
283
+ allChanges.push({
284
+ relative_path: relativePath,
285
+ change: "created",
286
+ ...(afterFile.sha256 === undefined ? {} : { after_sha256: afterFile.sha256 }),
287
+ ...(afterFile.size === undefined ? {} : { after_size: afterFile.size }),
288
+ });
289
+ }
290
+ else if (beforeFile !== undefined && afterFile === undefined) {
291
+ allChanges.push({
292
+ relative_path: relativePath,
293
+ change: "deleted",
294
+ ...(beforeFile.sha256 === undefined ? {} : { before_sha256: beforeFile.sha256 }),
295
+ ...(beforeFile.size === undefined ? {} : { before_size: beforeFile.size }),
296
+ });
297
+ }
298
+ else if (beforeFile !== undefined && afterFile !== undefined
299
+ && fileChanged(beforeFile, afterFile)) {
300
+ allChanges.push({
301
+ relative_path: relativePath,
302
+ change: "updated",
303
+ ...(beforeFile.sha256 === undefined ? {} : { before_sha256: beforeFile.sha256 }),
304
+ ...(afterFile.sha256 === undefined ? {} : { after_sha256: afterFile.sha256 }),
305
+ ...(beforeFile.size === undefined ? {} : { before_size: beforeFile.size }),
306
+ ...(afterFile.size === undefined ? {} : { after_size: afterFile.size }),
307
+ });
308
+ }
309
+ }
310
+ return {
311
+ changes: allChanges.slice(0, MEMORY_PRUNE_NOTE_CHANGE_LIMIT),
312
+ change_count: allChanges.length,
313
+ truncated: allChanges.length > MEMORY_PRUNE_NOTE_CHANGE_LIMIT,
314
+ evidence_complete: before.error === undefined
315
+ && after.error === undefined
316
+ && !before.truncated
317
+ && !after.truncated
318
+ && [...before.files, ...after.files].every((file) => file.exists
319
+ && file.sha256 !== undefined
320
+ && file.error === undefined
321
+ && file.hash_skipped_reason === undefined),
322
+ };
323
+ }
148
324
  export function evaluateMemoryPrunePostcondition(before, after, execution) {
149
325
  const facts = {
150
326
  work_log_before_nonempty: before.work_log.exists && (before.work_log.size ?? 0) > 0,
@@ -468,7 +468,7 @@ async function runCodexAppServerAttempt(bin, input, attempt, initializeTimeoutMs
468
468
  completionReject(new Error("Codex produced no semantic progress within the startup window"));
469
469
  void cancel();
470
470
  });
471
- activeStage = "turn_start";
471
+ activeStage = "model_init";
472
472
  stageStartedAt = Date.now();
473
473
  const started = await rpc.request("turn/start", {
474
474
  threadId,
package/dist/serve.js CHANGED
@@ -741,7 +741,7 @@ export function serve(config, opts = {}) {
741
741
  try {
742
742
  const data2 = req.type === "fs:list" ? await listWorkspace(root, req.path)
743
743
  : req.type === "fs:read" ? await readWorkspaceFile(root, req.path)
744
- : req.type === "skills:list" ? await listSkills(config.agentsRoot, req.handle)
744
+ : req.type === "skills:list" ? await listSkills(req.runtime)
745
745
  : { models: await listRuntimeModels(req.type === "probe-models" ? (req.runtime ?? "") : "") };
746
746
  reply({ ok: true, data: data2 });
747
747
  }
package/dist/skills.js CHANGED
@@ -1,39 +1,70 @@
1
1
  /**
2
- * 枚举某 agent 可用的 skill,供 Profile 的 SKILLS 区展示。两个来源:
3
- * - workspace 私有:<agentsRoot>/<handle>/skills/<name>/SKILL.md
4
- * - 全局共享: ~/.claude/skills/<name>/SKILL.md (可经 CREW_GLOBAL_SKILLS_DIR 覆盖)
2
+ * 枚举某 agent 可用的全局 skill,供 Profile 的 SKILLS 区展示。
3
+ *
4
+ * 目录由 runtime 决定——各 runtime 的原生发现路径互不相同:
5
+ * - codex: $CODEX_HOME/skills (未设时 ~/.codex/skills) + ~/.agents/skills
6
+ * - claude: ~/.claude/skills
7
+ * runtime 缺省(老 server 不下发)时按 claude 回落,与历史行为一致。
8
+ * CREW_GLOBAL_SKILLS_DIR 若设置则覆盖以上全部,只扫它。
9
+ * Windows 上目录名相同,位于用户 profile 下(codex 官方说明:"On Windows, use the
10
+ * equivalent path under the user profile"),差异仅在分隔符,由 platform 选 path API。
5
11
  *
6
12
  * 每个 skill = 一个目录,内含 SKILL.md;从其 YAML frontmatter 取 name/description,
7
13
  * 缺失则回退到目录名。只读、容错(目录不存在/无 frontmatter 都不报错)。
14
+ *
15
+ * 不扫 agent 工作区:项目技能由 project-skills/reconciler 投影到
16
+ * <agentRoot>/.agents/skills 与 <agentRoot>/.crew/claude-skills/.claude/skills,
17
+ * 那两处是 reconciler 独占(每次整目录原子替换),且已由「项目技能」区单独展示。
8
18
  */
9
19
  import { readdir, readFile, stat } from "node:fs/promises";
10
- import { join } from "node:path";
20
+ import { join, posix, win32 } from "node:path";
11
21
  import { homedir } from "node:os";
12
22
  import { parseSkillFrontmatter } from "./skill-frontmatter.js";
13
- const globalSkillsDir = () => process.env.CREW_GLOBAL_SKILLS_DIR ?? join(homedir(), ".claude", "skills");
14
- async function readSkillsFrom(dir, scope) {
23
+ /**
24
+ * 该 runtime 的原生全局 skill 目录;顺序即优先级(重名时靠前的胜出)
25
+ * 纯路径计算、不碰文件系统——所以能在任意宿主上跨平台单测。
26
+ */
27
+ export function globalSkillsDirs(runtime, userHome = homedir(), platform = process.platform, env = process.env) {
28
+ const override = env.CREW_GLOBAL_SKILLS_DIR;
29
+ if (override)
30
+ return [override];
31
+ const pathApi = platform === "win32" ? win32 : posix;
32
+ if (runtime === "codex") {
33
+ return [
34
+ pathApi.join(env.CODEX_HOME ?? pathApi.join(userHome, ".codex"), "skills"),
35
+ pathApi.join(userHome, ".agents", "skills"),
36
+ ];
37
+ }
38
+ return [pathApi.join(userHome, ".claude", "skills")];
39
+ }
40
+ async function readSkillsFrom(dir) {
15
41
  const names = await readdir(dir).catch(() => []);
16
42
  const skills = [];
17
43
  for (const name of names) {
18
44
  if (name.startsWith("."))
19
45
  continue;
46
+ // 读盘一律用宿主的 path:上面算出的目录已是目标平台形态,此处只在本机拼 SKILL.md
20
47
  const skillMd = join(dir, name, "SKILL.md");
21
48
  const s = await stat(skillMd).catch(() => null);
22
49
  if (!s || !s.isFile())
23
50
  continue;
24
51
  const md = await readFile(skillMd, "utf8").catch(() => "");
25
52
  const fm = parseSkillFrontmatter(md);
26
- skills.push({ scope, name: fm.name || name, description: fm.description ?? "" });
53
+ skills.push({ scope: "global", name: fm.name || name, description: fm.description ?? "" });
27
54
  }
28
55
  return skills;
29
56
  }
30
- /** 列出 agent workspace 私有 + 全局 skill,按 name 排序。 */
31
- export async function listSkills(agentsRoot, handle) {
32
- const [ws, global] = await Promise.all([
33
- readSkillsFrom(join(agentsRoot, handle, "skills"), "workspace"),
34
- readSkillsFrom(globalSkillsDir(), "global"),
35
- ]);
36
- const all = [...ws, ...global];
37
- all.sort((a, b) => (a.scope !== b.scope ? (a.scope === "workspace" ? -1 : 1) : a.name.localeCompare(b.name)));
38
- return all;
57
+ /** 列出该 runtime 的全局 skill,按 name 去重后排序。 */
58
+ export async function listSkills(runtime, userHome = homedir(), platform = process.platform, env = process.env) {
59
+ const dirs = globalSkillsDirs(runtime, userHome, platform, env);
60
+ const perDir = await Promise.all(dirs.map((dir) => readSkillsFrom(dir)));
61
+ // 同名 skill 可能同时存在于多个 root( ~/.codex/skills 与 ~/.agents/skills),
62
+ // 取先命中的:既符合 runtime 的目录优先级,也避免前端 key 冲突。
63
+ const byName = new Map();
64
+ for (const skills of perDir) {
65
+ for (const skill of skills)
66
+ if (!byName.has(skill.name))
67
+ byName.set(skill.name, skill);
68
+ }
69
+ return [...byName.values()].sort((a, b) => a.name.localeCompare(b.name));
39
70
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nowcrew/daemon",
3
- "version": "0.6.8",
3
+ "version": "0.6.10",
4
4
  "type": "module",
5
5
  "description": "crew daemon — 运行在用户机器:拉起/管理 agent 进程,注入 crew CLI,归一化 runtime 事件",
6
6
  "license": "Apache-2.0",
@@ -18,7 +18,7 @@
18
18
  },
19
19
  "dependencies": {
20
20
  "@agentclientprotocol/sdk": "1.2.1",
21
- "@nowcrew/cli": "^0.4.13",
21
+ "@nowcrew/cli": "^0.4.14",
22
22
  "cross-spawn": "^7.0.6",
23
23
  "ws": "^8",
24
24
  "yaml": "^2.8.1",