@cjhyy/code-shell-capability-coding 0.8.9 → 0.8.11

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.
@@ -22,14 +22,16 @@ export type ExternalAgentSessionRecord = Omit<ExternalAgentSessionBinding, "crea
22
22
  export declare function defaultExternalAgentSessionStorePath(): string;
23
23
  export declare class ExternalAgentSessionStore {
24
24
  private readonly file;
25
+ private readonly pendingBindings;
25
26
  constructor(file?: string);
26
27
  /** Snapshot all known bindings. Returned objects are detached from the
27
28
  * persisted array so read-only consumers (for example room discovery) can
28
29
  * correlate worktree sessions without gaining a mutation path. */
29
30
  list(): ExternalAgentSessionBinding[];
30
31
  get(cli: ExternalAgentCli, sessionId: string): ExternalAgentSessionBinding | undefined;
31
- record(binding: ExternalAgentSessionRecord): void;
32
+ record(binding: ExternalAgentSessionRecord): Promise<void>;
32
33
  private load;
34
+ private loadForWrite;
33
35
  private save;
34
36
  private withLock;
35
37
  }
@@ -1,4 +1,6 @@
1
- import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync, } from "node:fs";
1
+ import { randomUUID } from "node:crypto";
2
+ import { existsSync, readFileSync } from "node:fs";
3
+ import { chmod, mkdir, readFile, rename, rm, stat, writeFile } from "node:fs/promises";
2
4
  import { dirname, join } from "node:path";
3
5
  import { codeShellHome, logger, normalizeCwdPath } from "@cjhyy/code-shell-core/extension";
4
6
  const LOCK_WAIT_MS = 5_000;
@@ -9,6 +11,7 @@ export function defaultExternalAgentSessionStorePath() {
9
11
  }
