@abelxiaoxing/cadence 2.0.1 → 2.0.3

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/src/task-shell.ts CHANGED
@@ -9,7 +9,12 @@ import {
9
9
  truncateToWidth,
10
10
  wrapTextWithAnsi,
11
11
  } from "@earendil-works/pi-tui";
12
- import { MANAGEMENT_GUIDANCE, NativeConnection } from "./native-client.ts";
12
+ import {
13
+ autostartHint,
14
+ departure,
15
+ MANAGEMENT_GUIDANCE,
16
+ NativeConnection,
17
+ } from "./native-client.ts";
13
18
  import {
14
19
  NativeHistory,
15
20
  type NativeViews,
@@ -94,10 +99,10 @@ export function registerTaskShell(pi: ExtensionAPI): void {
94
99
  dismiss?.();
95
100
  // Detach original attachment, not service jobs/provider/auth. Shutdown does
96
101
  // not wait for their lifetime. Retained transport blocks uncertain reuse.
97
- void connection.detach().catch(() => {});
102
+ return connection.detach().catch(() => {});
98
103
  };
99
104
  pi.on("session_start", (_event, ctx) => {
100
- depart();
105
+ void depart();
101
106
  unsubscribe?.();
102
107
  unsubscribe = undefined;
103
108
  selectedTask = undefined;
@@ -108,15 +113,17 @@ export function registerTaskShell(pi: ExtensionAPI): void {
108
113
  });
109
114
  });
110
115
  pi.on("session_shutdown", () => {
111
- depart();
116
+ const leaving = depart();
112
117
  unsubscribe?.();
113
118
  unsubscribe = undefined;
119
+ return departure(leaving);
114
120
  });
115
- pi.on("session_before_switch", depart);
116
- pi.on("session_before_fork", depart);
117
- pi.on("session_before_tree", depart);
118
- pi.on("session_before_compact", depart);
119
- pi.on("model_select", depart);
121
+ const leave = () => void depart();
122
+ pi.on("session_before_switch", leave);
123
+ pi.on("session_before_fork", leave);
124
+ pi.on("session_before_tree", leave);
125
+ pi.on("session_before_compact", leave);
126
+ pi.on("model_select", leave);
120
127
  // Only local dialog input is owned. Detached service work never owns Pi's
121
128
  // conversation/tool lifetime or silently cancels it.
122
129
  pi.on("input", () => (modal ? { action: "handled" as const } : undefined));
