@abelxiaoxing/cadence 1.0.11 → 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@abelxiaoxing/cadence",
3
- "version": "1.0.11",
3
+ "version": "1.0.12",
4
4
  "description": "Prompts, skills, and a private orchestration extension exposing the Abel four-stage workflow (/abel-init, /abel-design, /abel-implement, /abel-diagnose) with package-owned professional Agents.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -10,7 +10,7 @@ import {
10
10
  import { Type } from "typebox";
11
11
  import type { ChildFailure, DiffResult, EvidenceResult } from "./contracts.ts";
12
12
  import { EmptyResourceLoader } from "./empty-resource-loader.ts";
13
- import { createScopedTools } from "./scoped-tools.ts";
13
+ import { createScopedTools, TOOL_LIMITS } from "./scoped-tools.ts";
14
14
  import {
15
15
  createSubmitTool,
16
16
  type FinalCategory,
@@ -120,8 +120,30 @@ function wrapScopedTools(
120
120
  description: scoped.description,
121
121
  parameters:
122
122
  scoped.name === "grep"
123
- ? Type.Object({ path: Type.String(), pattern: Type.String() })
124
- : Type.Object({ path: Type.String() }),
123
+ ? Type.Object({
124
+ path: Type.Optional(Type.String()),
125
+ pattern: Type.String(),
126
+ })
127
+ : scoped.name === "find"
128
+ ? Type.Object({
129
+ path: Type.Optional(Type.String()),
130
+ pattern: Type.String(),
131
+ limit: Type.Optional(
132
+ Type.Integer({
133
+ minimum: 1,
134
+ maximum: TOOL_LIMITS.maxEntries,
135
+ }),
136
+ ),
137
+ })
138
+ : scoped.name === "read"
139
+ ? Type.Object({
140
+ path: Type.String(),
141
+ offset: Type.Optional(Type.Integer({ minimum: 1 })),
142
+ limit: Type.Optional(Type.Integer({ minimum: 1 })),
143
+ })
144
+ : Type.Object({
145
+ path: Type.Optional(Type.String()),
146
+ }),
125
147
  async execute(_id, params) {
126
148
  const result = await scoped.execute(
127
149
  params as Record<string, unknown>,
package/src/index.ts CHANGED
@@ -232,6 +232,7 @@ export default function register(pi: ExtensionAPI): void {
232
232
  ? "Run exactly one bounded read-only design-explorer packet. When parallel packets are required, emit every sibling abel_dispatch call together in one assistant response before waiting for any result."
233
233
  : "Private Abel workflow delegation: run bounded read-only evidence or Worker phase requests, apply or discard retained results, apply a parent-only stable AGENTS checkpoint, cancel work, or finish the stage. Inactive unless an eligible Abel stage verified its invocation.",
234
234
  executionMode: "parallel",
235
+ renderShell: "self",
235
236
  parameters: {
236
237
  type: "object",
237
238
  properties: {
@@ -304,16 +305,25 @@ export default function register(pi: ExtensionAPI): void {
304
305
  ...(usage === undefined ? {} : { usage }),
305
306
  };
306
307
  },
307
- renderCall(args: unknown, theme: Theme, _context: unknown) {
308
- return renderActivityCall(args, theme);
308
+ renderCall(args: unknown, theme: Theme, context: unknown) {
309
+ return renderActivityCall(
310
+ args,
311
+ theme,
312
+ context as Parameters<typeof renderActivityCall>[2],
313
+ );
309
314
  },
310
315
  renderResult(
311
316
  result: AgentToolResult<unknown>,
312
317
  options: ToolRenderResultOptions,
313
318
  theme: Theme,
314
- _context: unknown,
319
+ context: unknown,
315
320
  ) {
316
- return renderActivityResult(result, options, theme);
321
+ return renderActivityResult(
322
+ result,
323
+ options,
324
+ theme,
325
+ context as Parameters<typeof renderActivityResult>[3],
326
+ );
317
327
  },
318
328
  } as never);
319
329
  };
@@ -46,6 +46,7 @@ export class ParentPayloadBridgeError extends Error {
46
46
 
47
47
  export interface ParentProviderRegistry {
48
48
  getProvider(provider: string): Provider | undefined;
49
+ getRegisteredNativeProvider?(provider: string): Provider | undefined;
49
50
  registerProvider(provider: Provider): void;
50
51
  }
51
52
 
@@ -198,9 +199,12 @@ export class ParentPayloadBridge {
198
199
  model: Model<string>,
199
200
  registry: ParentProviderRegistry,
200
201
  ): Provider | undefined {
201
- const current = registry.getProvider(model.provider);
202
+ const current = this.nativeCarrier(model.provider, registry);
202
203
  if (!current) return undefined;
203
204
  const delegate = this.unwrapProvider(current);
205
+ const wrapped = this.isWrappedProvider(current)
206
+ ? current
207
+ : this.wrapProvider(delegate);
204
208
  const state = this.state;
205
209
  if (state?.active) {
206
210
  const key = modelKeyId(modelKeyFor(model));
@@ -210,14 +214,18 @@ export class ParentPayloadBridge {
210
214
  state.ready.delete(key);
211
215
  }
212
216
  }
213
- const wrapped = this.wrapProvider(delegate);
214
- registry.registerProvider(wrapped);
217
+ if (wrapped !== current) {
218
+ registry.registerProvider(wrapped);
219
+ }
215
220
  return wrapped;
216
221
  }
217
222
 
218
223
  capture(
219
224
  modelKey: ParentModelKey,
220
- registry?: Pick<ParentProviderRegistry, "getProvider">,
225
+ registry?: Pick<
226
+ ParentProviderRegistry,
227
+ "getProvider" | "getRegisteredNativeProvider"
228
+ >,
221
229
  ): ParentPayloadCapture | undefined {
222
230
  const state = this.state;
223
231
  if (!state?.active) return undefined;
@@ -226,7 +234,7 @@ export class ParentPayloadBridge {
226
234
  return undefined;
227
235
  }
228
236
  if (registry) {
229
- const current = registry.getProvider(modelKey.provider);
237
+ const current = this.nativeCarrier(modelKey.provider, registry);
230
238
  if (!current || this.unwrapProvider(current) !== capture.delegate) {
231
239
  return undefined;
232
240
  }
@@ -236,11 +244,14 @@ export class ParentPayloadBridge {
236
244
 
237
245
  diagnoseCapture(
238
246
  modelKey: ParentModelKey,
239
- registry: Pick<ParentProviderRegistry, "getProvider">,
247
+ registry: Pick<
248
+ ParentProviderRegistry,
249
+ "getProvider" | "getRegisteredNativeProvider"
250
+ >,
240
251
  ): ParentBridgeFailureCode | undefined {
241
252
  const state = this.state;
242
253
  if (!state?.active) return "parent-bridge-session-unavailable";
243
- const current = registry.getProvider(modelKey.provider);
254
+ const current = this.nativeCarrier(modelKey.provider, registry);
244
255
  if (!current || !this.isWrappedProvider(current)) {
245
256
  return "parent-bridge-provider-not-installed";
246
257
  }
@@ -468,6 +479,19 @@ export class ParentPayloadBridge {
468
479
  return state;
469
480
  }
470
481
 
482
+ private nativeCarrier(
483
+ providerId: string,
484
+ registry: Pick<
485
+ ParentProviderRegistry,
486
+ "getProvider" | "getRegisteredNativeProvider"
487
+ >,
488
+ ): Provider | undefined {
489
+ const registered = registry.getRegisteredNativeProvider?.(providerId);
490
+ return registered && this.isWrappedProvider(registered)
491
+ ? registered
492
+ : registry.getProvider(providerId);
493
+ }
494
+
471
495
  private isWrappedProvider(provider: Provider): boolean {
472
496
  return Reflect.get(provider, PROVIDER_WRAPPER) !== undefined;
473
497
  }
@@ -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(),