10
12
  export class ExternalAgentSessionStore {
11
13
  file;
14
+ pendingBindings = new Map();
12
15
  constructor(file = defaultExternalAgentSessionStorePath()) {
13
16
  this.file = file;
14
17
  }
@@ -16,50 +19,40 @@ export class ExternalAgentSessionStore {
16
19
  * persisted array so read-only consumers (for example room discovery) can
17
20
  * correlate worktree sessions without gaining a mutation path. */
18
21
  list() {
19
- return this.load().map((binding) => ({ ...binding }));
22
+ const merged = new Map(this.load().map((binding) => [bindingKey(binding), binding]));
23
+ for (const [key, binding] of this.pendingBindings)
24
+ merged.set(key, binding);
25
+ return [...merged.values()].map((binding) => ({ ...binding }));
20
26
  }
21
27
  get(cli, sessionId) {
22
28
  if (!sessionId)
23
29
  return undefined;
24
- return this.load().find((s) => s.cli === cli && s.sessionId === sessionId);
30
+ const pending = this.pendingBindings.get(bindingKey({ cli, sessionId }));
31
+ return pending ?? this.load().find((s) => s.cli === cli && s.sessionId === sessionId);
25
32
  }
26
- record(binding) {
33
+ async record(binding) {
27
34
  if (!binding.sessionId || !binding.cwd)
28
35
  return;
29
- this.withLock(() => {
30
- const loaded = this.load();
31
- const existing = loaded.find((s) => s.cli === binding.cli && s.sessionId === binding.sessionId);
32
- const now = binding.lastUsedAt ?? binding.updatedAt ?? Date.now();
33
- const next = {
34
- cli: binding.cli,
35
- sessionId: binding.sessionId,
36
- ...((binding.codeShellSessionId ?? existing?.codeShellSessionId)
37
- ? { codeShellSessionId: binding.codeShellSessionId ?? existing?.codeShellSessionId }
38
- : {}),
39
- cwd: normalizeCwdPath(binding.cwd),
40
- ...((binding.workspaceRoot ?? existing?.workspaceRoot)
41
- ? { workspaceRoot: normalizeCwdPath(binding.workspaceRoot ?? existing.workspaceRoot) }
42
- : {}),
43
- ...((binding.worktreePath ?? existing?.worktreePath)
44
- ? { worktreePath: normalizeCwdPath(binding.worktreePath ?? existing.worktreePath) }
45
- : {}),
46
- ...((binding.worktreeBranch ?? existing?.worktreeBranch)
47
- ? { worktreeBranch: binding.worktreeBranch ?? existing?.worktreeBranch }
48
- : {}),
49
- ...((binding.worktreeBaseRef ?? existing?.worktreeBaseRef)
50
- ? { worktreeBaseRef: binding.worktreeBaseRef ?? existing?.worktreeBaseRef }
51
- : {}),
52
- ...((binding.isolation ?? existing?.isolation)
53
- ? { isolation: binding.isolation ?? existing?.isolation }
54
- : {}),
55
- createdAt: binding.createdAt ?? existing?.createdAt ?? now,
56
- lastUsedAt: now,
57
- updatedAt: now,
58
- };
59
- const sessions = loaded.filter((s) => !(s.cli === binding.cli && s.sessionId === binding.sessionId));
60
- sessions.push(next);
61
- this.save(sessions);
62
- });
36
+ const key = bindingKey(binding);
37
+ // Preserve the old immediate-read contract while disk persistence proceeds
38
+ // without blocking the event loop. This instance overlays the pending value
39
+ // on list/get until the atomic write finishes.
40
+ const optimistic = mergeBinding(binding, this.get(binding.cli, binding.sessionId));
41
+ this.pendingBindings.set(key, optimistic);
42
+ try {
43
+ await this.withLock(async () => {
44
+ const loaded = await this.loadForWrite();
45
+ const existing = loaded.find((s) => s.cli === binding.cli && s.sessionId === binding.sessionId);
46
+ const next = mergeBinding(binding, existing);
47
+ const sessions = loaded.filter((s) => !(s.cli === binding.cli && s.sessionId === binding.sessionId));
48
+ sessions.push(next);
49
+ await this.save(sessions);
50
+ });
51
+ }
52
+ finally {
53
+ if (this.pendingBindings.get(key) === optimistic)
54
+ this.pendingBindings.delete(key);
55
+ }
63
56
  }
64
57
  load() {
65
58
  if (!existsSync(this.file))
@@ -80,56 +73,111 @@ export class ExternalAgentSessionStore {
80
73
  return [];
81
74
  }
82
75
  }
83
- save(sessions) {
84
- const dir = dirname(this.file);
85
- if (!existsSync(dir))
86
- mkdirSync(dir, { recursive: true });
87
- const snapshot = { version: 2, sessions };
88
- const tmp = `${this.file}.${process.pid}.${Date.now()}.tmp`;
76
+ async loadForWrite() {
89
77
  try {
90
- writeFileSync(tmp, JSON.stringify(snapshot, null, 2) + "\n", "utf-8");
91
- renameSync(tmp, this.file);
78
+ const raw = await readFile(this.file, "utf-8");
79
+ const parsed = JSON.parse(raw);
80
+ if (!parsed || !Array.isArray(parsed.sessions)) {
81
+ throw new Error("external agent session store has an invalid root");
82
+ }
83
+ return parsed.sessions.filter(isBinding).map(normalizeBinding);
92
84
  }
93
85
  catch (err) {
94
- rmSync(tmp, { force: true });
86
+ if (err.code === "ENOENT")
87
+ return [];
88
+ logger.warn("external_agent_session_store.write_read_failed", {
89
+ cat: "cc",
90
+ file: this.file,
91
+ error: err instanceof Error ? err.message : String(err),
92
+ });
95
93
  throw err;
96
94
  }
97
95
  }
98
- withLock(fn) {
96
+ async save(sessions) {
97
+ const dir = dirname(this.file);
98
+ await mkdir(dir, { recursive: true, mode: 0o700 });
99
+ if (process.platform !== "win32")
100
+ await chmod(dir, 0o700);
101
+ const snapshot = { version: 2, sessions };
102
+ const tmp = `${this.file}.${process.pid}.${randomUUID()}.tmp`;
103
+ try {
104
+ await writeFile(tmp, JSON.stringify(snapshot, null, 2) + "\n", {
105
+ encoding: "utf-8",
106
+ mode: 0o600,
107
+ flag: "wx",
108
+ });
109
+ await rename(tmp, this.file);
110
+ }
111
+ finally {
112
+ await rm(tmp, { force: true }).catch(() => undefined);
113
+ }
114
+ }
115
+ async withLock(fn) {
99
116
  const dir = dirname(this.file);
100
- if (!existsSync(dir))
101
- mkdirSync(dir, { recursive: true });
102
- // TODO: move this sync polling lock to an async write queue; callers can
103
- // otherwise block the event loop for up to LOCK_WAIT_MS under contention.
117
+ await mkdir(dir, { recursive: true, mode: 0o700 });
118
+ if (process.platform !== "win32")
119
+ await chmod(dir, 0o700);
104
120
  const lockDir = `${this.file}.lock`;
105
121
  const deadline = Date.now() + LOCK_WAIT_MS;
106
122
  while (true) {
107
123
  try {
108
- mkdirSync(lockDir);
124
+ await mkdir(lockDir);
109
125
  break;
110
126
  }
111
127
  catch (err) {
112
128
  const code = err.code;
113
129
  if (code !== "EEXIST")
114
130
  throw err;
115
- if (removeStaleLock(lockDir))
131
+ if (await removeStaleLock(lockDir))
116
132
  continue;
117
133
  if (Date.now() >= deadline) {
118
134
  throw new Error(`timed out waiting for external agent session store lock: ${lockDir}`, {
119
135
  cause: err,
120
136
  });
121
137
  }
122
- sleepSync(LOCK_POLL_MS);
138
+ await delay(LOCK_POLL_MS);
123
139
  }
124
140
  }
125
141
  try {
126
- return fn();
142
+ return await fn();
127
143
  }
128
144
  finally {
129
- rmSync(lockDir, { recursive: true, force: true });
145
+ await rm(lockDir, { recursive: true, force: true });
130
146
  }
131
147
  }
132
148
  }
149
+ function bindingKey(binding) {
150
+ return `${binding.cli}\0${binding.sessionId}`;
151
+ }
152
+ function mergeBinding(binding, existing) {
153
+ const now = binding.lastUsedAt ?? binding.updatedAt ?? Date.now();
154
+ return {
155
+ cli: binding.cli,
156
+ sessionId: binding.sessionId,
157
+ ...((binding.codeShellSessionId ?? existing?.codeShellSessionId)
158
+ ? { codeShellSessionId: binding.codeShellSessionId ?? existing?.codeShellSessionId }
159
+ : {}),
160
+ cwd: normalizeCwdPath(binding.cwd),
161
+ ...((binding.workspaceRoot ?? existing?.workspaceRoot)
162
+ ? { workspaceRoot: normalizeCwdPath(binding.workspaceRoot ?? existing.workspaceRoot) }
163
+ : {}),
164
+ ...((binding.worktreePath ?? existing?.worktreePath)
165
+ ? { worktreePath: normalizeCwdPath(binding.worktreePath ?? existing.worktreePath) }
166
+ : {}),
167
+ ...((binding.worktreeBranch ?? existing?.worktreeBranch)
168
+ ? { worktreeBranch: binding.worktreeBranch ?? existing?.worktreeBranch }
169
+ : {}),
170
+ ...((binding.worktreeBaseRef ?? existing?.worktreeBaseRef)
171
+ ? { worktreeBaseRef: binding.worktreeBaseRef ?? existing?.worktreeBaseRef }
172
+ : {}),
173
+ ...((binding.isolation ?? existing?.isolation)
174
+ ? { isolation: binding.isolation ?? existing?.isolation }
175
+ : {}),
176
+ createdAt: binding.createdAt ?? existing?.createdAt ?? now,
177
+ lastUsedAt: now,
178
+ updatedAt: now,
179
+ };
180
+ }
133
181
  function normalizeBinding(binding) {
134
182
  const updatedAt = finiteTimestamp(binding.updatedAt) ?? Date.now();
135
183
  const lastUsedAt = finiteTimestamp(binding.lastUsedAt) ?? updatedAt;
@@ -147,11 +195,11 @@ function normalizeBinding(binding) {
147
195
  function finiteTimestamp(value) {
148
196
  return typeof value === "number" && Number.isFinite(value) ? value : undefined;
149
197
  }
150
- function removeStaleLock(lockDir) {
198
+ async function removeStaleLock(lockDir) {
151
199
  try {
152
- if (Date.now() - statSync(lockDir).mtimeMs <= LOCK_STALE_MS)
200
+ if (Date.now() - (await stat(lockDir)).mtimeMs <= LOCK_STALE_MS)
153
201
  return false;
154
- rmSync(lockDir, { recursive: true, force: true });
202
+ await rm(lockDir, { recursive: true, force: true });
155
203
  return true;
156
204
  }
157
205
  catch (err) {
@@ -160,17 +208,8 @@ function removeStaleLock(lockDir) {
160
208
  return false;
161
209
  }
162
210
  }
163
- function sleepSync(ms) {
164
- try {
165
- const view = new Int32Array(new SharedArrayBuffer(4));
166
- Atomics.wait(view, 0, 0, ms);
167
- }
168
- catch {
169
- const until = Date.now() + ms;
170
- while (Date.now() < until) {
171
- // fallback for runtimes where Atomics.wait is unavailable
172
- }
173
- }
211
+ function delay(ms) {
212
+ return new Promise((resolveDelay) => setTimeout(resolveDelay, ms));
174
213
  }
175
214
  function isBinding(value) {
176
215
  if (!value || typeof value !== "object")
@@ -66,15 +66,19 @@ export class ClaudeEventTranslator {
66
66
  onSystem(message) {
67
67
  if (str(message.subtype) !== "init")
68
68
  return [];
69
- const sessionId = str(message.session_id);
70
- if (sessionId)
71
- this.runtimeSessionId = sessionId;
69
+ const runtimeSessionId = str(message.session_id);
70
+ if (runtimeSessionId)
71
+ this.runtimeSessionId = runtimeSessionId;
72
72
  if (this.sessionStarted)
73
73
  return [];
74
74
  this.sessionStarted = true;
75
+ if (!runtimeSessionId)
76
+ return [];
75
77
  // promptTokens is unknown at init; the field is required, and 0 is honest
76
- // here rather than a guess.
77
- return sessionId ? [{ type: "session_started", sessionId, promptTokens: 0 }] : [];
78
+ // here rather than a guess. The StreamEvent identity is CodeShell's stable
79
+ // business session id; Claude's id is a resume key only and must never
80
+ // replace the renderer's engineSessionId.
81
+ return [{ type: "session_started", sessionId: this.options.sessionId, promptTokens: 0 }];
78
82
  }
79
83
  onStreamEvent(message) {
80
84
  const event = asRecord(message.event);
@@ -21,7 +21,7 @@ type Runner = (opts: {
21
21
  }) => Promise<AgentRunResult>;
22
22
  type SessionStore = {
23
23
  get(cli: DriveCli, sessionId: string): ExternalAgentSessionBinding | undefined;
24
- record(binding: ExternalAgentSessionRecord): void;
24
+ record(binding: ExternalAgentSessionRecord): void | Promise<void>;
25
25
  };
26
26
  export interface DriveAgentToolOptions {
27
27
  foregroundHandoffMs?: number;
@@ -107,6 +107,10 @@ export const driveAgentToolDef = {
107
107
  required: ["prompt"],
108
108
  },
109
109
  };
110
+ /** Foreground runs are not background jobs until the handoff deadline. Keep
111
+ * them visible to conflict detection during that window so another session
112
+ * cannot launch a writer into the same workspace. */
113
+ const foregroundDriveLeases = new Map();
110
114
  function isExternalRuntimeContext(ctx) {
111
115
  return (ctx?.externalRuntime === true);
112
116
  }
@@ -155,11 +159,20 @@ async function resolveDriveWorkspaceRoot(cwd) {
155
159
  function hasRunningDriveWriter(workspaceCwd, workspaceRoot) {
156
160
  if (backgroundJobRegistry.listRunningByCwd(workspaceCwd).some(isDriveAgentJob))
157
161
  return true;
158
- return backgroundJobRegistry
162
+ if (backgroundJobRegistry
159
163
  .list()
160
164
  .some((job) => (job.status === "running" || job.status === "cancelling") &&
161
165
  isDriveAgentJob(job) &&
162
- job.workspaceRoot === workspaceRoot);
166
+ job.workspaceRoot === workspaceRoot)) {
167
+ return true;
168
+ }
169
+ for (const lease of foregroundDriveLeases.values()) {
170
+ if (lease.effectiveWorkspaceRoot === workspaceRoot ||
171
+ lease.workspaceRoot === workspaceRoot) {
172
+ return true;
173
+ }
174
+ }
175
+ return false;
163
176
  }
164
177
  async function prepareDriveWorktree(params) {
165
178
  const gitRoot = await findGitRoot(params.cwd, params.signal);
@@ -401,7 +414,7 @@ function appendAttachmentPrompt(prompt, paths) {
401
414
  }
402
415
  return `${prompt}\n${lines.join("\n")}`;
403
416
  }
404
- function recordSuccessfulSession(store, cli, cwd, result, metadata, includeErroredSession = false) {
417
+ async function recordSuccessfulSession(store, cli, cwd, result, metadata, includeErroredSession = false) {
405
418
  if ((!includeErroredSession && result.isError) || !result.sessionId)
406
419
  return;
407
420
  // When the isolation worktree directory was removed during finalization the
@@ -425,7 +438,7 @@ function recordSuccessfulSession(store, cli, cwd, result, metadata, includeError
425
438
  ? { worktreeBranch: metadata.worktree.session.worktreeBranch }
426
439
  : {};
427
440
  try {
428
- store.record({
441
+ await store.record({
429
442
  cli,
430
443
  sessionId: result.sessionId,
431
444
  ...(metadata.codeShellSessionId ? { codeShellSessionId: metadata.codeShellSessionId } : {}),
@@ -445,21 +458,40 @@ function recordSuccessfulSession(store, cli, cwd, result, metadata, includeError
445
458
  });
446
459
  }
447
460
  }
448
- function duplicateCwdWarning(effectiveWorkspaceCwd, writable) {
461
+ function duplicateCwdError(effectiveWorkspaceCwd, effectiveWorkspaceRoot, writable) {
449
462
  if (!writable)
450
463
  return undefined;
451
464
  const running = backgroundJobRegistry
452
465
  .listRunningByCwd(effectiveWorkspaceCwd)
453
466
  .filter(isDriveAgentJob);
454
- if (running.length === 0)
467
+ const foreground = [...foregroundDriveLeases.values()].filter((lease) => lease.effectiveWorkspaceRoot === effectiveWorkspaceRoot);
468
+ if (running.length === 0 && foreground.length === 0)
455
469
  return undefined;
456
- const jobs = running.map(formatDriveJobListLine).join("; ");
457
- const workspaceRoot = running[0]?.effectiveWorkspaceRoot ?? normalizeCwdPath(effectiveWorkspaceCwd);
458
- return (`Warning: another DriveAgent job is already running in effective workspace ${workspaceRoot}. ` +
459
- "Concurrent writable agents in the same workspace can overwrite each other's work. " +
470
+ const jobs = [
471
+ ...running.map(formatDriveJobListLine),
472
+ ...foreground.map((lease) => `${lease.leaseId} status=foreground ${lease.label}`),
473
+ ].join("; ");
474
+ return (`Error: another writable DriveAgent is already running in effective workspace ${effectiveWorkspaceRoot}. ` +
475
+ "Refusing to start a concurrent writer because it could overwrite the other agent's work. " +
460
476
  `Run DriveAgentJobs(action:"list", cwd:"${effectiveWorkspaceCwd}") before dispatching parallel work for details/cancellation. ` +
461
477
  `Running: ${jobs}`);
462
478
  }
479
+ function acquireForegroundDriveLease(params) {
480
+ const conflict = duplicateCwdError(params.effectiveWorkspaceCwd, params.effectiveWorkspaceRoot, params.writable);
481
+ if (conflict)
482
+ return { error: conflict };
483
+ if (!params.writable)
484
+ return { release: () => undefined };
485
+ const leaseId = newDriveJobId();
486
+ const lease = { leaseId, ...params };
487
+ foregroundDriveLeases.set(leaseId, lease);
488
+ return {
489
+ release: () => {
490
+ if (foregroundDriveLeases.get(leaseId) === lease)
491
+ foregroundDriveLeases.delete(leaseId);
492
+ },
493
+ };
494
+ }
463
495
  function attachDriveCompletion(params) {
464
496
  const { jobId, sessionId, label, cli, cwd, workspaceRoot, isolation, worktree, run, sessionStore, readChangedFiles, recordExternalFileChanges, originClientMessageId, } = params;
465
497
  void run
@@ -481,7 +513,7 @@ function attachDriveCompletion(params) {
481
513
  if (lifecycle) {
482
514
  backgroundJobRegistry.recordWorktreeLifecycle(jobId, lifecycle.action);
483
515
  }
484
- recordSuccessfulSession(sessionStore, cli, cwd, r, { codeShellSessionId: sessionId, workspaceRoot, isolation, worktree, lifecycle }, cancelling);
516
+ void recordSuccessfulSession(sessionStore, cli, cwd, r, { codeShellSessionId: sessionId, workspaceRoot, isolation, worktree, lifecycle }, cancelling);
485
517
  if (changedFiles.length > 0) {
486
518
  recordExternalFileChanges?.({
487
519
  jobId,
@@ -574,34 +606,57 @@ function attachDriveCompletion(params) {
574
606
  });
575
607
  }
576
608
  function trackBackgroundRun(params) {
577
- const warning = duplicateCwdWarning(params.effectiveWorkspaceCwd, params.writable);
609
+ const conflict = duplicateCwdError(params.effectiveWorkspaceCwd, params.effectiveWorkspaceRoot, params.writable);
610
+ if (conflict)
611
+ return { error: conflict };
578
612
  const jobId = newDriveJobId();
579
- const run = params.start();
580
- backgroundJobRegistry.start(jobId, params.sessionId, params.label, {
581
- kind: "drive-agent",
582
- launchCwd: params.cwd,
583
- effectiveWorkspaceCwd: params.effectiveWorkspaceCwd,
584
- workspaceRoot: params.workspaceRoot,
585
- isolation: params.isolation,
586
- ...(params.worktree
587
- ? {
588
- worktreePath: params.worktree.session.worktreePath,
589
- worktreeBranch: params.worktree.session.worktreeBranch,
590
- worktreeBaseRef: params.worktree.session.baseRef,
591
- worktreeCleanup: params.worktree.cleanup,
592
- worktreeBranchPrefix: params.worktree.branchPrefix,
593
- }
594
- : {}),
595
- cli: params.cli,
596
- promptSummary: params.promptSummary,
597
- originClientMessageId: params.originClientMessageId,
598
- abort: async () => {
599
- params.abort();
600
- await run.catch(() => undefined);
601
- },
602
- });
613
+ let run;
614
+ try {
615
+ // Publish ownership before starting the external process. Registry
616
+ // listeners are synchronous and may re-enter session teardown from the
617
+ // start notification; starting first would leave that process orphaned.
618
+ backgroundJobRegistry.start(jobId, params.sessionId, params.label, {
619
+ kind: "drive-agent",
620
+ launchCwd: params.cwd,
621
+ effectiveWorkspaceCwd: params.effectiveWorkspaceCwd,
622
+ workspaceRoot: params.workspaceRoot,
623
+ isolation: params.isolation,
624
+ ...(params.worktree
625
+ ? {
626
+ worktreePath: params.worktree.session.worktreePath,
627
+ worktreeBranch: params.worktree.session.worktreeBranch,
628
+ worktreeBaseRef: params.worktree.session.baseRef,
629
+ worktreeCleanup: params.worktree.cleanup,
630
+ worktreeBranchPrefix: params.worktree.branchPrefix,
631
+ }
632
+ : {}),
633
+ cli: params.cli,
634
+ promptSummary: params.promptSummary,
635
+ originClientMessageId: params.originClientMessageId,
636
+ abort: async () => {
637
+ params.abort();
638
+ await run?.catch(() => undefined);
639
+ },
640
+ });
641
+ }
642
+ catch (error) {
643
+ return {
644
+ error: `Error: failed to register DriveAgent job: ${error instanceof Error ? error.message : String(error)}`,
645
+ };
646
+ }
647
+ if (backgroundJobRegistry.get(jobId)?.status !== "running") {
648
+ return { error: "Error: DriveAgent owner session closed before the job could start." };
649
+ }
650
+ try {
651
+ run = params.start();
652
+ }
653
+ catch (error) {
654
+ const message = error instanceof Error ? error.message : String(error);
655
+ backgroundJobRegistry.finish(jobId, { status: "failed", finalText: message });
656
+ return { error: `Error: failed to start DriveAgent job: ${message}` };
657
+ }
603
658
  attachDriveCompletion({ ...params, jobId, run });
604
- return { jobId, ...(warning ? { warning } : {}) };
659
+ return { jobId };
605
660
  }
606
661
  async function waitForForegroundOrHandoff(run, handoffMs) {
607
662
  if (!Number.isFinite(handoffMs) || handoffMs < 0) {
@@ -674,11 +729,20 @@ export function makeDriveAgentTool(runner = defaultRunner, fixedCli, options = {
674
729
  : process.cwd();
675
730
  const requestedCwd = normalizeCwdPath(rawRequestedCwd);
676
731
  const requestedWorkspaceRoot = await resolveDriveWorkspaceRoot(requestedCwd);
732
+ const declaredEffectiveWorkspaceCwd = typeof args.effectiveWorkspaceCwd === "string" && args.effectiveWorkspaceCwd.trim()
733
+ ? normalizeCwdPath(args.effectiveWorkspaceCwd)
734
+ : undefined;
735
+ const conflictCwd = declaredEffectiveWorkspaceCwd ?? requestedCwd;
736
+ const conflictWorkspaceRoot = conflictCwd === requestedCwd
737
+ ? requestedWorkspaceRoot
738
+ : await resolveDriveWorkspaceRoot(conflictCwd);
677
739
  const requestedIsolation = driveIsolationArg(args.isolation);
678
740
  const hasParallelWriter = !resumeSessionId &&
679
741
  isWritableRun &&
680
- hasRunningDriveWriter(requestedCwd, requestedWorkspaceRoot);
681
- let isolation = requestedIsolation ?? (hasParallelWriter ? "worktree" : "none");
742
+ hasRunningDriveWriter(conflictCwd, conflictWorkspaceRoot);
743
+ const canAutoIsolateEffectiveWorkspace = conflictWorkspaceRoot === requestedWorkspaceRoot;
744
+ let isolation = requestedIsolation ??
745
+ (hasParallelWriter && canAutoIsolateEffectiveWorkspace ? "worktree" : "none");
682
746
  let cwd = isolation === "current" && typeof ctx?.cwd === "string" && ctx.cwd
683
747
  ? normalizeCwdPath(ctx.cwd)
684
748
  : requestedCwd;
@@ -764,22 +828,16 @@ export function makeDriveAgentTool(runner = defaultRunner, fixedCli, options = {
764
828
  workspaceRoot = managedWorktree.session.originalCwd;
765
829
  }
766
830
  catch (error) {
767
- if (requestedIsolation === "worktree") {
768
- return `Error: failed to create DriveAgent isolation worktree: ${error instanceof Error ? error.message : String(error)}`;
769
- }
770
- isolation = "none";
771
- cwd = requestedCwd;
772
- workspaceRoot = requestedWorkspaceRoot;
773
- resumeNote =
774
- `Note: parallel worktree isolation was unavailable; continuing in ${requestedCwd}. ` +
775
- `${error instanceof Error ? error.message : String(error)}`;
831
+ const detail = error instanceof Error ? error.message : String(error);
832
+ return requestedIsolation === "worktree"
833
+ ? `Error: failed to create DriveAgent isolation worktree: ${detail}`
834
+ : `Error: another writable DriveAgent already targets this workspace, and automatic worktree isolation failed. Refusing to run both writers in ${requestedCwd}: ${detail}`;
776
835
  }
777
836
  }
778
837
  const effectiveWorkspaceCwd = managedWorktree
779
838
  ? managedWorktree.session.worktreePath
780
- : typeof args.effectiveWorkspaceCwd === "string" && args.effectiveWorkspaceCwd.trim()
781
- ? normalizeCwdPath(args.effectiveWorkspaceCwd)
782
- : cwd;
839
+ : (declaredEffectiveWorkspaceCwd ?? cwd);
840
+ const effectiveWorkspaceRoot = await resolveDriveWorkspaceRoot(effectiveWorkspaceCwd);
783
841
  // Default to bypassPermissions: this tool is a fire-one-turn delegation to
784
842
  // an external CLI with nobody watching for approvals, and there is no
785
843
  // interactive approval loop here — so under "default" a tool that needs
@@ -825,6 +883,7 @@ export function makeDriveAgentTool(runner = defaultRunner, fixedCli, options = {
825
883
  cli,
826
884
  cwd,
827
885
  effectiveWorkspaceCwd,
886
+ effectiveWorkspaceRoot,
828
887
  workspaceRoot,
829
888
  isolation,
830
889
  worktree: managedWorktree,
@@ -837,16 +896,30 @@ export function makeDriveAgentTool(runner = defaultRunner, fixedCli, options = {
837
896
  recordExternalFileChanges: ctx?.recordExternalFileChanges,
838
897
  originClientMessageId: ctx?.originClientMessageId,
839
898
  });
899
+ if ("error" in tracked) {
900
+ const lifecycle = safeFinalizeDriveWorktree(managedWorktree);
901
+ return appendLifecycleNote(tracked.error, lifecycle);
902
+ }
840
903
  ctx?.runYield?.request("background_notification");
841
904
  return [
842
905
  resumeNote,
843
906
  `已在后台启动 ${cliName}(jobId ${tracked.jobId})。完成后会通知你结果,无需轮询。`,
844
907
  worktreeStartNote(managedWorktree),
845
- tracked.warning,
846
908
  ]
847
909
  .filter(Boolean)
848
910
  .join("\n");
849
911
  }
912
+ const lease = acquireForegroundDriveLease({
913
+ effectiveWorkspaceCwd,
914
+ effectiveWorkspaceRoot,
915
+ workspaceRoot,
916
+ label,
917
+ writable: isWritableRun,
918
+ });
919
+ if ("error" in lease) {
920
+ const lifecycle = safeFinalizeDriveWorktree(managedWorktree);
921
+ return appendLifecycleNote(lease.error, lifecycle);
922
+ }
850
923
  const foregroundAbort = makeAbortController(callerSignal, true);
851
924
  const run = startRun(runner, { ...runOptsBase, signal: foregroundAbort.signal });
852
925
  let result;
@@ -855,15 +928,21 @@ export function makeDriveAgentTool(runner = defaultRunner, fixedCli, options = {
855
928
  }
856
929
  catch (error) {
857
930
  const lifecycle = safeFinalizeDriveWorktree(managedWorktree);
931
+ lease.release();
858
932
  return appendLifecycleNote(`${cliName} 运行出错:${error instanceof Error ? error.message : String(error)}`, lifecycle);
859
933
  }
860
934
  if (result.kind === "handoff" && isValidSessionId(ctx?.sessionId)) {
935
+ // Registration below is synchronous. Release the foreground lease and
936
+ // replace it with the background registry entry in the same event-loop
937
+ // turn, so another dispatch cannot slip into a gap.
938
+ lease.release();
861
939
  const tracked = trackBackgroundRun({
862
940
  sessionId: ctx.sessionId,
863
941
  label,
864
942
  cli,
865
943
  cwd,
866
944
  effectiveWorkspaceCwd,
945
+ effectiveWorkspaceRoot,
867
946
  workspaceRoot,
868
947
  isolation,
869
948
  worktree: managedWorktree,
@@ -876,30 +955,39 @@ export function makeDriveAgentTool(runner = defaultRunner, fixedCli, options = {
876
955
  recordExternalFileChanges: ctx?.recordExternalFileChanges,
877
956
  originClientMessageId: ctx?.originClientMessageId,
878
957
  });
958
+ if ("error" in tracked) {
959
+ foregroundAbort.abort();
960
+ const lifecycle = safeFinalizeDriveWorktree(managedWorktree);
961
+ return appendLifecycleNote(tracked.error, lifecycle);
962
+ }
879
963
  ctx?.runYield?.request("background_notification");
880
964
  return [
881
965
  resumeNote,
882
966
  `${cliName} foreground run exceeded ${foregroundHandoffMs}ms; moved it to background (jobId ${tracked.jobId}). Completion will notify this session, so do not poll.`,
883
967
  worktreeStartNote(managedWorktree),
884
- tracked.warning,
885
968
  ]
886
969
  .filter(Boolean)
887
970
  .join("\n");
888
971
  }
889
- const r = result.kind === "completed" ? result.result : await run;
890
- const lifecycle = safeFinalizeDriveWorktree(managedWorktree);
891
- recordSuccessfulSession(sessionStore, cli, cwd, r, {
892
- codeShellSessionId: ctx?.sessionId,
893
- workspaceRoot,
894
- isolation,
895
- worktree: managedWorktree,
896
- lifecycle,
897
- });
898
- const prefix = resumeNote ? `${resumeNote}\n` : "";
899
- const finalText = appendLifecycleNote(r.finalText, lifecycle);
900
- if (r.isError)
901
- return `${prefix}${cliName} 运行出错(session ${r.sessionId}):\n${finalText}`;
902
- return `${prefix}${cliName} 完成(session ${r.sessionId}):\n${finalText}`;
972
+ try {
973
+ const r = result.kind === "completed" ? result.result : await run;
974
+ const lifecycle = safeFinalizeDriveWorktree(managedWorktree);
975
+ await recordSuccessfulSession(sessionStore, cli, cwd, r, {
976
+ codeShellSessionId: ctx?.sessionId,
977
+ workspaceRoot,
978
+ isolation,
979
+ worktree: managedWorktree,
980
+ lifecycle,
981
+ });
982
+ const prefix = resumeNote ? `${resumeNote}\n` : "";
983
+ const finalText = appendLifecycleNote(r.finalText, lifecycle);
984
+ if (r.isError)
985
+ return `${prefix}${cliName} 运行出错(session ${r.sessionId}):\n${finalText}`;
986
+ return `${prefix}${cliName} 完成(session ${r.sessionId}):\n${finalText}`;
987
+ }
988
+ finally {
989
+ lease.release();
990
+ }
903
991
  };
904
992
  }
905
993
  export const driveAgentTool = makeDriveAgentTool();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cjhyy/code-shell-capability-coding",
3
- "version": "0.8.9",
3
+ "version": "0.8.11",
4
4
  "description": "Coding capability pack for the generic code-shell agent core.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -34,12 +34,13 @@
34
34
  ],
35
35
  "scripts": {
36
36
  "build": "bun run clean && tsc -p tsconfig.json && bun run copy-assets",
37
+ "typecheck": "tsc -p tsconfig.json --noEmit",
37
38
  "copy-assets": "node ../../scripts/copy-assets.mjs dist/prompt src/prompt/*.md && node ../../scripts/copy-assets.mjs dist/tools/apply-patch src/tools/apply-patch/NOTICE.md src/tools/apply-patch/LICENSE-codex",
38
39
  "dev": "bun run copy-assets && tsc -p tsconfig.json --watch --preserveWatchOutput",
39
40
  "clean": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\""
40
41
  },
41
42
  "dependencies": {
42
- "@cjhyy/code-shell-core": "0.8.9"
43
+ "@cjhyy/code-shell-core": "0.8.11"
43
44
  },
44
45
  "engines": {
45
46
  "node": ">=20.10"
@@ -57,4 +58,4 @@
57
58
  "publishConfig": {
58
59
  "access": "public"
59
60
  }
60
- }
61
+ }