@@ -180,14 +187,20 @@ export function registerTaskShell(pi: ExtensionAPI): void {
180
187
  const clean = diagnosticText(text, Number.MAX_SAFE_INTEGER);
181
188
  await ctx.ui.custom<void>((tui, _theme, _keys, done) => {
182
189
  let offset = 0,
183
- rows: string[] = [];
190
+ rows: string[] = [],
191
+ wrapped: number | undefined;
184
192
  const close = () => done();
185
193
  dismiss = close;
186
194
  return {
187
195
  render(width) {
188
- rows = clean
189
- .split("\n")
190
- .flatMap((line) => wrapTextWithAnsi(line, Math.max(1, width)));
196
+ // Wrap the whole text once per width; paging only moves the window.
197
+ const columns = Math.max(1, width);
198
+ if (columns !== wrapped) {
199
+ rows = clean
200
+ .split("\n")
201
+ .flatMap((line) => wrapTextWithAnsi(line, columns));
202
+ wrapped = columns;
203
+ }
191
204
  const height = Math.max(1, Math.min(24, tui.terminal.rows - 4));
192
205
  offset = Math.max(
193
206
  0,
@@ -1019,7 +1032,7 @@ export function registerTaskShell(pi: ExtensionAPI): void {
1019
1032
  }
1020
1033
  } catch (error) {
1021
1034
  ctx.ui.notify(
1022
- `${error instanceof ContractError ? error.code : "unavailable"}: Cadence refused or observation unavailable. No automatic action replay.\n${attached ? "Reopen /cadence for a fresh observation and confirmation. Accepted work may continue. For quarantined/unavailable ownership use cadence-service diagnose --workspace <path>; no locks were cleared." : MANAGEMENT_GUIDANCE}`,
1035
+ `${error instanceof ContractError ? error.code : "unavailable"}: Cadence refused or observation unavailable. No automatic action replay.\n${attached ? "Reopen /cadence for a fresh observation and confirmation. Accepted work may continue. For quarantined/unavailable ownership use cadence-service diagnose --workspace <path>; no locks were cleared." : `${MANAGEMENT_GUIDANCE}${autostartHint()}`}`,
1023
1036
  "error",
1024
1037
  );
1025
1038
  } finally {
package/src/task-store.ts CHANGED
@@ -75,16 +75,11 @@ export function safeDirectory(
75
75
  at = join(at, part);
76
76
  let existing: Stats | undefined;
77
77
  try {
78
- const st = lstatSync(at);
79
- if (!st.isDirectory()) throw new Error("Unsafe directory");
78
+ // One no-follow observation per component: a link is never reported as
79
+ // a directory, and the inspection sees this same, latest observation.
80
80
  const checked = lstatSync(at);
81
- if (
82
- checked.isSymbolicLink() ||
83
- (inspectExisting && !checked.isDirectory())
84
- )
81
+ if (!checked.isDirectory() || checked.isSymbolicLink())
85
82
  throw new Error("Unsafe directory");
86
- // Inspect the latest observation, not an earlier stat whose mode/uid
87
- // may already have changed. No extra IO or cross-call authority cache.
88
83
  existing = checked;
89
84
  } catch (error) {
90
85
  if (!absent(error)) throw error;
@@ -95,7 +90,8 @@ export function safeDirectory(
95
90
  if (!object(mkdirError) || mkdirError.code !== "EEXIST")
96
91
  throw mkdirError;
97
92
  }
98
- if (!lstatSync(at).isDirectory() || lstatSync(at).isSymbolicLink())
93
+ const created = lstatSync(at);
94
+ if (!created.isDirectory() || created.isSymbolicLink())
99
95
  throw new Error("Unsafe directory");
100
96
  }
101
97
  // Outside the ENOENT handler: an inspection refusal is never absence and
@@ -104,12 +100,52 @@ export function safeDirectory(
104
100
  }
105
101
  return true;
106
102
  }
107
- export function readBounded(path: string, max: number): Buffer | undefined {
103
+ const transientWindows = new Set(["EPERM", "EACCES", "EBUSY"]);
104
+ /** Windows refuses to replace, remove or recreate a path while another process
105
+ * holds it (a polling reader, antivirus, a delete still pending), which POSIX
106
+ * never does. Retry the same operation briefly; `recheck` reasserts the
107
+ * caller's expected bytes before every retry, so a retry never overwrites a
108
+ * change. Other errors, including EEXIST and ENOENT, keep their meaning. */
109
+ export function retryWindowsFileOperation<T>(
110
+ operation: () => T,
111
+ recheck?: () => void,
112
+ ): T {
113
+ if (process.platform !== "win32") return operation();
114
+ const deadline = performance.now() + 2000;
115
+ for (let delay = 2; ; delay = Math.min(delay * 2, 50)) {
116
+ try {
117
+ return operation();
118
+ } catch (error) {
119
+ if (
120
+ !object(error) ||
121
+ !transientWindows.has(String(error.code)) ||
122
+ performance.now() >= deadline
123
+ )
124
+ throw error;
125
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, delay);
126
+ recheck?.();
127
+ }
128
+ }
129
+ }
130
+ /** `packaged` reads the installed package's own files (web assets, Agent
131
+ * profiles). A package manager may hard-link them into its store (Bun's default
132
+ * backend), so a second link is not an alias of workspace or task state; their
133
+ * callers bind content instead. Every other read stays single-link. */
134
+ export function readBounded(
135
+ path: string,
136
+ max: number,
137
+ packaged = false,
138
+ ): Buffer | undefined {
108
139
  if (!safeDirectory(dirname(path))) return undefined;
109
140
  let fd: number;
110
141
  try {
111
142
  const st = lstatSync(path);
112
- if (!st.isFile() || st.isSymbolicLink() || st.nlink !== 1 || st.size > max)
143
+ if (
144
+ !st.isFile() ||
145
+ st.isSymbolicLink() ||
146
+ (!packaged && st.nlink !== 1) ||
147
+ st.size > max
148
+ )
113
149
  throw new Error("Unsafe or oversized file");
114
150
  fd = openSync(path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
115
151
  } catch (error) {
@@ -118,7 +154,7 @@ export function readBounded(path: string, max: number): Buffer | undefined {
118
154
  }
119
155
  try {
120
156
  const st = fstatSync(fd);
121
- if (!st.isFile() || st.nlink !== 1 || st.size > max)
157
+ if (!st.isFile() || (!packaged && st.nlink !== 1) || st.size > max)
122
158
  throw new Error("Unsafe or oversized file");
123
159
  // Read at most max+1 even if another editor grows the file after stat.
124
160
  const buffer = Buffer.alloc(max + 1);
@@ -215,6 +251,42 @@ function canonicalAmbientRoot(path: string): string {
215
251
  return join(canonicalAmbientRoot(parent), full.slice(parent.length));
216
252
  }
217
253
  }
254
+ function privateRootFor(canonicalRoot: string, stateHome: string): string {
255
+ return join(
256
+ canonicalAmbientRoot(stateHome),
257
+ "cadence-assistant-v1",
258
+ createHash("sha256").update(canonicalRoot).digest("hex"),
259
+ );
260
+ }
261
+ function contains(root: string, path: string): boolean {
262
+ const within = relative(root, resolve(path));
263
+ return (
264
+ !within ||
265
+ (!within.startsWith(`..${sep}`) && within !== ".." && !isAbsolute(within))
266
+ );
267
+ }
268
+ /** Private history, locks and access material must stay outside the working
269
+ * copy, or change observation, check freshness and review would see them. */
270
+ export function stateHomeInside(
271
+ root: string,
272
+ stateHome = defaultStateHome(),
273
+ ): boolean {
274
+ const canonical = realpathSync(root);
275
+ return contains(canonical, privateRootFor(canonical, stateHome));
276
+ }
277
+ export const LANDING_WORKSPACE = "cadence";
278
+ /** A directory that contains the private state home (the home directory and
279
+ * its ancestors) can never be a workspace; use ~/cadence instead of refusing.
280
+ * Observation only: the caller decides whether the landing may be created. */
281
+ export function effectiveWorkspace(
282
+ requested: string,
283
+ stateHome?: string,
284
+ home = homedir(),
285
+ ): { workspace: string; redirected: boolean } {
286
+ if (!stateHomeInside(resolveWorkspace(requested).root, stateHome))
287
+ return { workspace: requested, redirected: false };
288
+ return { workspace: join(home, LANDING_WORKSPACE), redirected: true };
289
+ }
218
290
  export interface CheckoutLock {
219
291
  schema: "cadence-assistant-lock/1";
220
292
  token: string;
@@ -230,16 +302,8 @@ export class TaskStore {
230
302
  constructor(root: string, stateHome = defaultStateHome()) {
231
303
  this.root = realpathSync(root);
232
304
  this.tasks = join(this.root, ".cadence", "assistant", "tasks");
233
- this.privateRoot = join(
234
- canonicalAmbientRoot(stateHome),
235
- "cadence-assistant-v1",
236
- createHash("sha256").update(this.root).digest("hex"),
237
- );
238
- const within = relative(this.root, resolve(this.privateRoot));
239
- if (
240
- !within ||
241
- (!within.startsWith(`..${sep}`) && within !== ".." && !isAbsolute(within))
242
- )
305
+ this.privateRoot = privateRootFor(this.root, stateHome);
306
+ if (contains(this.root, this.privateRoot))
243
307
  throw new Error("Private state home must be outside the working copy");
244
308
  }
245
309
  get lockPath(): string {
@@ -310,7 +374,9 @@ export class TaskStore {
310
374
  };
311
375
  let fd: number;
312
376
  try {
313
- fd = openSync(this.lockPath, "wx", 0o600);
377
+ fd = retryWindowsFileOperation(() =>
378
+ openSync(this.lockPath, "wx", 0o600),
379
+ );
314
380
  } catch {
315
381
  throw new Error(
316
382
  "Checkout busy or unresolved lock; inspect and explicitly recover only a dead owner",
@@ -332,7 +398,10 @@ export class TaskStore {
332
398
  if (lock.pid !== process.pid || lock.host !== hostname())
333
399
  throw new Error("Only the owning process may release a lock");
334
400
  this.assertOwner(lock);
335
- unlinkSync(this.lockPath);
401
+ retryWindowsFileOperation(
402
+ () => unlinkSync(this.lockPath),
403
+ () => this.assertOwner(lock),
404
+ );
336
405
  }
337
406
  recover(expected: CheckoutLock, acknowledgeEscapedProcesses: boolean): void {
338
407
  if (!acknowledgeEscapedProcesses || expected.host !== hostname())
@@ -426,7 +495,17 @@ export class TaskStore {
426
495
  throw new Error(
427
496
  "Task content changed; replacement preserved without overwrite",
428
497
  );
429
- renameSync(temporary, join(dir, "task.json"));
498
+ retryWindowsFileOperation(
499
+ () => renameSync(temporary, join(dir, "task.json")),
500
+ () => {
501
+ this.assertOwner(lock);
502
+ const now = readBounded(join(dir, "task.json"), MAX_RECORD_BYTES);
503
+ if ((now ? contentIdentity(now) : undefined) !== expectedIdentity)
504
+ throw new Error(
505
+ "Task content changed; replacement preserved without overwrite",
506
+ );
507
+ },
508
+ );
430
509
  return next;
431
510
  } finally {
432
511
  if (!held) this.release(lock);
@@ -8,6 +8,9 @@ import {
8
8
  TextSanitizer,
9
9
  } from "./worker-redaction.mjs";
10
10
 
11
+ /** Matches the stock bash renderer's update throttle. */
12
+ const UPDATE_INTERVAL_MS = 100;
13
+
11
14
  /** No stock bash definition/OutputAccumulator: raw output is never spilled. */
12
15
  export function captureBash(
13
16
  cwd,
@@ -57,6 +60,39 @@ export function captureBash(
57
60
  prefix.grow(Buffer.byteLength(text));
58
61
  output += text;
59
62
  };
63
+ const refuse = () => {
64
+ failed = true;
65
+ local.abort();
66
+ fail();
67
+ };
68
+ // Previews only: the final result carries every byte. Windows pipes
69
+ // deliver chatty output as thousands of small chunks, and each update is
70
+ // a durable capture detail, so throttle like the stock bash renderer.
71
+ let lastUpdate = Number.NEGATIVE_INFINITY;
72
+ let pending;
73
+ const publish = () => {
74
+ pending = undefined;
75
+ if (failed) return;
76
+ lastUpdate = performance.now();
77
+ try {
78
+ let tail = output.slice(-4096);
79
+ if (/^[\uDC00-\uDFFF]/.test(tail)) tail = tail.slice(1);
80
+ onUpdate?.({
81
+ content: [{ type: "text", text: tail }],
82
+ details:
83
+ output.length > tail.length
84
+ ? {
85
+ truncation: {
86
+ observedBytes: Buffer.byteLength(output),
87
+ updatePreview: true,
88
+ },
89
+ }
90
+ : {},
91
+ });
92
+ } catch {
93
+ refuse();
94
+ }
95
+ };
60
96
  try {
61
97
  const result = await createLocalBashOperations().exec(command, cwd, {
62
98
  signal: local.signal,
@@ -74,24 +110,11 @@ export function captureBash(
74
110
  )
75
111
  throw new ContractError("oversize");
76
112
  append(sanitizer.write(decoder.decode(data, { stream: true })));
77
- let tail = output.slice(-4096);
78
- if (/^[\uDC00-\uDFFF]/.test(tail)) tail = tail.slice(1);
79
- onUpdate?.({
80
- content: [{ type: "text", text: tail }],
81
- details:
82
- output.length > tail.length
83
- ? {
84
- truncation: {
85
- observedBytes: Buffer.byteLength(output),
86
- updatePreview: true,
87
- },
88
- }
89
- : {},
90
- });
113
+ const since = performance.now() - lastUpdate;
114
+ if (since >= UPDATE_INTERVAL_MS) publish();
115
+ else pending ??= setTimeout(publish, UPDATE_INTERVAL_MS - since);
91
116
  } catch {
92
- failed = true;
93
- local.abort();
94
- fail();
117
+ refuse();
95
118
  } finally {
96
119
  raw?.release();
97
120
  }
@@ -117,6 +140,8 @@ export function captureBash(
117
140
  details: {},
118
141
  };
119
142
  } finally {
143
+ // No preview may follow the result.
144
+ clearTimeout(pending);
120
145
  prefix.release();
121
146
  signal?.removeEventListener("abort", abort);
122
147
  }