@abelxiaoxing/cadence 2.0.0 → 2.0.2

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,11 @@ 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
+ MANAGEMENT_GUIDANCE,
15
+ NativeConnection,
16
+ } from "./native-client.ts";
13
17
  import {
14
18
  NativeHistory,
15
19
  type NativeViews,
@@ -180,14 +184,20 @@ export function registerTaskShell(pi: ExtensionAPI): void {
180
184
  const clean = diagnosticText(text, Number.MAX_SAFE_INTEGER);
181
185
  await ctx.ui.custom<void>((tui, _theme, _keys, done) => {
182
186
  let offset = 0,
183
- rows: string[] = [];
187
+ rows: string[] = [],
188
+ wrapped: number | undefined;
184
189
  const close = () => done();
185
190
  dismiss = close;
186
191
  return {
187
192
  render(width) {
188
- rows = clean
189
- .split("\n")
190
- .flatMap((line) => wrapTextWithAnsi(line, Math.max(1, width)));
193
+ // Wrap the whole text once per width; paging only moves the window.
194
+ const columns = Math.max(1, width);
195
+ if (columns !== wrapped) {
196
+ rows = clean
197
+ .split("\n")
198
+ .flatMap((line) => wrapTextWithAnsi(line, columns));
199
+ wrapped = columns;
200
+ }
191
201
  const height = Math.max(1, Math.min(24, tui.terminal.rows - 4));
192
202
  offset = Math.max(
193
203
  0,
@@ -1019,7 +1029,7 @@ export function registerTaskShell(pi: ExtensionAPI): void {
1019
1029
  }
1020
1030
  } catch (error) {
1021
1031
  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}`,
1032
+ `${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
1033
  "error",
1024
1034
  );
1025
1035
  } 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
  }