@abelxiaoxing/cadence 1.0.10 → 1.0.12

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.
@@ -8,7 +8,7 @@ import {
8
8
  type Stats,
9
9
  statSync,
10
10
  } from "node:fs";
11
- import { basename, join, relative, resolve } from "node:path";
11
+ import { basename, dirname, join, relative, resolve, sep } from "node:path";
12
12
 
13
13
  export const TOOL_LIMITS = {
14
14
  maxReadBytes: 50 * 1024,
@@ -19,6 +19,115 @@ export const TOOL_LIMITS = {
19
19
  maxEntries: 20000,
20
20
  } as const;
21
21
 
22
+ function globToRegExp(pattern: string): RegExp | { error: string } {
23
+ if (pattern.length === 0) return { error: "missing pattern" };
24
+ if (pattern.length > TOOL_LIMITS.maxGrepPattern) {
25
+ return {
26
+ error: `pattern exceeds ${TOOL_LIMITS.maxGrepPattern} characters`,
27
+ };
28
+ }
29
+ let source = "^";
30
+ for (let i = 0; i < pattern.length; i++) {
31
+ const ch = pattern[i];
32
+ if (ch === "*" && pattern[i + 1] === "*") {
33
+ const next = pattern[i + 2];
34
+ if (next === "/") {
35
+ source += "(?:.*/)?";
36
+ i += 2;
37
+ continue;
38
+ }
39
+ source += ".*";
40
+ i += 1;
41
+ continue;
42
+ }
43
+ if (ch === "*") {
44
+ source += "[^/]*";
45
+ continue;
46
+ }
47
+ if (ch === "?") {
48
+ source += "[^/]";
49
+ continue;
50
+ }
51
+ if (ch === "[") {
52
+ let end = i + 1;
53
+ if (pattern[end] === "!" || pattern[end] === "^") end++;
54
+ while (end < pattern.length && pattern[end] !== "]") end++;
55
+ if (end === pattern.length) {
56
+ source += "\\[";
57
+ continue;
58
+ }
59
+ const raw = pattern.slice(i + 1, end);
60
+ const negated = raw.startsWith("!") || raw.startsWith("^");
61
+ const body = raw.slice(negated ? 1 : 0);
62
+ if (body.length === 0 || body.includes("/")) {
63
+ return { error: "invalid glob character class" };
64
+ }
65
+ const escaped = [...body]
66
+ .map((value, index) => {
67
+ if (value === "\\" || value === "[" || value === "]") {
68
+ return `\\${value}`;
69
+ }
70
+ return value === "^" && index === 0 ? "\\^" : value;
71
+ })
72
+ .join("");
73
+ source += `[${negated ? "^" : ""}${escaped}]`;
74
+ i = end;
75
+ continue;
76
+ }
77
+ if ("\\^$+()[]{}|.".includes(ch)) source += `\\${ch}`;
78
+ else source += ch;
79
+ }
80
+ source += "$";
81
+ try {
82
+ return new RegExp(source, "u");
83
+ } catch {
84
+ return { error: "invalid glob pattern" };
85
+ }
86
+ }
87
+
88
+ function boundedInteger(value: unknown): number | undefined {
89
+ return typeof value === "number" && Number.isInteger(value) && value > 0
90
+ ? value
91
+ : undefined;
92
+ }
93
+
94
+ function isWithinPath(path: string, parent: string): boolean {
95
+ return path === parent || path.startsWith(`${parent}${sep}`);
96
+ }
97
+
98
+ function isAllowedContent(path: string, allowedPaths?: string[]): boolean {
99
+ return (
100
+ allowedPaths === undefined ||
101
+ allowedPaths.some((allowed) => isWithinPath(path, allowed))
102
+ );
103
+ }
104
+
105
+ function isRelatedToAllowedPath(
106
+ path: string,
107
+ allowedPaths?: string[],
108
+ ): boolean {
109
+ return (
110
+ allowedPaths === undefined ||
111
+ allowedPaths.some(
112
+ (allowed) => isWithinPath(path, allowed) || isWithinPath(allowed, path),
113
+ )
114
+ );
115
+ }
116
+
117
+ function commonAncestor(paths: string[]): string {
118
+ let candidate = paths[0];
119
+ while (!paths.every((path) => isWithinPath(path, candidate))) {
120
+ const parent = dirname(candidate);
121
+ if (parent === candidate) return candidate;
122
+ candidate = parent;
123
+ }
124
+ return candidate;
125
+ }
126
+
127
+ function relativePath(base: string, path: string): string {
128
+ return (relative(base, path) || ".").split(sep).join("/");
129
+ }
130
+
22
131
  export type Observation =
23
132
  | { kind: "file"; path: string }
24
133
  | { kind: "dir"; path: string };
@@ -38,11 +147,13 @@ interface Options {
38
147
  type PathResult =
39
148
  | { ok: true; abs: string; rel: string }
40
149
  | { ok: false; error: string };
150
+ type ResolvedPath = Extract<PathResult, { ok: true }>;
41
151
 
42
152
  function resolveScoped(
43
153
  roots: string[],
44
154
  input: unknown,
45
155
  allowedPaths?: string[],
156
+ allowScopedAncestor = false,
46
157
  ): PathResult {
47
158
  if (typeof input !== "string" || input.length === 0) {
48
159
  return { ok: false, error: "missing path" };
@@ -69,7 +180,9 @@ function resolveScoped(
69
180
  if (
70
181
  allowedPaths &&
71
182
  !allowedPaths.some(
72
- (allowed) => abs === allowed || abs.startsWith(`${allowed}/`),
183
+ (allowed) =>
184
+ isWithinPath(abs, allowed) ||
185
+ (allowScopedAncestor && isWithinPath(allowed, abs)),
73
186
  )
74
187
  ) {
75
188
  continue;
@@ -135,6 +248,37 @@ export function createScopedTools(opts: Options): ScopedToolDef[] {
135
248
  const roots = opts.roots.map((r) => resolve(r));
136
249
  const allowedPaths = opts.allowedPaths?.map((p) => resolve(p));
137
250
  const observe = opts.observer ?? (() => {});
251
+ const virtualRoot = commonAncestor(roots);
252
+
253
+ function resolveScanTargets(
254
+ input: unknown,
255
+ ):
256
+ | { ok: true; targets: ResolvedPath[]; virtual: boolean }
257
+ | { ok: false; error: string } {
258
+ if (input !== undefined) {
259
+ const resolved = resolveScoped(roots, input, allowedPaths, true);
260
+ return resolved.ok
261
+ ? { ok: true, targets: [resolved], virtual: false }
262
+ : resolved;
263
+ }
264
+ const targets: ResolvedPath[] = [];
265
+ for (const root of roots) {
266
+ const resolved = resolveScoped([root], ".", allowedPaths, true);
267
+ if (resolved.ok) targets.push(resolved);
268
+ }
269
+ return targets.length > 0
270
+ ? { ok: true, targets, virtual: roots.length > 1 }
271
+ : {
272
+ ok: false,
273
+ error: "path is outside the declared read/write scope",
274
+ };
275
+ }
276
+
277
+ function rootRelativePath(path: string, virtual: boolean): string {
278
+ if (virtual) return relativePath(virtualRoot, path);
279
+ const root = roots.find((candidate) => isWithinPath(path, candidate));
280
+ return relativePath(root ?? roots[0], path);
281
+ }
138
282
 
139
283
  const readTool: ScopedToolDef = {
140
284
  name: "read",
@@ -153,21 +297,39 @@ export function createScopedTools(opts: Options): ScopedToolDef[] {
153
297
  const text = utf8Text(resolved.abs);
154
298
  if (text === null) return { ok: false, error: "not valid UTF-8 text" };
155
299
  observe({ kind: "file", path: resolved.rel });
156
- const bytes = Buffer.byteLength(text, "utf8");
157
- const lines = text.split("\n").length;
158
- const truncated =
159
- bytes > TOOL_LIMITS.maxReadBytes || lines > TOOL_LIMITS.maxReadLines;
160
- let content = text;
161
- if (bytes > TOOL_LIMITS.maxReadBytes) {
162
- content = Buffer.from(text, "utf8")
300
+ const allLines = text.split("\n");
301
+ const offset = boundedInteger(params.offset);
302
+ const limit = boundedInteger(params.limit);
303
+ if (offset !== undefined && offset > allLines.length) {
304
+ return {
305
+ ok: false,
306
+ error: `offset ${offset} exceeds file length (${allLines.length} lines)`,
307
+ };
308
+ }
309
+ const start = offset === undefined ? 0 : Math.max(0, offset - 1);
310
+ const windowed =
311
+ offset === undefined && limit === undefined
312
+ ? allLines
313
+ : allLines.slice(
314
+ start,
315
+ limit === undefined ? undefined : start + limit,
316
+ );
317
+ let content = windowed.join("\n");
318
+ let truncated =
319
+ Buffer.byteLength(content, "utf8") > TOOL_LIMITS.maxReadBytes ||
320
+ windowed.length > TOOL_LIMITS.maxReadLines;
321
+ if (Buffer.byteLength(content, "utf8") > TOOL_LIMITS.maxReadBytes) {
322
+ content = Buffer.from(content, "utf8")
163
323
  .subarray(0, TOOL_LIMITS.maxReadBytes)
164
324
  .toString("utf8");
325
+ truncated = true;
165
326
  }
166
327
  if (content.split("\n").length > TOOL_LIMITS.maxReadLines) {
167
328
  content = content
168
329
  .split("\n")
169
330
  .slice(0, TOOL_LIMITS.maxReadLines)
170
331
  .join("\n");
332
+ truncated = true;
171
333
  }
172
334
  return { ok: true, content, truncated };
173
335
  },
@@ -188,8 +350,8 @@ export function createScopedTools(opts: Options): ScopedToolDef[] {
188
350
  error: `pattern exceeds ${TOOL_LIMITS.maxGrepPattern} characters`,
189
351
  };
190
352
  }
191
- const resolved = resolveScoped(roots, params.path ?? ".", allowedPaths);
192
- if (!resolved.ok) return { ok: false, error: resolved.error };
353
+ const scan = resolveScanTargets(params.path);
354
+ if (!scan.ok) return { ok: false, error: scan.error };
193
355
  let regex: RegExp;
194
356
  try {
195
357
  regex = new RegExp(pattern, "u");
@@ -199,20 +361,29 @@ export function createScopedTools(opts: Options): ScopedToolDef[] {
199
361
  const start = performance.now();
200
362
  const matches: { path: string; line: number; text: string }[] = [];
201
363
  let scanned = 0;
202
- const files = collectFiles(resolved.abs, [], TOOL_LIMITS.maxEntries);
364
+ const files: string[] = [];
365
+ for (const target of scan.targets) {
366
+ collectFiles(target.abs, files, TOOL_LIMITS.maxEntries, allowedPaths);
367
+ }
368
+ files.sort((left, right) => {
369
+ const leftPath = rootRelativePath(left, scan.virtual);
370
+ const rightPath = rootRelativePath(right, scan.virtual);
371
+ return leftPath < rightPath ? -1 : leftPath > rightPath ? 1 : 0;
372
+ });
203
373
  for (const file of files) {
204
374
  if (scanned >= TOOL_LIMITS.maxGrepFiles) break;
205
375
  if (performance.now() - start > TOOL_LIMITS.maxGrepMillis) break;
206
376
  scanned++;
207
377
  const text = utf8Text(file);
208
378
  if (text === null) continue;
209
- observe({ kind: "file", path: relative(roots[0], file) });
379
+ const path = rootRelativePath(file, scan.virtual);
380
+ observe({ kind: "file", path });
210
381
  const lines = text.split("\n");
211
382
  for (let i = 0; i < lines.length; i++) {
212
383
  regex.lastIndex = 0;
213
384
  if (regex.test(lines[i])) {
214
385
  matches.push({
215
- path: relative(roots[0], file),
386
+ path,
216
387
  line: i + 1,
217
388
  text: lines[i].slice(0, 500),
218
389
  });
@@ -228,18 +399,41 @@ export function createScopedTools(opts: Options): ScopedToolDef[] {
228
399
  description:
229
400
  "List a directory within the approved scope (max 20,000 entries, stable code-unit order).",
230
401
  async execute(params) {
231
- const resolved = resolveScoped(roots, params.path ?? ".", allowedPaths);
232
- if (!resolved.ok) return { ok: false, error: resolved.error };
233
- let st: Stats | undefined;
234
- try {
235
- st = statSync(resolved.abs);
236
- } catch {
237
- return { ok: false, error: "directory does not exist" };
402
+ const scan = resolveScanTargets(params.path);
403
+ if (!scan.ok) return { ok: false, error: scan.error };
404
+ for (const target of scan.targets) {
405
+ let st: Stats | undefined;
406
+ try {
407
+ st = statSync(target.abs);
408
+ } catch {
409
+ return { ok: false, error: "directory does not exist" };
410
+ }
411
+ if (!st.isDirectory()) return { ok: false, error: "not a directory" };
412
+ observe({
413
+ kind: "dir",
414
+ path: rootRelativePath(target.abs, scan.virtual),
415
+ });
416
+ }
417
+ if (scan.virtual) {
418
+ const names = new Set(
419
+ scan.targets.map(
420
+ (target) => relativePath(virtualRoot, target.abs).split("/")[0],
421
+ ),
422
+ );
423
+ return {
424
+ ok: true,
425
+ entries: [...names]
426
+ .sort()
427
+ .slice(0, TOOL_LIMITS.maxEntries)
428
+ .map((name) => ({ name, type: "dir" })),
429
+ };
238
430
  }
239
- if (!st.isDirectory()) return { ok: false, error: "not a directory" };
240
- observe({ kind: "dir", path: resolved.rel });
431
+ const resolved = scan.targets[0];
241
432
  const entries = readdirSync(resolved.abs, { withFileTypes: true })
242
433
  .filter((e) => !isHidden(e.name, roots))
434
+ .filter((e) =>
435
+ isRelatedToAllowedPath(join(resolved.abs, e.name), allowedPaths),
436
+ )
243
437
  .slice(0, TOOL_LIMITS.maxEntries)
244
438
  .sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
245
439
  return {
@@ -255,33 +449,79 @@ export function createScopedTools(opts: Options): ScopedToolDef[] {
255
449
  const findTool: ScopedToolDef = {
256
450
  name: "find",
257
451
  description:
258
- "Recursively list paths within the approved scope (max 20,000 entries, stable code-unit order).",
452
+ "Search for files by glob pattern within the approved scope; paths are relative to the search directory (max 20,000 entries).",
259
453
  async execute(params) {
260
- const resolved = resolveScoped(roots, params.path ?? ".", allowedPaths);
261
- if (!resolved.ok) return { ok: false, error: resolved.error };
262
- let st: Stats | undefined;
263
- try {
264
- st = statSync(resolved.abs);
265
- } catch {
266
- return { ok: false, error: "directory does not exist" };
454
+ const pattern = params.pattern;
455
+ if (typeof pattern !== "string" || pattern.length === 0) {
456
+ return { ok: false, error: "missing pattern" };
457
+ }
458
+ const limit =
459
+ params.limit === undefined
460
+ ? TOOL_LIMITS.maxEntries
461
+ : boundedInteger(params.limit);
462
+ if (limit === undefined) {
463
+ return { ok: false, error: "limit must be a positive integer" };
267
464
  }
268
- if (!st.isDirectory()) return { ok: false, error: "not a directory" };
269
- observe({ kind: "dir", path: resolved.rel });
270
- const out = collectFiles(resolved.abs, [], TOOL_LIMITS.maxEntries);
271
- return { ok: true, entries: out.slice(0, TOOL_LIMITS.maxEntries).sort() };
465
+ const scan = resolveScanTargets(params.path);
466
+ if (!scan.ok) return { ok: false, error: scan.error };
467
+ for (const target of scan.targets) {
468
+ let st: Stats | undefined;
469
+ try {
470
+ st = statSync(target.abs);
471
+ } catch {
472
+ return { ok: false, error: "directory does not exist" };
473
+ }
474
+ if (!st.isDirectory()) return { ok: false, error: "not a directory" };
475
+ observe({
476
+ kind: "dir",
477
+ path: rootRelativePath(target.abs, scan.virtual),
478
+ });
479
+ }
480
+ const matcher = globToRegExp(pattern);
481
+ if ("error" in matcher) {
482
+ return { ok: false, error: matcher.error };
483
+ }
484
+ const files: string[] = [];
485
+ for (const target of scan.targets) {
486
+ collectFiles(target.abs, files, TOOL_LIMITS.maxEntries, allowedPaths);
487
+ }
488
+ const findBase = scan.virtual ? virtualRoot : scan.targets[0].abs;
489
+ const out = files.map((abs) => relativePath(findBase, abs));
490
+ const entries = out.filter(
491
+ (rel) => matcher.test(rel) || matcher.test(basename(rel)),
492
+ );
493
+ return {
494
+ ok: true,
495
+ entries: entries
496
+ .sort()
497
+ .slice(0, Math.min(limit, TOOL_LIMITS.maxEntries)),
498
+ };
272
499
  },
273
500
  };
274
501
 
275
- function collectFiles(dir: string, out: string[], limit: number): string[] {
502
+ function collectFiles(
503
+ dir: string,
504
+ out: string[],
505
+ limit: number,
506
+ scopedPaths?: string[],
507
+ ): string[] {
276
508
  if (out.length >= limit) return out;
509
+ const target = statSync(dir);
510
+ if (target.isFile()) {
511
+ if (isAllowedContent(dir, scopedPaths)) out.push(dir);
512
+ return out;
513
+ }
514
+ if (!target.isDirectory()) return out;
277
515
  const entries = readdirSync(dir, { withFileTypes: true });
278
516
  for (const e of entries) {
279
517
  if (out.length >= limit) break;
280
518
  if (isHidden(e.name, roots)) continue;
281
519
  const full = join(dir, e.name);
282
520
  if (e.isDirectory()) {
283
- collectFiles(full, out, limit);
284
- } else if (e.isFile()) {
521
+ if (isRelatedToAllowedPath(full, scopedPaths)) {
522
+ collectFiles(full, out, limit, scopedPaths);
523
+ }
524
+ } else if (e.isFile() && isAllowedContent(full, scopedPaths)) {
285
525
  out.push(full);
286
526
  }
287
527
  }
@@ -87,6 +87,11 @@ type ActivityTheme = {
87
87
  fg?: (color: ActivityColor, text: string) => string;
88
88
  };
89
89
  type ActivityUi = Pick<ExtensionUIContext, "setWidget" | "setStatus">;
90
+ type ActivityRenderContext = {
91
+ args?: unknown;
92
+ executionStarted?: boolean;
93
+ isError?: boolean;
94
+ };
90
95
  type TimerHandle = ReturnType<typeof setInterval>;
91
96
 
92
97
  export interface ActivityControllerOptions {
@@ -734,12 +739,41 @@ export function renderActivityResult(
734
739
  result: unknown,
735
740
  options: ToolRenderResultOptions,
736
741
  theme?: ActivityTheme,
742
+ context?: ActivityRenderContext,
737
743
  ): Component {
738
744
  const details = asRecord(asRecord(result)?.details);
739
745
  const activity = details?.[ACTIVITY_DETAILS_KEY];
740
- if (isActivityDisplay(activity)) {
746
+ const failed = context?.isError === true;
747
+ const args = asRecord(context?.args);
748
+ const failedRun = failed && args?.action === "run";
749
+ if (isActivityDisplay(activity) && !failed) {
741
750
  return new ActivityInlineComponent(activity, theme, options.expanded);
742
751
  }
752
+ if (failedRun) {
753
+ const snapshot = identityFromDispatchArgs(asRecord(context?.args)?.request);
754
+ const display: ActivityDisplay = {
755
+ version: 1,
756
+ kind: "activityDisplay",
757
+ requestId: isActivityDisplay(activity)
758
+ ? activity.requestId
759
+ : snapshot.requestId,
760
+ role: isActivityDisplay(activity) ? activity.role : snapshot.role,
761
+ phase: isActivityDisplay(activity) ? activity.phase : snapshot.phase,
762
+ objective: isActivityDisplay(activity)
763
+ ? activity.objective
764
+ : snapshot.objective,
765
+ state: "failed",
766
+ tone: "error",
767
+ elapsedMs: isActivityDisplay(activity) ? activity.elapsedMs : 0,
768
+ reason: sanitizeFailureReason(
769
+ isActivityDisplay(activity) ? activity.reason : undefined,
770
+ ),
771
+ };
772
+ return new ActivityInlineComponent(display, theme);
773
+ }
774
+ if (failed) {
775
+ return new Text(`${dispatchLabel(args?.action)} failed`, 0, 0);
776
+ }
743
777
  const content = asRecord(result)?.content;
744
778
  const text = Array.isArray(content)
745
779
  ? content
@@ -750,6 +784,11 @@ export function renderActivityResult(
750
784
  return new Text(text, 0, 0);
751
785
  }
752
786
 
787
+ function dispatchLabel(action: unknown): string {
788
+ const value = sanitizeDisplayText(action ?? "");
789
+ return value.length > 0 ? `Abel Dispatch ${value}` : "Abel Dispatch";
790
+ }
791
+
753
792
  function isActivityDisplay(value: unknown): value is ActivityDisplay {
754
793
  const record = asRecord(value);
755
794
  return (
@@ -764,26 +803,55 @@ function isActivityDisplay(value: unknown): value is ActivityDisplay {
764
803
  );
765
804
  }
766
805
 
806
+ function identityFromDispatchArgs(request: unknown): {
807
+ requestId: string;
808
+ role: string;
809
+ phase: string;
810
+ objective: string;
811
+ } {
812
+ const value = asRecord(request);
813
+ const attempt = asRecord(value?.attempt);
814
+ const boundary = asRecord(value?.boundary);
815
+ return {
816
+ requestId: sanitizeDisplayText(
817
+ attempt?.requestId ?? value?.id ?? "unknown",
818
+ 128,
819
+ ),
820
+ role: sanitizeDisplayText(
821
+ value?.role ??
822
+ (value?.stage === "abel-implement"
823
+ ? "implementation-worker"
824
+ : "unknown"),
825
+ 80,
826
+ ),
827
+ phase: sanitizeDisplayText(attempt?.phase ?? value?.phase ?? "unknown", 40),
828
+ objective: sanitizeDisplayText(
829
+ value?.objective ?? boundary?.objective ?? "",
830
+ 240,
831
+ ),
832
+ };
833
+ }
834
+
767
835
  export function renderActivityCall(
768
836
  args: unknown,
769
837
  theme?: ActivityTheme,
838
+ context?: ActivityRenderContext,
770
839
  ): Component {
771
840
  const value = asRecord(args);
772
841
  if (value?.action !== "run") {
773
- return new Text(
774
- `Abel Dispatch ${sanitizeDisplayText(value?.action ?? "")}`,
775
- 0,
776
- 0,
777
- );
842
+ return new Text(context?.isError ? "" : dispatchLabel(value?.action), 0, 0);
843
+ }
844
+ if (context?.executionStarted || context?.isError) {
845
+ return new Text("");
778
846
  }
779
- const request = asRecord(value.request);
780
- if (!request) return new Text("Subagent", 0, 0);
847
+ if (!asRecord(value.request)) return new Text("Subagent", 0, 0);
848
+ const identity = identityFromDispatchArgs(value.request);
781
849
  const display: ActivitySnapshot = {
782
850
  toolCallId: "call",
783
- requestId: sanitizeDisplayText(request.id ?? "unknown", 128),
784
- role: sanitizeDisplayText(request.role ?? "unknown", 80),
785
- phase: sanitizeDisplayText(request.phase ?? "unknown", 40),
786
- objective: sanitizeDisplayText(request.objective ?? "", 240),
851
+ requestId: identity.requestId,
852
+ role: identity.role,
853
+ phase: identity.phase,
854
+ objective: identity.objective,
787
855
  state: "queued",
788
856
  sequence: 0,
789
857
  startedAt: Date.now(),