wayang-ai 0.8.0 → 0.8.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.
Files changed (2) hide show
  1. package/dist/cli.js +1672 -379
  2. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/cli.ts
4
- import * as path8 from "path";
4
+ import * as path9 from "path";
5
5
 
6
6
  // src/cli-args.ts
7
7
  import meow from "meow";
@@ -463,11 +463,11 @@ var RemoteStateStore = class {
463
463
  rpc;
464
464
  entries = /* @__PURE__ */ new Map();
465
465
  byId = /* @__PURE__ */ new Map();
466
- subscribe(target, path9, callback) {
467
- const key = `${target}:${path9}`;
466
+ subscribe(target, path10, callback) {
467
+ const key = `${target}:${path10}`;
468
468
  let entry = this.entries.get(key);
469
469
  if (!entry) {
470
- entry = { subscriptionId: "", target, path: path9, data: void 0, listeners: /* @__PURE__ */ new Set(), settled: false };
470
+ entry = { subscriptionId: "", target, path: path10, data: void 0, listeners: /* @__PURE__ */ new Set(), settled: false };
471
471
  this.entries.set(key, entry);
472
472
  this.connect(entry, key);
473
473
  }
@@ -477,8 +477,8 @@ var RemoteStateStore = class {
477
477
  if (entry.listeners.size === 0) this.teardown(entry, key);
478
478
  };
479
479
  }
480
- getSnapshot(target, path9) {
481
- return this.entries.get(`${target}:${path9}`)?.data;
480
+ getSnapshot(target, path10) {
481
+ return this.entries.get(`${target}:${path10}`)?.data;
482
482
  }
483
483
  connect(entry, key) {
484
484
  this.rpc.request(METHOD.SUBSCRIBE, { target: entry.target, path: entry.path }).then((res) => {
@@ -516,11 +516,11 @@ var RemoteSubscribable = class {
516
516
  }
517
517
  store;
518
518
  target;
519
- subscribe(path9, callback) {
520
- return this.store.subscribe(this.target, path9, callback);
519
+ subscribe(path10, callback) {
520
+ return this.store.subscribe(this.target, path10, callback);
521
521
  }
522
- getSnapshot(path9) {
523
- return this.store.getSnapshot(this.target, path9);
522
+ getSnapshot(path10) {
523
+ return this.store.getSnapshot(this.target, path10);
524
524
  }
525
525
  };
526
526
 
@@ -683,6 +683,7 @@ ${getStderrTail()}`));
683
683
 
684
684
  // src/infra/session-helpers.ts
685
685
  import * as fs3 from "fs";
686
+ import { closeSync as closeSync2, openSync as openSync2, readSync, statSync } from "fs";
686
687
  import * as path3 from "path";
687
688
 
688
689
  // src/infra/session-index.ts
@@ -1021,8 +1022,115 @@ function listSessions(homeDir2, query2 = {}) {
1021
1022
  function getSession(homeDir2, sessionId) {
1022
1023
  return createSessionIndex(homeDir2).get(sessionId);
1023
1024
  }
1024
- function getLatestSessionForWorkspace(homeDir2, workspaceDir2) {
1025
- return createSessionIndex(homeDir2).latestForWorkspace(workspaceDir2);
1025
+ var CONVERSATION_FILENAME = "conversation.jsonl";
1026
+ var ACTIVITY_WINDOW_BYTES = 64 * 1024;
1027
+ var MAX_ACTIVITY_WINDOW_BYTES = 2 * 1024 * 1024;
1028
+ function entryTimestamp(entry) {
1029
+ if (entry === null || typeof entry !== "object") return null;
1030
+ const e = entry;
1031
+ for (const raw of [e.timestamp, e.endedAt, e.startedAt]) {
1032
+ if (typeof raw === "string") {
1033
+ const ms = Date.parse(raw);
1034
+ if (Number.isFinite(ms)) return ms;
1035
+ }
1036
+ }
1037
+ return null;
1038
+ }
1039
+ function readTailEntryTimestamp(filePath) {
1040
+ let size;
1041
+ try {
1042
+ size = statSync(filePath).size;
1043
+ } catch {
1044
+ return null;
1045
+ }
1046
+ if (size === 0) return null;
1047
+ let windowBytes = Math.min(size, ACTIVITY_WINDOW_BYTES);
1048
+ for (; ; ) {
1049
+ const start = size - windowBytes;
1050
+ const chunk = Buffer.alloc(windowBytes);
1051
+ let fd = null;
1052
+ try {
1053
+ fd = openSync2(filePath, "r");
1054
+ readSync(fd, chunk, 0, windowBytes, start);
1055
+ } catch {
1056
+ return null;
1057
+ } finally {
1058
+ if (fd !== null) closeSync2(fd);
1059
+ }
1060
+ let body = chunk.toString("utf-8");
1061
+ if (start > 0) {
1062
+ const nl = body.indexOf("\n");
1063
+ if (nl === -1) {
1064
+ if (windowBytes >= MAX_ACTIVITY_WINDOW_BYTES) return null;
1065
+ windowBytes = Math.min(size, windowBytes * 2);
1066
+ continue;
1067
+ }
1068
+ body = body.slice(nl + 1);
1069
+ }
1070
+ let latest = null;
1071
+ for (const line of body.split("\n")) {
1072
+ if (line.length === 0) continue;
1073
+ let parsed;
1074
+ try {
1075
+ parsed = JSON.parse(line);
1076
+ } catch {
1077
+ continue;
1078
+ }
1079
+ const ts = entryTimestamp(parsed);
1080
+ if (ts !== null && (latest === null || ts > latest)) latest = ts;
1081
+ }
1082
+ if (latest !== null) return latest;
1083
+ if (start === 0 || windowBytes >= MAX_ACTIVITY_WINDOW_BYTES) return null;
1084
+ windowBytes = Math.min(size, windowBytes * 2);
1085
+ }
1086
+ }
1087
+ function readMetaForActivity(sessionDir) {
1088
+ try {
1089
+ const raw = fs3.readFileSync(path3.join(sessionDir, "meta.json"), "utf-8");
1090
+ const meta = JSON.parse(raw);
1091
+ return meta !== null && typeof meta === "object" ? meta : null;
1092
+ } catch {
1093
+ return null;
1094
+ }
1095
+ }
1096
+ function usableTimestamp(value) {
1097
+ return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : null;
1098
+ }
1099
+ function readSessionLastActivity(sessionDir) {
1100
+ const meta = readMetaForActivity(sessionDir);
1101
+ const candidates = [];
1102
+ const conversationAt = readTailEntryTimestamp(path3.join(sessionDir, CONVERSATION_FILENAME));
1103
+ if (conversationAt !== null) candidates.push({ at: conversationAt, source: "conversation" });
1104
+ const lastActiveAt = usableTimestamp(meta?.lastActiveAt);
1105
+ if (lastActiveAt !== null) candidates.push({ at: lastActiveAt, source: "meta.lastActiveAt" });
1106
+ const createdAt = usableTimestamp(meta?.createdAt);
1107
+ if (createdAt !== null) candidates.push({ at: createdAt, source: "meta.createdAt" });
1108
+ if (candidates.length === 0) return null;
1109
+ return candidates.reduce((best, candidate) => candidate.at > best.at ? candidate : best);
1110
+ }
1111
+ function pickMostRecentlyActive(candidates, activityOf) {
1112
+ let best = null;
1113
+ let bestAt = 0;
1114
+ for (const candidate of candidates) {
1115
+ const at = activityOf(candidate);
1116
+ if (best === null || at > bestAt || at === bestAt && candidate.sessionId > best.sessionId) {
1117
+ best = candidate;
1118
+ bestAt = at;
1119
+ }
1120
+ }
1121
+ return best;
1122
+ }
1123
+ function activityAtForEntry(entry) {
1124
+ const activity = readSessionLastActivity(entry.sessionDir);
1125
+ if (activity) return activity.at;
1126
+ return usableTimestamp(entry.meta.createdAt) ?? 0;
1127
+ }
1128
+ function getLatestSessionForWorkspace(homeDir2, workspaceDir2, opts = {}) {
1129
+ const candidates = createSessionIndex(homeDir2).list({
1130
+ workspace: workspaceDir2,
1131
+ includeArchived: opts.includeArchived
1132
+ });
1133
+ return pickMostRecentlyActive(candidates, activityAtForEntry);
1026
1134
  }
1027
1135
  function readSessionUnfinishedTasks(sessionDir) {
1028
1136
  const tasksPath = path3.join(sessionDir, "tasks.json");
@@ -1303,7 +1411,7 @@ function validateConfig(config) {
1303
1411
  if (!config.providers[wp]) return `worker.provider "${wp}" not found`;
1304
1412
  const permission = config.permission;
1305
1413
  if (permission !== void 0 && permission !== null) {
1306
- const keys = ["allowedDirs", "autoAllowRoots", "deniedDirs"];
1414
+ const keys = ["allowedDirs", "autoAllowRoots", "sensitiveDirs", "deniedDirs"];
1307
1415
  for (const key of keys) {
1308
1416
  const value = permission[key];
1309
1417
  if (value === void 0) continue;
@@ -1311,6 +1419,10 @@ function validateConfig(config) {
1311
1419
  return `permission.${key} must be an array of strings (got ${JSON.stringify(value)})`;
1312
1420
  }
1313
1421
  }
1422
+ const mode = permission.sensitiveMode;
1423
+ if (mode !== void 0 && mode !== "ask" && mode !== "deny") {
1424
+ return `permission.sensitiveMode must be 'ask' or 'deny' (got ${JSON.stringify(mode)})`;
1425
+ }
1314
1426
  }
1315
1427
  return null;
1316
1428
  }
@@ -1363,43 +1475,43 @@ var BaseWayangState = class {
1363
1475
  }
1364
1476
  // --- Read ---
1365
1477
  // eslint-disable-next-line @typescript-eslint/no-explicit-any -- public default generic: bare get() callers across the repo rely on it; per-call sites pass an explicit T
1366
- get(path9) {
1367
- return get(this.data, path9);
1478
+ get(path10) {
1479
+ return get(this.data, path10);
1368
1480
  }
1369
1481
  // --- Write operations ---
1370
- set(path9, value) {
1371
- const prev = get(this.data, path9);
1372
- set(this.data, path9, value);
1373
- this.persist(path9, "set");
1374
- this.notify(path9, "set", prev);
1482
+ set(path10, value) {
1483
+ const prev = get(this.data, path10);
1484
+ set(this.data, path10, value);
1485
+ this.persist(path10, "set");
1486
+ this.notify(path10, "set", prev);
1375
1487
  }
1376
1488
  // eslint-disable-next-line @typescript-eslint/no-explicit-any -- partial update merges an untyped object into the state tree root (see `data`)
1377
- update(path9, partial) {
1378
- const prev = cloneDeep(get(this.data, path9));
1379
- const target = get(this.data, path9);
1489
+ update(path10, partial) {
1490
+ const prev = cloneDeep(get(this.data, path10));
1491
+ const target = get(this.data, path10);
1380
1492
  if (target && typeof target === "object") {
1381
1493
  Object.assign(target, partial);
1382
1494
  } else {
1383
- set(this.data, path9, partial);
1495
+ set(this.data, path10, partial);
1384
1496
  }
1385
- this.persist(path9, "update");
1386
- this.notify(path9, "update", prev);
1497
+ this.persist(path10, "update");
1498
+ this.notify(path10, "update", prev);
1387
1499
  }
1388
- append(path9, entry) {
1389
- const arr = get(this.data, path9);
1500
+ append(path10, entry) {
1501
+ const arr = get(this.data, path10);
1390
1502
  if (!Array.isArray(arr)) {
1391
- throw new Error(`State.append: path "${path9}" is not an array`);
1503
+ throw new Error(`State.append: path "${path10}" is not an array`);
1392
1504
  }
1393
- const stamped = path9 === "conversation" ? applyAppendEnvelope(entry, arr) : entry;
1505
+ const stamped = path10 === "conversation" ? applyAppendEnvelope(entry, arr) : entry;
1394
1506
  arr.push(stamped);
1395
- set(this.data, path9, [...arr]);
1396
- this.persist(path9, "append", stamped);
1397
- this.notify(path9, "append", void 0);
1507
+ set(this.data, path10, [...arr]);
1508
+ this.persist(path10, "append", stamped);
1509
+ this.notify(path10, "append", void 0);
1398
1510
  }
1399
- remove(path9, idx) {
1400
- const arr = get(this.data, path9);
1511
+ remove(path10, idx) {
1512
+ const arr = get(this.data, path10);
1401
1513
  if (!Array.isArray(arr)) {
1402
- throw new Error(`State.remove: path "${path9}" is not an array`);
1514
+ throw new Error(`State.remove: path "${path10}" is not an array`);
1403
1515
  }
1404
1516
  const prev = idx !== void 0 ? arr[idx] : arr[arr.length - 1];
1405
1517
  if (idx === void 0) {
@@ -1407,16 +1519,16 @@ var BaseWayangState = class {
1407
1519
  } else {
1408
1520
  arr.splice(idx, 1);
1409
1521
  }
1410
- this.persist(path9, "remove");
1411
- this.notify(path9, "remove", prev);
1522
+ this.persist(path10, "remove");
1523
+ this.notify(path10, "remove", prev);
1412
1524
  }
1413
1525
  // --- Subscription ---
1414
- on(path9, callback) {
1415
- if (!this.subscriptions.has(path9)) {
1416
- this.subscriptions.set(path9, /* @__PURE__ */ new Set());
1526
+ on(path10, callback) {
1527
+ if (!this.subscriptions.has(path10)) {
1528
+ this.subscriptions.set(path10, /* @__PURE__ */ new Set());
1417
1529
  }
1418
- this.subscriptions.get(path9).add(callback);
1419
- return () => this.subscriptions.get(path9)?.delete(callback);
1530
+ this.subscriptions.get(path10).add(callback);
1531
+ return () => this.subscriptions.get(path10)?.delete(callback);
1420
1532
  }
1421
1533
  // --- Persistence ---
1422
1534
  /**
@@ -1424,8 +1536,8 @@ var BaseWayangState = class {
1424
1536
  * for the helper to pick its own strategy. Snapshot helpers replace the
1425
1537
  * file; append helpers write only the new entry and ignore window edits.
1426
1538
  */
1427
- persist(path9, op, entry) {
1428
- const topKey = path9.split(".")[0];
1539
+ persist(path10, op, entry) {
1540
+ const topKey = path10.split(".")[0];
1429
1541
  for (const mapping of this.persistMappings) {
1430
1542
  if (mapping.path !== topKey) continue;
1431
1543
  mapping.helper.write({
@@ -1466,10 +1578,10 @@ import {
1466
1578
  existsSync as existsSync4,
1467
1579
  mkdirSync as mkdirSync3,
1468
1580
  renameSync as renameSync4,
1469
- openSync as openSync2,
1581
+ openSync as openSync3,
1470
1582
  writeSync as writeSync2,
1471
1583
  fsyncSync as fsyncSync2,
1472
- closeSync as closeSync2,
1584
+ closeSync as closeSync3,
1473
1585
  unlinkSync as unlinkSync3,
1474
1586
  copyFileSync
1475
1587
  } from "fs";
@@ -1498,16 +1610,16 @@ var JSONFileHelper = class {
1498
1610
  const tmp = this.tempPath();
1499
1611
  let fd = null;
1500
1612
  try {
1501
- fd = openSync2(tmp, "w");
1613
+ fd = openSync3(tmp, "w");
1502
1614
  writeSync2(fd, JSON.stringify(value, null, 2));
1503
1615
  fsyncSync2(fd);
1504
- closeSync2(fd);
1616
+ closeSync3(fd);
1505
1617
  fd = null;
1506
1618
  renameSync4(tmp, this.filePath);
1507
1619
  } catch (err) {
1508
1620
  if (fd !== null) {
1509
1621
  try {
1510
- closeSync2(fd);
1622
+ closeSync3(fd);
1511
1623
  } catch {
1512
1624
  }
1513
1625
  }
@@ -1958,56 +2070,6 @@ var theme = {
1958
2070
  }
1959
2071
  };
1960
2072
 
1961
- // src/tui/utils/chat-lookups.ts
1962
- var EMPTY_CHAT_LOOKUPS = {
1963
- permissionRequests: /* @__PURE__ */ new Map(),
1964
- inquiries: /* @__PURE__ */ new Map(),
1965
- workers: /* @__PURE__ */ new Map(),
1966
- workerTypeEmoji: /* @__PURE__ */ new Map()
1967
- };
1968
- function buildChatLookups(items) {
1969
- const permissionRequests = /* @__PURE__ */ new Map();
1970
- const inquiries = /* @__PURE__ */ new Map();
1971
- const workers = /* @__PURE__ */ new Map();
1972
- const workerTypeEmoji = /* @__PURE__ */ new Map();
1973
- for (const item of items) {
1974
- if (item.role !== "signal") continue;
1975
- if (item.workerType && item.emoji && !workerTypeEmoji.has(item.workerType)) {
1976
- workerTypeEmoji.set(item.workerType, item.emoji);
1977
- }
1978
- let worker;
1979
- if (item.workerId) {
1980
- const prev = workers.get(item.workerId);
1981
- worker = {
1982
- workerId: item.workerId,
1983
- emoji: item.emoji ?? prev?.emoji,
1984
- workerType: item.workerType ?? prev?.workerType,
1985
- taskTitle: item.taskTitle ?? prev?.taskTitle
1986
- };
1987
- workers.set(item.workerId, worker);
1988
- }
1989
- const requestId = item.permissionMeta?.requestId;
1990
- if (item.permissionMeta && requestId) {
1991
- permissionRequests.set(requestId, { meta: item.permissionMeta, ...worker && { worker } });
1992
- }
1993
- const inquiryId = item.inquiryMeta?.inquiryId;
1994
- if (item.inquiryMeta && inquiryId) {
1995
- inquiries.set(inquiryId, { meta: item.inquiryMeta, ...worker && { worker } });
1996
- }
1997
- }
1998
- return { permissionRequests, inquiries, workers, workerTypeEmoji };
1999
- }
2000
- function formatWorkerTag(ctx) {
2001
- const parts = [];
2002
- if (ctx.taskTitle) parts.push(ctx.taskTitle);
2003
- if (ctx.workerId) parts.push(`w:${ctx.workerId.slice(-4)}`);
2004
- const body = parts.join(" \xB7 ");
2005
- return [ctx.emoji, body].filter(Boolean).join(" ");
2006
- }
2007
-
2008
- // src/tui/components/chat-area/user-row.tsx
2009
- import { Box, Text } from "ink";
2010
-
2011
2073
  // src/tui/utils/text-width.ts
2012
2074
  function charWidth(cp) {
2013
2075
  if (cp >= 768 && cp <= 879) return 0;
@@ -2049,6 +2111,28 @@ function sliceToWidth(s, maxWidth) {
2049
2111
  }
2050
2112
  return out;
2051
2113
  }
2114
+ function sliceToWidthFromEnd(s, maxWidth) {
2115
+ if (maxWidth <= 0) return "";
2116
+ const chars = Array.from(s);
2117
+ let w = 0;
2118
+ let out = "";
2119
+ for (let i = chars.length - 1; i >= 0; i -= 1) {
2120
+ const cw = charWidth(chars[i].codePointAt(0) ?? 0);
2121
+ if (w + cw > maxWidth) break;
2122
+ out = chars[i] + out;
2123
+ w += cw;
2124
+ }
2125
+ return out;
2126
+ }
2127
+ function truncateMiddleToWidth(s, maxWidth) {
2128
+ if (maxWidth <= 0) return "";
2129
+ if (stringWidth(s) <= maxWidth) return s;
2130
+ if (maxWidth === 1) return "\u2026";
2131
+ const keep = maxWidth - 1;
2132
+ const headBudget = Math.ceil(keep / 2);
2133
+ const tailBudget = keep - headBudget;
2134
+ return sliceToWidth(s, headBudget) + "\u2026" + sliceToWidthFromEnd(s, tailBudget);
2135
+ }
2052
2136
  function collapseWhitespace(s) {
2053
2137
  return s.replace(/\s+/g, " ").trim();
2054
2138
  }
@@ -2063,7 +2147,144 @@ function shortId(id) {
2063
2147
  return id.slice(-4);
2064
2148
  }
2065
2149
 
2150
+ // src/tui/utils/workspace-display.ts
2151
+ var EMPTY_WORKSPACE_CONTEXT = { workspaces: [] };
2152
+ function formatWorkspaceSlug(workspace) {
2153
+ const slug = workspace?.slug?.trim();
2154
+ return slug ? slug : null;
2155
+ }
2156
+ function formatWorkspaceSummary(workspaces) {
2157
+ if (!workspaces || workspaces.length === 0) return null;
2158
+ const slugs = workspaces.map((workspace) => workspace?.slug?.trim()).filter((slug) => !!slug);
2159
+ if (slugs.length === 0) return null;
2160
+ if (slugs.length === 1) return slugs[0];
2161
+ return `${slugs[0]} +${slugs.length - 1}`;
2162
+ }
2163
+ function isSameWorkspacePath(a, b) {
2164
+ const left = a?.trim();
2165
+ const right = b?.trim();
2166
+ if (!left || !right) return false;
2167
+ return normalizeWorkspacePath(left) === normalizeWorkspacePath(right);
2168
+ }
2169
+ function resolveWorkspaceSlug(workspace) {
2170
+ const slug = workspace?.slug?.trim();
2171
+ if (slug) return slug;
2172
+ const path10 = workspace?.path?.trim();
2173
+ return path10 ? workspaceSlugFromPath(path10) : null;
2174
+ }
2175
+ function formatTaskWorkspaceMark(workspace, defaultWorkspacePath) {
2176
+ const slug = resolveWorkspaceSlug(workspace);
2177
+ if (!slug) return "";
2178
+ if (defaultWorkspacePath && isSameWorkspacePath(workspace?.path, defaultWorkspacePath)) {
2179
+ return "";
2180
+ }
2181
+ return ` \u2014 ${slug}`;
2182
+ }
2183
+ function resolveDeclaredWorkspaceSlug(declaredPath, options = {}) {
2184
+ const raw = declaredPath?.trim();
2185
+ if (!raw) return null;
2186
+ if (options.defaultWorkspacePath && isSameWorkspacePath(raw, options.defaultWorkspacePath)) {
2187
+ return null;
2188
+ }
2189
+ const known = (options.workspaces ?? []).find(
2190
+ (workspace) => workspace?.path && isSameWorkspacePath(workspace.path, raw)
2191
+ );
2192
+ if (known) return resolveWorkspaceSlug(known);
2193
+ return resolveWorkspaceSlug({ path: normalizeWorkspacePath(raw) });
2194
+ }
2195
+ var PROJECT_LIST_MAX_WIDTH = 80;
2196
+ var PROJECT_DEFAULT_MARKER = "*";
2197
+ var PROJECT_DEFAULT_LEGEND = "* = default workspace";
2198
+ function collectProjectEntries(input2) {
2199
+ const defaultPath = input2.defaultWorkspacePath?.trim();
2200
+ const seen = /* @__PURE__ */ new Set();
2201
+ const entries = [];
2202
+ const candidates = [
2203
+ ...input2.workspaces ?? [],
2204
+ ...input2.taskWorkspaces ?? [],
2205
+ ...defaultPath ? [{ path: defaultPath }] : []
2206
+ ];
2207
+ for (const candidate of candidates) {
2208
+ const rawPath = candidate?.path?.trim();
2209
+ if (!rawPath) continue;
2210
+ const canonical = normalizeWorkspacePath(rawPath);
2211
+ if (seen.has(canonical)) continue;
2212
+ const slug = resolveWorkspaceSlug({ ...candidate, path: rawPath });
2213
+ if (!slug) continue;
2214
+ seen.add(canonical);
2215
+ entries.push({
2216
+ slug,
2217
+ path: rawPath,
2218
+ isDefault: defaultPath !== void 0 && isSameWorkspacePath(rawPath, defaultPath)
2219
+ });
2220
+ }
2221
+ const defaultEntry = entries.find((entry) => entry.isDefault);
2222
+ return defaultEntry ? [defaultEntry, ...entries.filter((entry) => entry !== defaultEntry)] : entries;
2223
+ }
2224
+ function formatProjectList(input2, options = {}) {
2225
+ const maxWidth = options.maxWidth ?? PROJECT_LIST_MAX_WIDTH;
2226
+ const entries = collectProjectEntries(input2);
2227
+ if (entries.length === 0) return "No projects";
2228
+ const lines = entries.map((entry) => {
2229
+ const suffix = entry.isDefault ? ` ${PROJECT_DEFAULT_MARKER}` : "";
2230
+ const prefix = `${entry.slug} `;
2231
+ const pathBudget = Math.max(1, maxWidth - stringWidth(prefix) - stringWidth(suffix));
2232
+ return prefix + truncateMiddleToWidth(entry.path, pathBudget) + suffix;
2233
+ });
2234
+ if (entries.some((entry) => entry.isDefault)) lines.push(PROJECT_DEFAULT_LEGEND);
2235
+ return lines.join("\n");
2236
+ }
2237
+
2238
+ // src/tui/utils/chat-lookups.ts
2239
+ var EMPTY_CHAT_LOOKUPS = {
2240
+ permissionRequests: /* @__PURE__ */ new Map(),
2241
+ inquiries: /* @__PURE__ */ new Map(),
2242
+ workers: /* @__PURE__ */ new Map(),
2243
+ workerTypeEmoji: /* @__PURE__ */ new Map(),
2244
+ workspace: EMPTY_WORKSPACE_CONTEXT
2245
+ };
2246
+ function buildChatLookups(items, workspace = EMPTY_WORKSPACE_CONTEXT) {
2247
+ const permissionRequests = /* @__PURE__ */ new Map();
2248
+ const inquiries = /* @__PURE__ */ new Map();
2249
+ const workers = /* @__PURE__ */ new Map();
2250
+ const workerTypeEmoji = /* @__PURE__ */ new Map();
2251
+ for (const item of items) {
2252
+ if (item.role !== "signal") continue;
2253
+ if (item.workerType && item.emoji && !workerTypeEmoji.has(item.workerType)) {
2254
+ workerTypeEmoji.set(item.workerType, item.emoji);
2255
+ }
2256
+ let worker;
2257
+ if (item.workerId) {
2258
+ const prev = workers.get(item.workerId);
2259
+ worker = {
2260
+ workerId: item.workerId,
2261
+ emoji: item.emoji ?? prev?.emoji,
2262
+ workerType: item.workerType ?? prev?.workerType,
2263
+ taskTitle: item.taskTitle ?? prev?.taskTitle
2264
+ };
2265
+ workers.set(item.workerId, worker);
2266
+ }
2267
+ const requestId = item.permissionMeta?.requestId;
2268
+ if (item.permissionMeta && requestId) {
2269
+ permissionRequests.set(requestId, { meta: item.permissionMeta, ...worker && { worker } });
2270
+ }
2271
+ const inquiryId = item.inquiryMeta?.inquiryId;
2272
+ if (item.inquiryMeta && inquiryId) {
2273
+ inquiries.set(inquiryId, { meta: item.inquiryMeta, ...worker && { worker } });
2274
+ }
2275
+ }
2276
+ return { permissionRequests, inquiries, workers, workerTypeEmoji, workspace };
2277
+ }
2278
+ function formatWorkerTag(ctx) {
2279
+ const parts = [];
2280
+ if (ctx.taskTitle) parts.push(ctx.taskTitle);
2281
+ if (ctx.workerId) parts.push(`w:${ctx.workerId.slice(-4)}`);
2282
+ const body = parts.join(" \xB7 ");
2283
+ return [ctx.emoji, body].filter(Boolean).join(" ");
2284
+ }
2285
+
2066
2286
  // src/tui/components/chat-area/user-row.tsx
2287
+ import { Box, Text } from "ink";
2067
2288
  import { jsx as jsx2, jsxs } from "react/jsx-runtime";
2068
2289
  var PREFIX = `${theme.user.prefix} `;
2069
2290
  var PREFIX_WIDTH = stringWidth(PREFIX);
@@ -2143,27 +2364,27 @@ function toolTarget(toolName, args, budget) {
2143
2364
  return fitWithSuffix(lines[0].trim(), note, budget);
2144
2365
  }
2145
2366
  case "write_file": {
2146
- const path9 = argString(args, ["path", "file_path"]);
2147
- if (!path9) return "";
2367
+ const path10 = argString(args, ["path", "file_path"]);
2368
+ if (!path10) return "";
2148
2369
  const lines = lineCount(args.content);
2149
2370
  const note = lines > 0 ? ` (+${lines})` : "";
2150
- return fitWithSuffix(shortenPath(path9), note, budget);
2371
+ return fitWithSuffix(shortenPath(path10), note, budget);
2151
2372
  }
2152
2373
  case "edit_file": {
2153
- const path9 = argString(args, ["path", "file_path"]);
2154
- if (!path9) return "";
2374
+ const path10 = argString(args, ["path", "file_path"]);
2375
+ if (!path10) return "";
2155
2376
  const oldLines = lineCount(args.old_string);
2156
2377
  const newLines = lineCount(args.new_string);
2157
2378
  const note = oldLines > 0 || newLines > 0 ? ` (${oldLines}\u2192${newLines} \u884C)` : "";
2158
- return fitWithSuffix(shortenPath(path9), note, budget);
2379
+ return fitWithSuffix(shortenPath(path10), note, budget);
2159
2380
  }
2160
2381
  case "read_file": {
2161
- const path9 = argString(args, ["path", "file_path"]);
2162
- if (!path9) return "";
2382
+ const path10 = argString(args, ["path", "file_path"]);
2383
+ if (!path10) return "";
2163
2384
  const offset = argNumber(args, ["offset"]);
2164
2385
  const limit = argNumber(args, ["limit"]);
2165
2386
  const range = offset !== void 0 ? `:${offset}${limit !== void 0 ? `+${limit}` : ""}` : "";
2166
- return truncateToWidth(shortenPath(path9) + range, budget);
2387
+ return truncateToWidth(shortenPath(path10) + range, budget);
2167
2388
  }
2168
2389
  case "search_files":
2169
2390
  return truncateToWidth(argString(args, ["pattern"]) ?? "", budget);
@@ -2199,15 +2420,18 @@ function toolTarget(toolName, args, budget) {
2199
2420
  function requestArgs(meta) {
2200
2421
  return meta.toolArgs && typeof meta.toolArgs === "object" && !Array.isArray(meta.toolArgs) ? meta.toolArgs : {};
2201
2422
  }
2202
- var RESOLVED_PATH_RE = /^\S+: (.*) \(resolved: (.*)\)$/s;
2423
+ var RESOLVED_PATH_RE = /^\S+: (.*) \(resolved: (.*?)\)(?: \[(.*)\])?$/s;
2203
2424
  function meaningfulDescription(description) {
2204
2425
  if (!description) return "";
2205
2426
  const text = description.trim();
2206
2427
  if (/^execute shell command:/i.test(text)) return "";
2207
2428
  const m = RESOLVED_PATH_RE.exec(text);
2208
2429
  if (m) {
2209
- const [, rawPath, resolved] = m;
2210
- return resolved && resolved !== rawPath ? `\u89E3\u6790\u4E3A ${resolved}` : "";
2430
+ const [, rawPath, resolved, hit] = m;
2431
+ const parts = [];
2432
+ if (resolved && resolved !== rawPath) parts.push(`\u89E3\u6790\u4E3A ${resolved}`);
2433
+ if (hit) parts.push(`[${hit}]`);
2434
+ return collapseWhitespace(parts.join(" "));
2211
2435
  }
2212
2436
  return collapseWhitespace(text);
2213
2437
  }
@@ -2318,11 +2542,25 @@ function summarizeWorkerMessage(argsJson, width, result2, maxLines = 3) {
2318
2542
  };
2319
2543
  }
2320
2544
  var TASK_ID_RE = /^Task (\S+) (?:added|assigned)/;
2321
- function summarizeAddTask(argsJson, width, result2, maxLines = 2) {
2545
+ var ADD_TASK_HEADER_CHROME = 27;
2546
+ var ADD_TASK_TAG_INSET = 1;
2547
+ var ADD_TASK_WORKSPACE_TAG_MAX_WIDTH = 16;
2548
+ function formatAddTaskWorkspaceTag(slug, maxWidth = ADD_TASK_WORKSPACE_TAG_MAX_WIDTH) {
2549
+ const label = slug?.trim();
2550
+ if (!label) return "";
2551
+ return truncateToWidth(` \u2014 ${label}`, maxWidth);
2552
+ }
2553
+ function summarizeAddTask(argsJson, width, result2, options = {}) {
2322
2554
  const args = parseArgs(argsJson) ?? {};
2555
+ const maxLines = options.maxLines ?? 2;
2323
2556
  const title = str(args.title) ?? "(untitled)";
2324
2557
  const description = str(args.description) ?? "";
2325
2558
  const { lines, hidden } = previewLines(description, maxLines, width);
2559
+ const declaredWorkspaceSlug = resolveDeclaredWorkspaceSlug(str(args.workspace), {
2560
+ defaultWorkspacePath: options.defaultWorkspacePath,
2561
+ workspaces: options.workspaces
2562
+ });
2563
+ const workspaceTag = formatAddTaskWorkspaceTag(declaredWorkspaceSlug);
2326
2564
  let outcome = "pending";
2327
2565
  let taskShortId;
2328
2566
  let failure = "";
@@ -2336,8 +2574,11 @@ function summarizeAddTask(argsJson, width, result2, maxLines = 2) {
2336
2574
  failure = failureText(result2.text, width);
2337
2575
  }
2338
2576
  }
2577
+ const tagCost = workspaceTag ? stringWidth(workspaceTag) + ADD_TASK_TAG_INSET : 0;
2578
+ const titleBudget = Math.max(1, width - ADD_TASK_HEADER_CHROME - tagCost);
2339
2579
  return {
2340
- title: fitWithSuffix(collapseWhitespace(title), "", width),
2580
+ title: fitWithSuffix(collapseWhitespace(title), "", titleBudget),
2581
+ ...declaredWorkspaceSlug && { workspaceSlug: declaredWorkspaceSlug },
2341
2582
  workerType: str(args.workerType) ?? str(args.worker_type) ?? "puppet",
2342
2583
  ...str(args.priority) && { priority: str(args.priority) },
2343
2584
  multiStage: args.multiStage === true,
@@ -2597,8 +2838,13 @@ function AddTaskRow({ section: section2, result: result2, lookups }) {
2597
2838
  const summary = summarizeAddTask(
2598
2839
  section2.text,
2599
2840
  SECONDARY_WIDTH,
2600
- result2 ? { text: result2.text, isError: result2.isError } : void 0
2841
+ result2 ? { text: result2.text, isError: result2.isError } : void 0,
2842
+ {
2843
+ defaultWorkspacePath: lookups.workspace.defaultWorkspacePath,
2844
+ workspaces: lookups.workspace.workspaces
2845
+ }
2601
2846
  );
2847
+ const workspaceTag = formatAddTaskWorkspaceTag(summary.workspaceSlug);
2602
2848
  const reuse = summary.assignToWorker !== void 0;
2603
2849
  const iconColor = summary.outcome === "failed" ? theme.action.failColor : theme.action.color;
2604
2850
  const reusedWorker = reuse ? lookups.workers.get(summary.assignToWorker) : void 0;
@@ -2623,6 +2869,7 @@ function AddTaskRow({ section: section2, result: result2, lookups }) {
2623
2869
  " #",
2624
2870
  summary.taskShortId
2625
2871
  ] }),
2872
+ workspaceTag ? /* @__PURE__ */ jsx3(Text2, { color: theme.secondary.color, children: workspaceTag }) : null,
2626
2873
  /* @__PURE__ */ jsx3(OutcomeMark, { outcome: summary.outcome, ok: "\u5DF2\u6D3E\u53D1", failed: "\u6D3E\u53D1\u5931\u8D25" })
2627
2874
  ] }),
2628
2875
  /* @__PURE__ */ jsx3(SecondaryLine, { children: metaLine }),
@@ -2817,14 +3064,20 @@ function shouldShowGap(prev, _current) {
2817
3064
  if (!prev) return false;
2818
3065
  return true;
2819
3066
  }
2820
- var ChatArea = React2.memo(function ChatArea2({ items, busy, streamingItems, contentWidth }) {
3067
+ var ChatArea = React2.memo(function ChatArea2({
3068
+ items,
3069
+ busy,
3070
+ streamingItems,
3071
+ contentWidth,
3072
+ workspace = EMPTY_WORKSPACE_CONTEXT
3073
+ }) {
2821
3074
  const width = contentWidth ?? 78;
2822
3075
  const { staticItems, dynamicItems } = useMemo(() => {
2823
3076
  const read = items.filter((i) => i.read);
2824
3077
  const unread = items.filter((i) => !i.read);
2825
3078
  return { staticItems: read, dynamicItems: unread };
2826
3079
  }, [items]);
2827
- const lookups = useMemo(() => buildChatLookups(items), [items]);
3080
+ const lookups = useMemo(() => buildChatLookups(items, workspace), [items, workspace]);
2828
3081
  return /* @__PURE__ */ jsxs5(Fragment2, { children: [
2829
3082
  /* @__PURE__ */ jsx8(Static, { items: staticItems, children: (item, idx) => /* @__PURE__ */ jsx8(
2830
3083
  Box5,
@@ -2975,29 +3228,61 @@ function renderShortcutList() {
2975
3228
  return lines.join("\n");
2976
3229
  }
2977
3230
 
2978
- // src/tui/utils/workspace-display.ts
2979
- function formatWorkspaceSlug(workspace) {
2980
- const slug = workspace?.slug?.trim();
2981
- return slug ? slug : null;
2982
- }
2983
- function formatWorkspaceSummary(workspaces) {
2984
- if (!workspaces || workspaces.length === 0) return null;
2985
- const slugs = workspaces.map((workspace) => workspace?.slug?.trim()).filter((slug) => !!slug);
2986
- if (slugs.length === 0) return null;
2987
- if (slugs.length === 1) return slugs[0];
2988
- return `${slugs[0]} +${slugs.length - 1}`;
3231
+ // src/tui/utils/task-list.ts
3232
+ var TASK_LIST_DESCRIPTION_MAX_WIDTH = 80;
3233
+ var TASK_LIST_MAX_ITEMS = 20;
3234
+ function truncateForList(text, maxWidth = TASK_LIST_DESCRIPTION_MAX_WIDTH) {
3235
+ return truncateToWidth(collapseWhitespace(text), maxWidth);
2989
3236
  }
2990
- function formatWorkspaceDetail(workspace) {
2991
- const path9 = workspace?.path?.trim();
2992
- const slug = workspace?.slug?.trim();
2993
- if (path9 && slug) return `${slug} (${path9})`;
2994
- return path9 || slug || null;
3237
+ var ACTIVE_TASK_STATUSES = /* @__PURE__ */ new Set([
3238
+ "pending",
3239
+ "running"
3240
+ ]);
3241
+ function isActiveTask(task) {
3242
+ return ACTIVE_TASK_STATUSES.has(task.status);
3243
+ }
3244
+ function limitForList(items, max = TASK_LIST_MAX_ITEMS, options = {}) {
3245
+ const isActive = options.isActive ?? (() => false);
3246
+ const total = items.length;
3247
+ if (total === 0) {
3248
+ return { items: [], total, hidden: 0, note: null };
3249
+ }
3250
+ const activeIndices = [];
3251
+ const historyIndices = [];
3252
+ items.forEach((item, index) => {
3253
+ (isActive(item) ? activeIndices : historyIndices).push(index);
3254
+ });
3255
+ const historyBudget = Math.max(0, max - activeIndices.length);
3256
+ const keptHistory = historyBudget === 0 ? [] : historyIndices.slice(-historyBudget);
3257
+ const keep = /* @__PURE__ */ new Set([...activeIndices, ...keptHistory]);
3258
+ const kept = items.filter((_, index) => keep.has(index));
3259
+ const hidden = total - kept.length;
3260
+ if (hidden === 0) {
3261
+ return { items: kept, total, hidden: 0, note: null };
3262
+ }
3263
+ const note = activeIndices.length > 0 ? `${noteFor(total, kept.length)}\uFF08\u53E6\u6709 ${hidden} \u6761\u5386\u53F2\u672A\u663E\u793A\uFF09` : noteFor(total, kept.length);
3264
+ return { items: kept, total, hidden, note };
3265
+ }
3266
+ function noteFor(total, shown) {
3267
+ return `\u2026 \u663E\u793A\u6700\u8FD1 ${shown} / \u5171 ${total} \u6761`;
3268
+ }
3269
+ function formatTaskRow(task, options = {}) {
3270
+ const workspacePart = formatTaskWorkspaceMark(task.workspace, options.defaultWorkspacePath);
3271
+ const description = truncateForList(task.description, options.descriptionMaxWidth);
3272
+ return `[${task.status}] ${task.id}: ${description}${workspacePart}${task.result ? ` \u2192 ${task.result}` : ""}`;
3273
+ }
3274
+ function formatTaskList(tasks, options = {}) {
3275
+ const limited = limitForList(tasks, options.maxItems, { isActive: isActiveTask });
3276
+ const lines = limited.items.map((task) => formatTaskRow(task, options));
3277
+ if (limited.note) lines.push(limited.note);
3278
+ return lines.join("\n");
2995
3279
  }
2996
3280
 
2997
3281
  // src/tui/hooks/use-slash-commands.ts
2998
3282
  var SLASH_COMMANDS = [
2999
3283
  { name: "tasks", args: "[status]", description: "List tasks" },
3000
3284
  { name: "workers", description: "List active workers" },
3285
+ { name: "projects", description: "List session workspaces" },
3001
3286
  { name: "cancel", args: "<id>", description: "Cancel a task" },
3002
3287
  { name: "context", description: "Show context token breakdown" },
3003
3288
  { name: "compact", description: "Compact context" },
@@ -3018,12 +3303,21 @@ function useSlashCommands(supervisor) {
3018
3303
  const status = parts[1];
3019
3304
  const tasks = await supervisor.engine.list(status);
3020
3305
  if (tasks.length === 0) return { handled: true, output: "No tasks" };
3021
- const lines = tasks.map((t) => {
3022
- const workspace = formatWorkspaceDetail(t.workspace);
3023
- const workspacePart = workspace ? ` \u2014 ${workspace}` : "";
3024
- return `[${t.status}] ${t.id}: ${t.description}${workspacePart}${t.result ? ` \u2192 ${t.result}` : ""}`;
3025
- });
3026
- return { handled: true, output: lines.join("\n") };
3306
+ return {
3307
+ handled: true,
3308
+ output: formatTaskList(tasks, { defaultWorkspacePath: supervisor.ctx.workspaceDir })
3309
+ };
3310
+ }
3311
+ case "projects": {
3312
+ const tasks = await supervisor.engine.list();
3313
+ return {
3314
+ handled: true,
3315
+ output: formatProjectList({
3316
+ workspaces: supervisor.ctx.workspaces,
3317
+ taskWorkspaces: tasks.map((task) => task.workspace ?? {}),
3318
+ defaultWorkspacePath: supervisor.ctx.workspaceDir
3319
+ })
3320
+ };
3027
3321
  }
3028
3322
  case "workers": {
3029
3323
  const activeWorkers = supervisor.engine.getActiveWorkers();
@@ -3057,8 +3351,9 @@ function useSlashCommands(supervisor) {
3057
3351
  handled: true,
3058
3352
  output: [
3059
3353
  "Commands:",
3060
- " /tasks [status] \u2014 List tasks",
3354
+ " /tasks [status] \u2014 List tasks (workspace slug when not the default)",
3061
3355
  " /workers \u2014 List active workers",
3356
+ " /projects \u2014 List session workspaces (* = default workspace)",
3062
3357
  " /cancel <id> \u2014 Cancel a task and abort its worker",
3063
3358
  " /context \u2014 Show context token breakdown",
3064
3359
  " /compact \u2014 Compact conversation context",
@@ -3768,15 +4063,15 @@ import { Box as Box8, Text as Text9, useStdout } from "ink";
3768
4063
 
3769
4064
  // src/tui/hooks/use-wayang-state.ts
3770
4065
  import { useState as useState4, useEffect as useEffect3 } from "react";
3771
- function useWayangState(source, path9) {
3772
- const [value, setValue] = useState4(() => source.getSnapshot(path9));
4066
+ function useWayangState(source, path10) {
4067
+ const [value, setValue] = useState4(() => source.getSnapshot(path10));
3773
4068
  useEffect3(() => {
3774
- const unSub = source.subscribe(path9, () => {
3775
- setValue(source.getSnapshot(path9));
4069
+ const unSub = source.subscribe(path10, () => {
4070
+ setValue(source.getSnapshot(path10));
3776
4071
  });
3777
- setValue(source.getSnapshot(path9));
4072
+ setValue(source.getSnapshot(path10));
3778
4073
  return unSub;
3779
- }, [source, path9]);
4074
+ }, [source, path10]);
3780
4075
  return value;
3781
4076
  }
3782
4077
 
@@ -4162,6 +4457,13 @@ function ControllerPage({ onExit }) {
4162
4457
  break;
4163
4458
  }
4164
4459
  });
4460
+ const workspaceContext = useMemo4(
4461
+ () => ({
4462
+ defaultWorkspacePath: supervisor.ctx.workspaceDir,
4463
+ workspaces: supervisor.ctx.workspaces
4464
+ }),
4465
+ [supervisor]
4466
+ );
4165
4467
  const { handleCommand } = useSlashCommands(supervisor);
4166
4468
  const handleSubmit = useMemoizedFn(async (input2) => {
4167
4469
  let cmdResult;
@@ -4201,7 +4503,8 @@ function ControllerPage({ onExit }) {
4201
4503
  items: displayItems,
4202
4504
  busy,
4203
4505
  streamingItems,
4204
- contentWidth: chatContentWidth
4506
+ contentWidth: chatContentWidth,
4507
+ workspace: workspaceContext
4205
4508
  }
4206
4509
  ) }),
4207
4510
  overlay && /* @__PURE__ */ jsx13(OverlayPanel, { overlay }),
@@ -4268,7 +4571,7 @@ function AppContent() {
4268
4571
  // package.json
4269
4572
  var package_default = {
4270
4573
  name: "wayang-ai",
4271
- version: "0.8.0",
4574
+ version: "0.8.3",
4272
4575
  description: "Multi-agent orchestration platform",
4273
4576
  type: "module",
4274
4577
  license: "MIT",
@@ -4395,6 +4698,36 @@ import * as fs5 from "fs";
4395
4698
  import * as path6 from "path";
4396
4699
  import * as childProcess from "child_process";
4397
4700
  import prompts from "prompts";
4701
+
4702
+ // src/infra/permission/permission-config.ts
4703
+ var DEFAULT_SENSITIVE_MODE = "ask";
4704
+ function mergeSensitiveDirs(permission) {
4705
+ const entries = [...permission?.sensitiveDirs ?? [], ...permission?.deniedDirs ?? []];
4706
+ const seen = /* @__PURE__ */ new Set();
4707
+ const merged = [];
4708
+ for (const entry of entries) {
4709
+ if (typeof entry !== "string" || entry.length === 0) continue;
4710
+ if (seen.has(entry)) continue;
4711
+ seen.add(entry);
4712
+ merged.push(entry);
4713
+ }
4714
+ return merged;
4715
+ }
4716
+ function resolveSensitiveMode(permission) {
4717
+ return permission?.sensitiveMode ?? DEFAULT_SENSITIVE_MODE;
4718
+ }
4719
+ var deprecatedDeniedDirsWarned = false;
4720
+ function warnDeprecatedPermissionKeys(permission, warn) {
4721
+ if (deprecatedDeniedDirsWarned) return;
4722
+ const legacy = permission?.deniedDirs;
4723
+ if (!Array.isArray(legacy) || legacy.length === 0) return;
4724
+ deprecatedDeniedDirsWarned = true;
4725
+ warn(
4726
+ "permission.deniedDirs is deprecated and will be removed in a future release \u2014 use permission.sensitiveDirs instead (both keys are read and merged)."
4727
+ );
4728
+ }
4729
+
4730
+ // src/onboard.ts
4398
4731
  function fillApiKeys(config) {
4399
4732
  const envKey = process.env.WAYANG_LLM_API_KEY || "";
4400
4733
  for (const provider of Object.values(config.providers)) {
@@ -4549,6 +4882,7 @@ async function loadConfig(configPath2, options = {}) {
4549
4882
  }
4550
4883
  }
4551
4884
  fillApiKeys(config);
4885
+ warnDeprecatedPermissionKeys(config.permission, (message) => console.warn(`Warning: ${message}`));
4552
4886
  const err = validateConfig(config);
4553
4887
  if (err) {
4554
4888
  if (nonInteractive) throw new Error(`Invalid config: ${err}`);
@@ -5137,11 +5471,11 @@ var SignalQueue = class {
5137
5471
  this.counter = Math.max(mark, this.maxIdInWindow());
5138
5472
  }
5139
5473
  // --- Subscribable ---
5140
- subscribe(path9, callback) {
5141
- return this.state.on(path9, callback);
5474
+ subscribe(path10, callback) {
5475
+ return this.state.on(path10, callback);
5142
5476
  }
5143
- getSnapshot(path9) {
5144
- return this.state.get(path9);
5477
+ getSnapshot(path10) {
5478
+ return this.state.get(path10);
5145
5479
  }
5146
5480
  enqueue(sig) {
5147
5481
  const id = `sig-${++this.counter}`;
@@ -5261,7 +5595,7 @@ var SignalQueue = class {
5261
5595
  };
5262
5596
 
5263
5597
  // src/services/worker-transcript.ts
5264
- import { closeSync as closeSync3, existsSync as existsSync8, openSync as openSync3, readSync, statSync } from "fs";
5598
+ import { closeSync as closeSync4, existsSync as existsSync8, openSync as openSync4, readSync as readSync2, statSync as statSync2 } from "fs";
5265
5599
  import { join as join12 } from "path";
5266
5600
 
5267
5601
  // src/services/agents/worker-state.ts
@@ -5770,9 +6104,9 @@ function workerTranscriptPath(sessionDir, workerId) {
5770
6104
  }
5771
6105
  function readWorkerTranscript(sessionDir, workerId) {
5772
6106
  if (!isValidWorkerId(workerId)) return null;
5773
- const path9 = workerTranscriptPath(sessionDir, workerId);
5774
- if (!existsSync8(path9)) return null;
5775
- return new JSONLFileHelper(path9).read();
6107
+ const path10 = workerTranscriptPath(sessionDir, workerId);
6108
+ if (!existsSync8(path10)) return null;
6109
+ return new JSONLFileHelper(path10).read();
5776
6110
  }
5777
6111
  var MAX_TRANSCRIPT_WINDOW_BYTES = 2 * 1024 * 1024;
5778
6112
  var MIN_WINDOW_BYTES = 64 * 1024;
@@ -5802,10 +6136,10 @@ function parseWindow(text, startsAtFileHead) {
5802
6136
  }
5803
6137
  function readWorkerTranscriptWindow(sessionDir, workerId, opts = {}) {
5804
6138
  if (!isValidWorkerId(workerId)) return null;
5805
- const path9 = workerTranscriptPath(sessionDir, workerId);
6139
+ const path10 = workerTranscriptPath(sessionDir, workerId);
5806
6140
  let size;
5807
6141
  try {
5808
- size = statSync(path9).size;
6142
+ size = statSync2(path10).size;
5809
6143
  } catch {
5810
6144
  return null;
5811
6145
  }
@@ -5819,11 +6153,11 @@ function readWorkerTranscriptWindow(sessionDir, workerId, opts = {}) {
5819
6153
  const readLen = Math.min(size, windowBytes);
5820
6154
  const start = size - readLen;
5821
6155
  const chunk = Buffer.alloc(readLen);
5822
- const fd = openSync3(path9, "r");
6156
+ const fd = openSync4(path10, "r");
5823
6157
  try {
5824
- readSync(fd, chunk, 0, readLen, start);
6158
+ readSync2(fd, chunk, 0, readLen, start);
5825
6159
  } finally {
5826
- closeSync3(fd);
6160
+ closeSync4(fd);
5827
6161
  }
5828
6162
  const parsed = parseWindow(chunk.toString("utf8"), start === 0);
5829
6163
  const enough = parsed.entries.filter(matches).length >= need;
@@ -5873,7 +6207,7 @@ function entryLabel(entry) {
5873
6207
  return subtype ? `${entry.type}/${subtype}` : entry.type;
5874
6208
  }
5875
6209
  var TERMINAL_SUBTYPES = /* @__PURE__ */ new Set(["task_result" /* TaskResult */, "error" /* Error */]);
5876
- function entryTimestamp(entry) {
6210
+ function entryTimestamp2(entry) {
5877
6211
  const e = entry;
5878
6212
  for (const raw of [e.timestamp, e.endedAt, e.startedAt]) {
5879
6213
  if (typeof raw === "string") {
@@ -5886,7 +6220,7 @@ function entryTimestamp(entry) {
5886
6220
  function summarizeTranscript(entries) {
5887
6221
  let lastActivityAt = null;
5888
6222
  for (let i = entries.length - 1; i >= 0; i--) {
5889
- lastActivityAt = entryTimestamp(entries[i]);
6223
+ lastActivityAt = entryTimestamp2(entries[i]);
5890
6224
  if (lastActivityAt !== null) break;
5891
6225
  }
5892
6226
  let currentTool = null;
@@ -6723,6 +7057,23 @@ ${lines}`
6723
7057
  );
6724
7058
  }
6725
7059
 
7060
+ // src/infra/general-workspace.ts
7061
+ import { mkdirSync as mkdirSync6 } from "fs";
7062
+ import { homedir } from "os";
7063
+ import { join as join14 } from "path";
7064
+ var GENERAL_WORKSPACE_DIRNAME = "workspace";
7065
+ function wayangDirFor(ctx) {
7066
+ return wayangDirForSessionDir(ctx.sessionDir) ?? join14(homedir(), ".wayang");
7067
+ }
7068
+ function resolveGeneralWorkspace(ctx) {
7069
+ return join14(wayangDirFor(ctx), GENERAL_WORKSPACE_DIRNAME);
7070
+ }
7071
+ function ensureGeneralWorkspace(ctx) {
7072
+ const dir = resolveGeneralWorkspace(ctx);
7073
+ mkdirSync6(dir, { recursive: true });
7074
+ return dir;
7075
+ }
7076
+
6726
7077
  // src/services/agents/prompts/controller-prompt.ts
6727
7078
  var IDENTITY = section(
6728
7079
  "Identity",
@@ -6999,13 +7350,17 @@ You resumed a previous session that had unfinished tasks. Workers were NOT autom
6999
7350
 
7000
7351
  Do NOT blindly re-create everything. The user may have moved on. Prefer to summarize and ask unless the intent is obvious.`
7001
7352
  );
7002
- var WORKING_PRINCIPLES = section(
7003
- "Working principles",
7004
- `\u3010\u4EFB\u52A1\u62C6\u5206\u3011
7353
+ function buildWorkingPrinciples(generalWorkspace) {
7354
+ const body = `\u3010\u4EFB\u52A1\u62C6\u5206\u3011
7005
7355
  - \u4E00\u4E2A\u4EFB\u52A1\u53EA\u4EA4\u4ED8\u4E00\u4E2A\u53EF\u72EC\u7ACB\u9A8C\u6536\u7684\u7ED3\u679C\uFF1B\u4E0D\u8981\u628A\u8BBE\u8BA1\u3001\u5B9E\u73B0\u3001\u6D4B\u8BD5\u585E\u8FDB\u540C\u4E00\u6BB5\u3002
7006
7356
  - \u5148\u505A\u80FD\u88AB\u5355\u6D4B\u65AD\u8A00\u7684\u6838\u5FC3\u903B\u8F91\uFF0C\u628A\u8FB9\u754C\uFF08\u5BBD\u5B57\u7B26\u3001\u4EE3\u7406\u5BF9\u3001\u6298\u884C\u4F4D\u7F6E\u4E4B\u7C7B\uFF09\u56FA\u5316\u6210\u7528\u4F8B\uFF0C\u518D\u53E0\u52A0\u96C6\u6210\u4E0E\u4EA4\u4E92\u5C42\u3002
7007
7357
  - \u4EFB\u52A1\u63CF\u8FF0\u5FC5\u987B\u81EA\u5305\u542B\uFF1A\u80CC\u666F\u3001\u76EE\u6807\u3001\u8F93\u5165\u8F93\u51FA\u3001\u7EA6\u675F\u3001\u9A8C\u6536\u6807\u51C6\u3002\u6267\u884C\u65B9\u770B\u4E0D\u5230\u4F60\u7684\u5BF9\u8BDD\u3002
7008
7358
 
7359
+ \u3010\u5DE5\u4F5C\u533A\u3011
7360
+ - \u7528\u6237\u7ED9\u4E86\u76EE\u5F55\uFF0C\u6216\u4EFB\u52A1\u660E\u663E\u5C5E\u4E8E\u67D0\u4E2A\u4EE3\u7801\u5E93\uFF1A\u7528\u90A3\u4E2A\u76EE\u5F55\u3002
7361
+ - \u7528\u6237\u6CA1\u6307\u5B9A\u3001\u6216\u4EFB\u52A1\u4E0E\u4EE3\u7801\u5E93\u65E0\u5173\uFF08\u8C03\u7814\u3001\u8D44\u6599\u6574\u7406\u3001\u4FE1\u606F\u67E5\u8BE2\u3001\u5199\u6587\u6863\u8349\u7A3F\uFF09\uFF1A\u7528\u901A\u7528\u5DE5\u4F5C\u533A ${generalWorkspace}\uFF0C\u5B83\u4E0D\u5C5E\u4E8E\u4EFB\u4F55\u4ED3\u5E93\uFF0C\u9996\u6B21\u4F7F\u7528\u81EA\u52A8\u521B\u5EFA\u3002
7362
+ - \u4EFB\u52A1\u4F1A\u7559\u4E0B\u4E2D\u95F4\u4EA7\u7269\u65F6\u5199\u8FDB\u63CF\u8FF0\uFF1A\u4E34\u65F6\u6587\u4EF6\u653E\u4EFB\u52A1 workspace \u5185\u7684 .agents/tmp/\uFF0C\u4E0D\u8981\u7528 /tmp\u3002
7363
+
7009
7364
  \u3010\u5206\u5DE5\u4E0E\u5BA1\u6838\u3011
7010
7365
  - \u8BBE\u8BA1\u5224\u65AD\u4E0E\u673A\u68B0\u5B9E\u73B0\u5206\u5F00\u6D3E\uFF1A\u8BBE\u8BA1\u65B9\u5148\u5B9E\u6D4B\u5173\u952E\u884C\u4E3A\uFF0C\u4EA7\u51FA\u300C\u6539\u54EA\u4E2A\u6587\u4EF6\u3001\u51FD\u6570\u7B7E\u540D\u3001\u6570\u636E\u7ED3\u6784\u3001\u8FB9\u754C\u60C5\u51B5\u3001\u6D4B\u8BD5\u6E05\u5355\u300D\u7684\u65B9\u6848\uFF1B\u6267\u884C\u65B9\u7167\u65B9\u6848\u7F16\u7801\uFF0C\u4E0D\u505A\u8BBE\u8BA1\u51B3\u7B56\u3002
7011
7366
  - \u65B9\u6848\u91CC\u8981\u663E\u5F0F\u5199\u51FA\u300C\u4E0D\u8981\u505A\u4EC0\u4E48\u300D\uFF0C\u5426\u5219\u6267\u884C\u65B9\u4F1A\u987A\u624B\u91CD\u6784\u65E0\u5173\u4EE3\u7801\u3002
@@ -7030,8 +7385,9 @@ var WORKING_PRINCIPLES = section(
7030
7385
  - \u540C\u533A\u4E92\u5377 \u2014\u2014 \u8BA9\u4E24\u4E2A\u6267\u884C\u8005\u5728\u540C\u4E00\u5DE5\u4F5C\u533A\u5E76\u53D1\u63D0\u4EA4\uFF0C\u4E92\u76F8\u628A\u5BF9\u65B9\u7684\u6539\u52A8\u5377\u8FDB\u81EA\u5DF1\u7684 commit\u3002
7031
7386
  - \u9759\u9ED8\u5224\u6B7B \u2014\u2014 \u51ED\u8F93\u51FA\u9759\u9ED8\u5224\u65AD\u6B7B\u6D3B\uFF1A\u628A\u5728\u8DD1\u957F\u4EFB\u52A1\u5F53\u6210\u5361\u6B7B\u6740\u6389\uFF0C\u6216\u628A\u6D3B\u9501\u5F53\u6210\u6162\u4EFB\u52A1\u6B7B\u7B49\u3002
7032
7387
  - \u7EFF\u706F\u5373\u5B8C\u6210 \u2014\u2014 \u5355\u6D4B\u6216\u6D41\u7A0B\u5168\u7EFF\u5C31\u5BA3\u5E03\u5B8C\u6210\uFF1B\u5076\u53D1\u5931\u8D25\u590D\u8DD1\u4E00\u6B21\u901A\u8FC7\u5C31\u5F53\u6CA1\u53D1\u751F\uFF0C\u4E0D\u505A\u8BB0\u5F55\u3002
7033
- - \u96F6\u788E\u53D6\u8BC1 \u2014\u2014 \u628A\u4E00\u6B21\u53D6\u8BC1\u62C6\u6210\u51E0\u5341\u6761\u96F6\u788E\u53EA\u8BFB\u547D\u4EE4\u9010\u6761\u7B49\u6279\u51C6\uFF0C\u6392\u969C\u88AB\u6D41\u7A0B\u672C\u8EAB\u62D6\u6B7B\u3002`
7034
- );
7388
+ - \u96F6\u788E\u53D6\u8BC1 \u2014\u2014 \u628A\u4E00\u6B21\u53D6\u8BC1\u62C6\u6210\u51E0\u5341\u6761\u96F6\u788E\u53EA\u8BFB\u547D\u4EE4\u9010\u6761\u7B49\u6279\u51C6\uFF0C\u6392\u969C\u88AB\u6D41\u7A0B\u672C\u8EAB\u62D6\u6B7B\u3002`;
7389
+ return section("Working principles", body);
7390
+ }
7035
7391
  var HARD_CONSTRAINTS = section(
7036
7392
  "Hard constraints",
7037
7393
  `- NEVER execute commands or write files yourself. Delegate via add_task\uFF08worker will execute\uFF09.
@@ -7050,24 +7406,93 @@ function buildWorkerList(workers) {
7050
7406
  }
7051
7407
  return section("Available workers", lines.join("\n"));
7052
7408
  }
7053
- function buildControllerSystemPrompt(ctx, skills) {
7409
+ function buildControllerSystemPrompt(ctx, skills, opts) {
7054
7410
  return assemble(
7055
7411
  IDENTITY,
7056
7412
  RESPONSE_STYLE,
7057
7413
  TOOL_USAGE,
7058
7414
  PLANNING_TOOLS,
7059
7415
  SIGNAL_HANDLING,
7060
- WORKING_PRINCIPLES,
7416
+ buildWorkingPrinciples(resolveGeneralWorkspace(ctx)),
7061
7417
  HARD_CONSTRAINTS,
7062
7418
  buildWorkerList(ctx.config.workers),
7063
7419
  buildSkillCatalog(skills),
7064
- buildEnvironment(ctx)
7420
+ buildEnvironment(ctx),
7421
+ // Project conventions go last: they are the most specific instruction and
7422
+ // the last word on conflicts with the generic sections above.
7423
+ opts?.rules
7065
7424
  );
7066
7425
  }
7067
7426
 
7427
+ // src/services/agents/prompts/controller-rules.ts
7428
+ import { readFileSync as readFileSync8, realpathSync as realpathSync2, statSync as statSync3 } from "fs";
7429
+ import { join as join15 } from "path";
7430
+ var CONTROLLER_RULES_FILENAME = "AGENTS.md";
7431
+ var CONTROLLER_RULES_MAX_BYTES = 32 * 1024;
7432
+ function loadControllerRules(opts) {
7433
+ const maxBytes = opts.maxBytes ?? CONTROLLER_RULES_MAX_BYTES;
7434
+ const files = [];
7435
+ for (const dir of [opts.homeDir, opts.cwd]) {
7436
+ if (!dir) continue;
7437
+ const file = readRulesFile(dir);
7438
+ if (!file) continue;
7439
+ if (files.some((seen) => seen.path === file.path)) continue;
7440
+ files.push(file);
7441
+ }
7442
+ if (files.length === 0) return "";
7443
+ const bodies = [];
7444
+ let usedBytes = 0;
7445
+ let truncated = false;
7446
+ for (const file of files) {
7447
+ const heading = `## Project rules (from ${file.path})`;
7448
+ const size = Buffer.byteLength(file.content, "utf8");
7449
+ if (usedBytes + size <= maxBytes) {
7450
+ bodies.push(`${heading}
7451
+
7452
+ ${file.content.trimEnd()}`);
7453
+ usedBytes += size;
7454
+ continue;
7455
+ }
7456
+ const budget = maxBytes - usedBytes;
7457
+ const head = budget > 0 ? truncateToBytes(file.content, budget).trimEnd() : "";
7458
+ bodies.push(head ? `${heading}
7459
+
7460
+ ${head}` : heading);
7461
+ truncated = true;
7462
+ break;
7463
+ }
7464
+ const intro = `${CONTROLLER_RULES_FILENAME} content loaded once at startup. The home directory file comes first as the baseline; the working directory file comes second and takes precedence wherever the two disagree. Follow these rules unless the user explicitly overrides them.`;
7465
+ const parts = [`# Project rules
7466
+
7467
+ ${intro}`, ...bodies];
7468
+ if (truncated) {
7469
+ parts.push(
7470
+ `...truncated at ${maxBytes} bytes (rules content exceeded the ${maxBytes}-byte total limit: the rest of the last file and any later files were dropped)`
7471
+ );
7472
+ }
7473
+ return parts.join("\n\n");
7474
+ }
7475
+ function readRulesFile(dir) {
7476
+ const candidate = join15(dir, CONTROLLER_RULES_FILENAME);
7477
+ try {
7478
+ const path10 = realpathSync2(candidate);
7479
+ if (!statSync3(path10).isFile()) return null;
7480
+ const content = readFileSync8(path10, "utf8");
7481
+ if (!content.trim()) return null;
7482
+ return { path: path10, content };
7483
+ } catch {
7484
+ return null;
7485
+ }
7486
+ }
7487
+ function truncateToBytes(text, maxBytes) {
7488
+ const buf = Buffer.from(text, "utf8");
7489
+ if (buf.length <= maxBytes) return text;
7490
+ return buf.subarray(0, maxBytes).toString("utf8").replace(/\uFFFD+$/, "");
7491
+ }
7492
+
7068
7493
  // src/services/agents/prompts/project-rules.ts
7069
- import { existsSync as existsSync9, readFileSync as readFileSync8, statSync as statSync2 } from "fs";
7070
- import { basename as basename5, dirname as dirname6, join as join14, resolve as resolve3 } from "path";
7494
+ import { existsSync as existsSync9, readFileSync as readFileSync9, statSync as statSync4 } from "fs";
7495
+ import { basename as basename5, dirname as dirname6, join as join16, resolve as resolve3 } from "path";
7071
7496
  var RULE_FILE_NAMES = ["AGENTS.md", "CLAUDE.md"];
7072
7497
  var MAX_LEVELS = 5;
7073
7498
  var PROJECT_RULES_MAX_LENGTH = 8e3;
@@ -7076,7 +7501,7 @@ function findProjectRules(startDir) {
7076
7501
  for (let level = 0; level < MAX_LEVELS; level++) {
7077
7502
  const found = readRuleFile(current);
7078
7503
  if (found) return found;
7079
- if (existsSync9(join14(current, ".git"))) break;
7504
+ if (existsSync9(join16(current, ".git"))) break;
7080
7505
  const parent = dirname6(current);
7081
7506
  if (parent === current) break;
7082
7507
  current = parent;
@@ -7085,12 +7510,12 @@ function findProjectRules(startDir) {
7085
7510
  }
7086
7511
  function readRuleFile(dir) {
7087
7512
  for (const name of RULE_FILE_NAMES) {
7088
- const path9 = join14(dir, name);
7513
+ const path10 = join16(dir, name);
7089
7514
  try {
7090
- if (!statSync2(path9).isFile()) continue;
7091
- const content = readFileSync8(path9, "utf8");
7515
+ if (!statSync4(path10).isFile()) continue;
7516
+ const content = readFileSync9(path10, "utf8");
7092
7517
  if (!content.trim()) continue;
7093
- return { path: path9, content };
7518
+ return { path: path10, content };
7094
7519
  } catch {
7095
7520
  }
7096
7521
  }
@@ -7166,6 +7591,9 @@ Only use bash when no dedicated tool fits the task (e.g. running builds, install
7166
7591
  - edit_file modifies an existing file by replacing a unique string. The old_string must appear exactly once. If it matches multiple times, the tool will tell you \u2014 expand the surrounding context to make it unique.
7167
7592
  - Always verify changes: after write_file or edit_file, use read_file to confirm the content is correct. For large files, use offset and limit to read specific sections instead of the entire file.
7168
7593
  - Use relative paths (relative to workspace). Accessing files outside workspace requires controller approval.
7594
+ - Scratch files and intermediate artifacts go to \`.agents/tmp/\` inside the current task workspace. Never \`/tmp\` \u2014 it sits outside the workspace, so every command there costs an approval \u2014 and never the home directory or the repository root.
7595
+ - Anything worth keeping goes to a normal path in the workspace (it is version-controlled); one-off intermediates stay in \`.agents/tmp/\`.
7596
+ - Before writing, confirm which workspace you are in: \`pwd\`, or the workspace named in the task description \u2014 that one outranks any path you assume.
7169
7597
 
7170
7598
  ## Web search
7171
7599
 
@@ -7285,6 +7713,7 @@ var RULES = [
7285
7713
  "Report meaningful progress as you work (entering a new phase, completing a sub-step, recovering from an error).",
7286
7714
  "Do NOT output a final summary right before completing \u2014 the system already captures the result separately.",
7287
7715
  "Match the language of the task description.",
7716
+ "Keep scratch files and intermediate artifacts in `.agents/tmp/` inside the task workspace \u2014 never `/tmp`, the home directory, or the repository root.",
7288
7717
  "</Rules>"
7289
7718
  ].join("\n");
7290
7719
  function buildThirdPartyPrompt(skills) {
@@ -7477,11 +7906,11 @@ var WorkerAgent = class extends BaseAgent {
7477
7906
  return this.state.get("conversation") ?? [];
7478
7907
  }
7479
7908
  // --- Subscribable ---
7480
- subscribe(path9, callback) {
7481
- return this.state.on(path9, callback);
7909
+ subscribe(path10, callback) {
7910
+ return this.state.on(path10, callback);
7482
7911
  }
7483
- getSnapshot(path9) {
7484
- return this.state.get(path9);
7912
+ getSnapshot(path10) {
7913
+ return this.state.get(path10);
7485
7914
  }
7486
7915
  /**
7487
7916
  * Accept a message from the controller — writes to internal inbox. The
@@ -7588,9 +8017,9 @@ function toolResultBlockToRecord(block, toolName, endedAt) {
7588
8017
  import { resolve as resolve5 } from "path";
7589
8018
 
7590
8019
  // src/infra/permission/policy.ts
7591
- import { realpathSync as realpathSync2 } from "fs";
7592
- import { basename as basename6, dirname as dirname7, isAbsolute, join as join15, resolve as resolve4, sep } from "path";
7593
- var DEFAULT_DENIED_RELATIVE_PATHS = [
8020
+ import { realpathSync as realpathSync3 } from "fs";
8021
+ import { basename as basename6, dirname as dirname7, isAbsolute, join as join17, resolve as resolve4, sep } from "path";
8022
+ var DEFAULT_SENSITIVE_RELATIVE_PATHS = [
7594
8023
  ".ssh",
7595
8024
  ".gnupg",
7596
8025
  ".aws",
@@ -7602,9 +8031,7 @@ var DEFAULT_DENIED_RELATIVE_PATHS = [
7602
8031
  ".git-credentials",
7603
8032
  ".npmrc",
7604
8033
  ".pypirc",
7605
- ".config/gh",
7606
- ".wayang.config.json",
7607
- ".wayang"
8034
+ ".config/gh"
7608
8035
  ];
7609
8036
  var SENSITIVE_SEGMENTS = /* @__PURE__ */ new Set([
7610
8037
  ".ssh",
@@ -7616,20 +8043,17 @@ var SENSITIVE_SEGMENTS = /* @__PURE__ */ new Set([
7616
8043
  ".netrc",
7617
8044
  ".git-credentials",
7618
8045
  ".npmrc",
7619
- ".pypirc",
7620
- // The wayang data dir (sessions/conversations) — same intent as the
7621
- // home-anchored `.wayang` entry, but applied wherever it appears.
7622
- ".wayang"
7623
- ]);
7624
- var SENSITIVE_FILENAMES = /* @__PURE__ */ new Set([
7625
- ".wayang.config.json"
8046
+ ".pypirc"
7626
8047
  ]);
8048
+ var SENSITIVE_SEGMENT_PAIRS = [
8049
+ [".docker", "config.json"]
8050
+ ];
7627
8051
  var MAX_ANCESTOR_DEPTH = 32;
7628
8052
  function normalizePath(p, opts = {}) {
7629
8053
  const base = opts.base ?? process.cwd();
7630
8054
  const lexical = isAbsolute(p) ? resolve4(p) : resolve4(base, p);
7631
8055
  try {
7632
- return realpathSync2(lexical);
8056
+ return realpathSync3(lexical);
7633
8057
  } catch {
7634
8058
  return resolveDeepestExistingAncestor(lexical);
7635
8059
  }
@@ -7639,8 +8063,8 @@ function resolveDeepestExistingAncestor(lexical) {
7639
8063
  let candidate = lexical;
7640
8064
  for (let depth = 0; depth <= MAX_ANCESTOR_DEPTH; depth += 1) {
7641
8065
  try {
7642
- const resolved = realpathSync2(candidate);
7643
- return peeled.length === 0 ? resolved : resolve4(join15(resolved, ...peeled));
8066
+ const resolved = realpathSync3(candidate);
8067
+ return peeled.length === 0 ? resolved : resolve4(join17(resolved, ...peeled));
7644
8068
  } catch {
7645
8069
  }
7646
8070
  const parent = dirname7(candidate);
@@ -7650,8 +8074,8 @@ function resolveDeepestExistingAncestor(lexical) {
7650
8074
  }
7651
8075
  return lexical;
7652
8076
  }
7653
- function defaultDeniedPaths(homeDir2) {
7654
- return DEFAULT_DENIED_RELATIVE_PATHS.map((rel) => resolve4(homeDir2, rel));
8077
+ function defaultSensitivePaths(homeDir2) {
8078
+ return DEFAULT_SENSITIVE_RELATIVE_PATHS.map((rel) => resolve4(homeDir2, rel));
7655
8079
  }
7656
8080
  function isPathWithin(parent, child) {
7657
8081
  if (parent === sep) return child.startsWith(sep);
@@ -7659,21 +8083,38 @@ function isPathWithin(parent, child) {
7659
8083
  const prefix = parent.endsWith(sep) ? parent : parent + sep;
7660
8084
  return child.startsWith(prefix);
7661
8085
  }
7662
- function isDenied(absPath, deniedPaths) {
7663
- return deniedPaths.some((denied) => isPathWithin(denied, absPath));
8086
+ function isDenied(absPath, sensitivePaths) {
8087
+ return sensitivePaths.some((sensitive) => isPathWithin(sensitive, absPath));
7664
8088
  }
7665
- function isSensitiveSegmentPath(absPath) {
8089
+ function matchSensitiveEntry(absPath) {
7666
8090
  const segments = absPath.split(sep).filter((s) => s.length > 0);
7667
8091
  for (let i = 0; i < segments.length; i += 1) {
7668
8092
  const segment = segments[i];
7669
- if (SENSITIVE_SEGMENTS.has(segment) || SENSITIVE_FILENAMES.has(segment)) {
7670
- return true;
7671
- }
7672
- if (segment === ".docker" && segments[i + 1] === "config.json") {
7673
- return true;
8093
+ if (SENSITIVE_SEGMENTS.has(segment)) return { entry: segment, source: "segment" };
8094
+ for (const [dir, child] of SENSITIVE_SEGMENT_PAIRS) {
8095
+ if (segment === dir && segments[i + 1] === child) {
8096
+ return { entry: `${dir}/${child}`, source: "pair" };
8097
+ }
7674
8098
  }
7675
8099
  }
7676
- return false;
8100
+ return void 0;
8101
+ }
8102
+ function sensitivePathEntries(homeDir2, extraSensitiveDirs) {
8103
+ const builtIn = DEFAULT_SENSITIVE_RELATIVE_PATHS.map((rel) => ({
8104
+ absPath: normalizePath(resolve4(homeDir2, rel)),
8105
+ entry: rel,
8106
+ source: "home-default"
8107
+ }));
8108
+ const configured = (extraSensitiveDirs ?? []).filter((d) => typeof d === "string" && d.length > 0).map((d) => ({
8109
+ absPath: normalizePath(d, { base: homeDir2 }),
8110
+ entry: d,
8111
+ source: "config-dir"
8112
+ }));
8113
+ return [...builtIn, ...configured];
8114
+ }
8115
+ function matchSensitivePath(homeDir2, absPath, extraSensitiveDirs) {
8116
+ const hit = sensitivePathEntries(homeDir2, extraSensitiveDirs).find((entry) => isPathWithin(entry.absPath, absPath));
8117
+ return hit ? { entry: hit.entry, source: hit.source } : void 0;
7677
8118
  }
7678
8119
  function normalizeDirs(dirs, base) {
7679
8120
  if (!dirs) return [];
@@ -7689,15 +8130,17 @@ function decideAccess(input2) {
7689
8130
  const taskAllowedDirs = normalizeDirs(input2.taskAllowedDirs, taskWorkspace ?? homeDir2);
7690
8131
  const configAllowedDirs = normalizeDirs(input2.configAllowedDirs, homeDir2);
7691
8132
  const autoAllowRoots = normalizeDirs(input2.autoAllowRoots, homeDir2);
7692
- const deniedPaths = [
7693
- ...defaultDeniedPaths(homeDir2),
7694
- ...normalizeDirs(input2.extraDeniedDirs, homeDir2)
7695
- ].map((d) => normalizePath(d));
7696
- if (isSensitiveSegmentPath(resolvedPath)) {
7697
- return { decision: "deny", matchedRule: "denylist", resolvedPath };
7698
- }
7699
- if (isDenied(resolvedPath, deniedPaths)) {
7700
- return { decision: "deny", matchedRule: "denylist", resolvedPath };
8133
+ const sensitiveMode = input2.sensitiveMode ?? "ask";
8134
+ const sensitive = matchSensitiveEntry(resolvedPath) ?? matchSensitivePath(homeDir2, resolvedPath, input2.extraSensitiveDirs);
8135
+ if (sensitive) {
8136
+ const hit = { matchedEntry: sensitive.entry, matchedSource: sensitive.source, sensitive: true };
8137
+ if (input2.access === "write") {
8138
+ return { decision: "deny", matchedRule: "sensitive-list-write", resolvedPath, ...hit };
8139
+ }
8140
+ if (sensitiveMode === "deny") {
8141
+ return { decision: "deny", matchedRule: "sensitive-list", resolvedPath, ...hit };
8142
+ }
8143
+ return { decision: "ask", matchedRule: "sensitive-list", resolvedPath, ...hit };
7701
8144
  }
7702
8145
  if (findContaining(configAllowedDirs, resolvedPath) !== void 0) {
7703
8146
  return { decision: "allow", matchedRule: "config-allowed", resolvedPath };
@@ -7717,11 +8160,403 @@ function decideAccess(input2) {
7717
8160
  return { decision: "ask", matchedRule: "outside-all", resolvedPath };
7718
8161
  }
7719
8162
 
8163
+ // src/services/tools/shell-lexer.ts
8164
+ var CMDSUBST_PLACEHOLDER = "__cmdsubst__";
8165
+ var DISCARD_TARGETS = /* @__PURE__ */ new Set(["/dev/null", "/dev/stdout", "/dev/stderr"]);
8166
+ function isDiscardRedirectTarget(target) {
8167
+ return target !== void 0 && DISCARD_TARGETS.has(target);
8168
+ }
8169
+ function isWordTerminator(ch) {
8170
+ return ch === " " || ch === " " || ch === "\n" || ch === "\r" || ch === "|" || ch === ";" || ch === "&" || ch === "<" || ch === ">";
8171
+ }
8172
+ function findClosingDoubleQuote(command, start) {
8173
+ let i = start + 1;
8174
+ while (i < command.length) {
8175
+ const ch = command[i];
8176
+ if (ch === "\\") {
8177
+ i += 2;
8178
+ continue;
8179
+ }
8180
+ if (ch === '"') return i;
8181
+ i += 1;
8182
+ }
8183
+ return -1;
8184
+ }
8185
+ function findClosingBacktick(command, start) {
8186
+ let i = start + 1;
8187
+ while (i < command.length) {
8188
+ const ch = command[i];
8189
+ if (ch === "\\") {
8190
+ i += 2;
8191
+ continue;
8192
+ }
8193
+ if (ch === "`") return i;
8194
+ i += 1;
8195
+ }
8196
+ return -1;
8197
+ }
8198
+ function scanBacktickSubstitution(command, start) {
8199
+ const end = findClosingBacktick(command, start);
8200
+ if (end === -1) return { error: 'unbalanced backtick \u2014 no closing "`"' };
8201
+ return { inner: command.slice(start + 1, end), next: end + 1 };
8202
+ }
8203
+ function scanDollarSubstitution(command, start) {
8204
+ const n = command.length;
8205
+ let i = start + 2;
8206
+ let depth = 1;
8207
+ let inner = "";
8208
+ while (i < n) {
8209
+ const ch = command[i];
8210
+ if (ch === "\\") {
8211
+ inner += command.slice(i, i + 2);
8212
+ i += 2;
8213
+ continue;
8214
+ }
8215
+ if (ch === "'") {
8216
+ const end = command.indexOf("'", i + 1);
8217
+ if (end === -1) return { error: "unterminated single quote inside command substitution" };
8218
+ inner += command.slice(i, end + 1);
8219
+ i = end + 1;
8220
+ continue;
8221
+ }
8222
+ if (ch === '"') {
8223
+ const end = findClosingDoubleQuote(command, i);
8224
+ if (end === -1) return { error: "unterminated double quote inside command substitution" };
8225
+ inner += command.slice(i, end + 1);
8226
+ i = end + 1;
8227
+ continue;
8228
+ }
8229
+ if (ch === "`") {
8230
+ const end = findClosingBacktick(command, i);
8231
+ if (end === -1) return { error: "unterminated backtick inside command substitution" };
8232
+ inner += command.slice(i, end + 1);
8233
+ i = end + 1;
8234
+ continue;
8235
+ }
8236
+ if (ch === "(") {
8237
+ depth += 1;
8238
+ inner += ch;
8239
+ i += 1;
8240
+ continue;
8241
+ }
8242
+ if (ch === ")") {
8243
+ depth -= 1;
8244
+ if (depth === 0) return { inner, next: i + 1 };
8245
+ inner += ch;
8246
+ i += 1;
8247
+ continue;
8248
+ }
8249
+ inner += ch;
8250
+ i += 1;
8251
+ }
8252
+ return { error: 'unbalanced "$(" \u2014 no closing ")"' };
8253
+ }
8254
+ function scanSubstitutionAt(command, start) {
8255
+ const dollarForm = command[start] === "$" && command[start + 1] === "(";
8256
+ const backtickForm = command[start] === "`";
8257
+ if (!dollarForm && !backtickForm) return void 0;
8258
+ const scanned = dollarForm ? scanDollarSubstitution(command, start) : scanBacktickSubstitution(command, start);
8259
+ if ("error" in scanned) return scanned;
8260
+ return {
8261
+ substitution: { inner: scanned.inner, raw: command.slice(start, scanned.next) },
8262
+ next: scanned.next
8263
+ };
8264
+ }
8265
+ function scanWordLite(command, start) {
8266
+ const n = command.length;
8267
+ let i = start;
8268
+ let text = "";
8269
+ const substitutions = [];
8270
+ const takeSubstitution = () => {
8271
+ const scanned = scanSubstitutionAt(command, i);
8272
+ if (!scanned) return { ok: true, consumed: false };
8273
+ if ("error" in scanned) return { ok: false, error: scanned.error };
8274
+ substitutions.push(scanned.substitution);
8275
+ text += CMDSUBST_PLACEHOLDER;
8276
+ i = scanned.next;
8277
+ return { ok: true, consumed: true };
8278
+ };
8279
+ while (i < n) {
8280
+ const ch = command[i];
8281
+ if (ch === "\\") {
8282
+ if (i + 1 < n) {
8283
+ text += command[i + 1];
8284
+ i += 2;
8285
+ } else {
8286
+ text += ch;
8287
+ i += 1;
8288
+ }
8289
+ continue;
8290
+ }
8291
+ if (ch === "'") {
8292
+ const end = command.indexOf("'", i + 1);
8293
+ if (end === -1) return { error: "unterminated single quote" };
8294
+ text += command.slice(i + 1, end);
8295
+ i = end + 1;
8296
+ continue;
8297
+ }
8298
+ if (ch === '"') {
8299
+ i += 1;
8300
+ let closed = false;
8301
+ while (i < n) {
8302
+ const inner = command[i];
8303
+ if (inner === "\\") {
8304
+ if (i + 1 < n) {
8305
+ text += command[i + 1];
8306
+ i += 2;
8307
+ } else {
8308
+ text += inner;
8309
+ i += 1;
8310
+ }
8311
+ continue;
8312
+ }
8313
+ if (inner === '"') {
8314
+ i += 1;
8315
+ closed = true;
8316
+ break;
8317
+ }
8318
+ const taken2 = takeSubstitution();
8319
+ if (!taken2.ok) return { error: taken2.error };
8320
+ if (taken2.consumed) continue;
8321
+ text += inner;
8322
+ i += 1;
8323
+ }
8324
+ if (!closed) return { error: "unterminated double quote" };
8325
+ continue;
8326
+ }
8327
+ const taken = takeSubstitution();
8328
+ if (!taken.ok) return { error: taken.error };
8329
+ if (taken.consumed) continue;
8330
+ if (isWordTerminator(ch)) break;
8331
+ text += ch;
8332
+ i += 1;
8333
+ }
8334
+ return { word: { text, substitutions }, next: i };
8335
+ }
8336
+ function scanFdDupTail(command, start) {
8337
+ const match = /^&(\d+|-)/.exec(command.slice(start, start + 8));
8338
+ if (!match) return { error: "unrecognised file-descriptor redirection" };
8339
+ return { tail: match[0], next: start + match[0].length };
8340
+ }
8341
+ function scanRedirectTarget(command, start) {
8342
+ let i = start;
8343
+ while (i < command.length && (command[i] === " " || command[i] === " ")) i += 1;
8344
+ const ch = command[i];
8345
+ if (ch === void 0 || isWordTerminator(ch)) return { error: "redirection without a target" };
8346
+ const scanned = scanWordLite(command, i);
8347
+ if ("error" in scanned) return scanned;
8348
+ if (scanned.word.text.length === 0 && scanned.word.substitutions.length === 0) {
8349
+ return { error: "redirection without a target" };
8350
+ }
8351
+ return scanned;
8352
+ }
8353
+ function scanRedirect(command, start, fd) {
8354
+ const ch = command[start];
8355
+ if (ch === "&") {
8356
+ if (command[start + 1] !== ">") return { error: 'unexpected "&" in a redirection' };
8357
+ const append2 = command[start + 2] === ">";
8358
+ const target2 = scanRedirectTarget(command, start + (append2 ? 3 : 2));
8359
+ if ("error" in target2) return target2;
8360
+ return {
8361
+ redirect: { operator: append2 ? "&>>" : "&>", kind: "output", fd: 2, target: target2.word },
8362
+ next: target2.next
8363
+ };
8364
+ }
8365
+ if (ch === "<") {
8366
+ if (command[start + 1] === "<") {
8367
+ if (command[start + 2] === "<") {
8368
+ const target4 = scanRedirectTarget(command, start + 3);
8369
+ if ("error" in target4) return target4;
8370
+ return {
8371
+ redirect: { operator: "<<<", kind: "here-string", target: target4.word },
8372
+ next: target4.next
8373
+ };
8374
+ }
8375
+ const stripTabs = command[start + 2] === "-";
8376
+ const target3 = scanRedirectTarget(command, start + (stripTabs ? 3 : 2));
8377
+ if ("error" in target3) return target3;
8378
+ return {
8379
+ redirect: { operator: stripTabs ? "<<-" : "<<", kind: "here-doc", target: target3.word },
8380
+ next: target3.next,
8381
+ heredoc: { delimiter: target3.word.text, stripTabs }
8382
+ };
8383
+ }
8384
+ if (command[start + 1] === "&") {
8385
+ const dup = scanFdDupTail(command, start + 1);
8386
+ if ("error" in dup) return dup;
8387
+ return { redirect: { operator: `${fd ?? ""}<${dup.tail}`, kind: "fd-dup" }, next: dup.next };
8388
+ }
8389
+ if (command[start + 1] === ">") {
8390
+ const target3 = scanRedirectTarget(command, start + 2);
8391
+ if ("error" in target3) return target3;
8392
+ return {
8393
+ redirect: { operator: `${fd ?? ""}<>`, kind: "output", fd: fd ?? 0, target: target3.word },
8394
+ next: target3.next
8395
+ };
8396
+ }
8397
+ const target2 = scanRedirectTarget(command, start + 1);
8398
+ if ("error" in target2) return target2;
8399
+ return {
8400
+ redirect: { operator: `${fd ?? ""}<`, kind: "input", target: target2.word },
8401
+ next: target2.next
8402
+ };
8403
+ }
8404
+ const append = command[start + 1] === ">";
8405
+ let cursor = start + (append ? 2 : 1);
8406
+ const clobber = !append && command[cursor] === "|";
8407
+ if (clobber) cursor += 1;
8408
+ const operator = `${fd ?? ""}${append ? ">>" : ">"}${clobber ? "|" : ""}`;
8409
+ if (command[cursor] === "&") {
8410
+ const dup = scanFdDupTail(command, cursor);
8411
+ if ("error" in dup) return dup;
8412
+ return { redirect: { operator: `${operator}${dup.tail}`, kind: "fd-dup" }, next: dup.next };
8413
+ }
8414
+ const target = scanRedirectTarget(command, cursor);
8415
+ if ("error" in target) return target;
8416
+ return {
8417
+ redirect: { operator, kind: "output", fd: fd ?? 1, target: target.word },
8418
+ next: target.next
8419
+ };
8420
+ }
8421
+ function consumeHeredocBodies(command, start, heredocs) {
8422
+ let pos = start;
8423
+ for (const heredoc of heredocs) {
8424
+ let matched = false;
8425
+ while (pos < command.length) {
8426
+ const newline = command.indexOf("\n", pos);
8427
+ const lineEnd2 = newline === -1 ? command.length : newline;
8428
+ const raw = command.slice(pos, lineEnd2);
8429
+ const line = heredoc.stripTabs ? raw.replace(/^\t+/, "") : raw;
8430
+ pos = lineEnd2 + 1;
8431
+ if (line === heredoc.delimiter) {
8432
+ matched = true;
8433
+ break;
8434
+ }
8435
+ if (newline === -1) break;
8436
+ }
8437
+ if (!matched) return void 0;
8438
+ }
8439
+ return pos;
8440
+ }
8441
+ function tokenizeShellLite(command) {
8442
+ const n = command.length;
8443
+ const segments = [];
8444
+ let tokens = [];
8445
+ let pendingHeredocs = [];
8446
+ let i = 0;
8447
+ const endSegment = () => {
8448
+ if (tokens.length > 0) segments.push({ tokens });
8449
+ tokens = [];
8450
+ };
8451
+ const takeRedirect = (redirectStart, fd) => {
8452
+ const scanned = scanRedirect(command, redirectStart, fd);
8453
+ if ("error" in scanned) return { ok: false, error: scanned.error };
8454
+ tokens.push({ kind: "redirect", redirect: scanned.redirect });
8455
+ if (scanned.heredoc) pendingHeredocs.push(scanned.heredoc);
8456
+ i = scanned.next;
8457
+ return void 0;
8458
+ };
8459
+ while (i < n) {
8460
+ const ch = command[i];
8461
+ if (ch === " " || ch === " " || ch === "\r") {
8462
+ i += 1;
8463
+ continue;
8464
+ }
8465
+ if (ch === "\\" && command[i + 1] === "\n") {
8466
+ i += 2;
8467
+ continue;
8468
+ }
8469
+ if (ch === "\n") {
8470
+ endSegment();
8471
+ i += 1;
8472
+ if (pendingHeredocs.length > 0) {
8473
+ const after = consumeHeredocBodies(command, i, pendingHeredocs);
8474
+ if (after === void 0) return { ok: false, error: "here-document without its terminator line" };
8475
+ i = after;
8476
+ pendingHeredocs = [];
8477
+ }
8478
+ continue;
8479
+ }
8480
+ if (ch === "#") {
8481
+ while (i < n && command[i] !== "\n") i += 1;
8482
+ continue;
8483
+ }
8484
+ if (ch === "|" || ch === ";" || ch === "&") {
8485
+ if (ch === "&" && command[i + 1] === ">") {
8486
+ const failed = takeRedirect(i);
8487
+ if (failed) return failed;
8488
+ continue;
8489
+ }
8490
+ i += 1;
8491
+ while (i < n && (command[i] === "|" || command[i] === ";" || command[i] === "&")) i += 1;
8492
+ endSegment();
8493
+ continue;
8494
+ }
8495
+ const fdMatch = /^(\d+)(?=>>|>|<>|<)/.exec(command.slice(i, i + 24));
8496
+ const fd = fdMatch ? Number(fdMatch[1]) : void 0;
8497
+ const redirectStart = i + (fdMatch ? fdMatch[1].length : 0);
8498
+ const redirectChar = command[redirectStart];
8499
+ if (redirectChar === "<" || redirectChar === ">") {
8500
+ const failed = takeRedirect(redirectStart, fd);
8501
+ if (failed) return failed;
8502
+ continue;
8503
+ }
8504
+ const scanned = scanWordLite(command, i);
8505
+ if ("error" in scanned) return { ok: false, error: scanned.error };
8506
+ if (scanned.next === i) return { ok: false, error: `unexpected character "${ch}"` };
8507
+ tokens.push({ kind: "word", word: scanned.word });
8508
+ i = scanned.next;
8509
+ }
8510
+ if (pendingHeredocs.length > 0) {
8511
+ return { ok: false, error: "here-document without a body" };
8512
+ }
8513
+ endSegment();
8514
+ return { ok: true, segments };
8515
+ }
8516
+ function classifyRedirections(segments) {
8517
+ const result2 = {
8518
+ writeTargets: [],
8519
+ readTargets: [],
8520
+ duplicates: [],
8521
+ discarded: []
8522
+ };
8523
+ for (const segment of segments) {
8524
+ for (const token of segment.tokens) {
8525
+ if (token.kind !== "redirect") continue;
8526
+ const { redirect } = token;
8527
+ const target = redirect.target?.text;
8528
+ if (redirect.kind === "fd-dup") {
8529
+ result2.duplicates.push(redirect.operator);
8530
+ continue;
8531
+ }
8532
+ if (redirect.kind === "here-string" || redirect.kind === "here-doc") continue;
8533
+ if (target === void 0) continue;
8534
+ if (isDiscardRedirectTarget(target)) {
8535
+ result2.discarded.push(target);
8536
+ continue;
8537
+ }
8538
+ if (redirect.kind === "input") {
8539
+ if (target.includes(CMDSUBST_PLACEHOLDER)) {
8540
+ result2.unresolved ??= `${redirect.operator} ${target}`;
8541
+ continue;
8542
+ }
8543
+ result2.readTargets.push(target);
8544
+ continue;
8545
+ }
8546
+ if (target.includes(CMDSUBST_PLACEHOLDER)) {
8547
+ result2.unresolved ??= `${redirect.operator} ${target}`;
8548
+ continue;
8549
+ }
8550
+ result2.writeTargets.push(target);
8551
+ }
8552
+ }
8553
+ return result2;
8554
+ }
8555
+
7720
8556
  // src/services/tools/permission-rules.ts
7721
8557
  var FILE_TOOL_NAMES = ["read_file", "write_file", "edit_file", "search_files", "search_content"];
7722
8558
  var READ_ONLY_FILE_TOOLS = /* @__PURE__ */ new Set(["read_file", "search_files", "search_content"]);
7723
8559
  var DANGEROUS_BASH_PATTERN = /(?:^|\||;|&&)\s*(?:rm\s|sudo\b|chmod\b|chown\b|mkfs\b|dd\s|shutdown\b|reboot\b|kill\s+-9)/;
7724
- var URL_RE = /[a-zA-Z][a-zA-Z0-9+.-]*:\/\/\S+/g;
7725
8560
  function expandHome(p, homeDir2) {
7726
8561
  if (p === "~") return { path: homeDir2, unresolvable: false };
7727
8562
  if (p.startsWith("~/")) return { path: resolve5(homeDir2, p.slice(2)), unresolvable: false };
@@ -7731,37 +8566,139 @@ function expandHome(p, homeDir2) {
7731
8566
  function expandHomeVars(command, homeDir2) {
7732
8567
  return command.replace(/\$\{HOME\}/g, homeDir2).replace(/\$HOME(?![A-Za-z0-9_])/g, homeDir2);
7733
8568
  }
7734
- function detectUnsafeDynamicTarget(command) {
7735
- if (command.includes("`")) return "command substitution (backticks)";
7736
- if (/\$\(/.test(command)) return "command substitution ($(...))";
7737
- if (hasOutputRedirection(command)) return "output redirection";
7738
- return void 0;
8569
+ var CMDSUBST_READONLY_VERBS = /* @__PURE__ */ new Set([
8570
+ "cat",
8571
+ "ls",
8572
+ "grep",
8573
+ "head",
8574
+ "tail",
8575
+ "wc",
8576
+ "stat",
8577
+ "file",
8578
+ "which",
8579
+ "command",
8580
+ "readlink",
8581
+ "echo",
8582
+ "printf",
8583
+ "date",
8584
+ "pwd"
8585
+ ]);
8586
+ var MAX_CMDSUBST_DEPTH = 5;
8587
+ var BASH_WRITE_VERBS = /* @__PURE__ */ new Set([
8588
+ "rm",
8589
+ "rmdir",
8590
+ "mv",
8591
+ "cp",
8592
+ "ln",
8593
+ "tee",
8594
+ "dd",
8595
+ "truncate",
8596
+ "install",
8597
+ "mkdir",
8598
+ "touch",
8599
+ "chmod",
8600
+ "chown",
8601
+ "chgrp",
8602
+ "shred",
8603
+ "unlink",
8604
+ "chattr",
8605
+ "mkfs",
8606
+ "mkswap",
8607
+ "mount",
8608
+ "umount"
8609
+ ]);
8610
+ var BASH_FLAGGED_WRITE_VERBS = /* @__PURE__ */ new Map([
8611
+ ["sed", ["-i", "--in-place"]],
8612
+ ["perl", ["-i"]],
8613
+ ["ruby", ["-i"]],
8614
+ ["find", ["-delete", "-fprint", "-fls", "-fprintf", "-exec", "-execdir", "-ok"]]
8615
+ ]);
8616
+ function isWriteSegment(segment) {
8617
+ const words = [];
8618
+ for (const token of segment.tokens) {
8619
+ if (token.kind === "word") {
8620
+ words.push(token.word.text);
8621
+ continue;
8622
+ }
8623
+ const { redirect } = token;
8624
+ if (redirect.kind !== "output") continue;
8625
+ if (isDiscardRedirectTarget(redirect.target?.text)) continue;
8626
+ return true;
8627
+ }
8628
+ for (const word of words) {
8629
+ if (BASH_WRITE_VERBS.has(word)) return true;
8630
+ const flags2 = BASH_FLAGGED_WRITE_VERBS.get(word);
8631
+ if (flags2 && flags2.some((flag) => words.includes(flag))) return true;
8632
+ }
8633
+ return false;
7739
8634
  }
7740
- function hasOutputRedirection(command) {
7741
- return /(?:^|[^<>])>>?\s*(?!&)\S/.test(command);
8635
+ function pathPieces(text, access) {
8636
+ const candidates = [];
8637
+ for (const piece of text.split("=")) {
8638
+ if (!piece.startsWith("/") && !piece.startsWith("~")) continue;
8639
+ if (isDiscardRedirectTarget(piece)) continue;
8640
+ candidates.push({ path: piece, access });
8641
+ }
8642
+ return candidates;
7742
8643
  }
7743
- function extractPathCandidates(command) {
7744
- const withoutUrls = command.replace(URL_RE, " ");
8644
+ function extractPathCandidates(segments) {
7745
8645
  const candidates = [];
7746
- for (const raw of withoutUrls.split(/[\s=;|&]+/)) {
7747
- const word = raw.replace(/^['"]+/, "").replace(/['",;|&()<>'`]+$/, "");
7748
- if (word.startsWith("/") || word.startsWith("~")) candidates.push(word);
8646
+ for (const segment of segments) {
8647
+ const segmentAccess = isWriteSegment(segment) ? "write" : "unknown";
8648
+ for (const token of segment.tokens) {
8649
+ if (token.kind === "word") {
8650
+ candidates.push(...pathPieces(token.word.text, segmentAccess));
8651
+ continue;
8652
+ }
8653
+ const { redirect } = token;
8654
+ if (redirect.kind === "fd-dup" || redirect.kind === "here-doc" || redirect.kind === "here-string") continue;
8655
+ const target = redirect.target?.text;
8656
+ if (target === void 0 || isDiscardRedirectTarget(target)) continue;
8657
+ if (target.includes(CMDSUBST_PLACEHOLDER)) continue;
8658
+ const access = redirect.kind === "output" ? "write" : segmentAccess;
8659
+ for (const candidate of pathPieces(target, access)) {
8660
+ candidates.push(
8661
+ redirect.kind === "output" ? { ...candidate, reason: `output redirection to "${target}"` } : candidate
8662
+ );
8663
+ }
8664
+ }
7749
8665
  }
7750
8666
  return candidates;
7751
8667
  }
7752
- function decidePath(scope, path9, access) {
8668
+ function commandAccess(candidates) {
8669
+ return candidates.some((candidate) => candidate.access === "write") ? "write" : "unknown";
8670
+ }
8671
+ function decidePath(scope, path10, access) {
8672
+ const permission = scope.permission;
7753
8673
  return decideAccess({
7754
- path: path9,
8674
+ path: path10,
7755
8675
  access,
7756
8676
  cwd: scope.workspace,
7757
8677
  homeDir: scope.homeDir,
7758
8678
  taskWorkspace: scope.workspace,
7759
8679
  taskAllowedDirs: scope.taskAllowedDirs,
7760
- configAllowedDirs: scope.permission?.allowedDirs,
7761
- autoAllowRoots: scope.permission?.autoAllowRoots,
7762
- extraDeniedDirs: scope.permission?.deniedDirs
8680
+ configAllowedDirs: permission?.allowedDirs,
8681
+ autoAllowRoots: permission?.autoAllowRoots,
8682
+ // `sensitiveDirs` is the current key; `deniedDirs` is its deprecated alias
8683
+ // and is merged (deduped) here so a legacy config keeps protecting exactly
8684
+ // the directories it declares.
8685
+ extraSensitiveDirs: mergeSensitiveDirs(permission),
8686
+ sensitiveMode: resolveSensitiveMode(permission)
7763
8687
  });
7764
8688
  }
8689
+ function toToolDecision(verdict, access) {
8690
+ return {
8691
+ decision: verdict.decision,
8692
+ matchedRule: verdict.matchedRule,
8693
+ resolvedPath: verdict.resolvedPath,
8694
+ access,
8695
+ ...verdict.matchedEntry !== void 0 && {
8696
+ matchedEntry: verdict.matchedEntry,
8697
+ matchedSource: verdict.matchedSource,
8698
+ sensitive: true
8699
+ }
8700
+ };
8701
+ }
7765
8702
  function evaluateFileAccess(scope, toolName, args) {
7766
8703
  const rawPath = args.path;
7767
8704
  if (typeof rawPath !== "string" || !rawPath) return { decision: "allow", matchedRule: "no-path" };
@@ -7776,35 +8713,163 @@ function evaluateFileAccess(scope, toolName, args) {
7776
8713
  }
7777
8714
  const verdict = decidePath(scope, expanded.path, access);
7778
8715
  if (verdict.decision === "allow" && verdict.withinReadScope) {
7779
- return { decision: "ask", matchedRule: "read-within-home" };
8716
+ return { decision: "ask", matchedRule: "read-within-home", resolvedPath: verdict.resolvedPath, access };
8717
+ }
8718
+ return toToolDecision(verdict, access);
8719
+ }
8720
+ function collectSubstitutions(segments) {
8721
+ const substitutions = [];
8722
+ for (const segment of segments) {
8723
+ for (const token of segment.tokens) {
8724
+ const word = token.kind === "word" ? token.word : token.redirect.target;
8725
+ if (word) substitutions.push(...word.substitutions);
8726
+ }
8727
+ }
8728
+ return substitutions;
8729
+ }
8730
+ function checkSubstitutionInner(inner) {
8731
+ const tokenized = tokenizeShellLite(inner);
8732
+ if (!tokenized.ok) return { ok: false, reason: `cannot parse it (${tokenized.error})` };
8733
+ if (tokenized.segments.length === 0) return { ok: false, reason: "it is empty" };
8734
+ for (const segment of tokenized.segments) {
8735
+ const verb = segment.tokens.find((token) => token.kind === "word");
8736
+ if (!verb || verb.kind !== "word" || verb.word.text === CMDSUBST_PLACEHOLDER) {
8737
+ return { ok: false, reason: "it has no command word to judge" };
8738
+ }
8739
+ if (!CMDSUBST_READONLY_VERBS.has(verb.word.text)) {
8740
+ return { ok: false, reason: `\`${verb.word.text}\` is not a read-only command` };
8741
+ }
8742
+ if (isWriteSegment(segment)) return { ok: false, reason: `\`${verb.word.text}\` writes` };
8743
+ }
8744
+ return { ok: true };
8745
+ }
8746
+ function evaluateSubstitutions(scope, segments, depth) {
8747
+ const substitutions = collectSubstitutions(segments);
8748
+ if (substitutions.length === 0) return void 0;
8749
+ if (depth + 1 > MAX_CMDSUBST_DEPTH) {
8750
+ return {
8751
+ decision: "ask",
8752
+ matchedRule: "dynamic-shell",
8753
+ reason: `command substitution nested deeper than ${MAX_CMDSUBST_DEPTH} levels`
8754
+ };
8755
+ }
8756
+ for (const substitution of substitutions) {
8757
+ const inner = substitution.inner.trim();
8758
+ if (inner.length === 0) {
8759
+ return { decision: "ask", matchedRule: "dynamic-shell", reason: `empty command substitution ${substitution.raw}` };
8760
+ }
8761
+ const readOnly = checkSubstitutionInner(inner);
8762
+ if (!readOnly.ok) {
8763
+ return {
8764
+ decision: "ask",
8765
+ matchedRule: "dynamic-shell",
8766
+ reason: `command substitution ${substitution.raw}: ${readOnly.reason}`
8767
+ };
8768
+ }
8769
+ const verdict = evaluateBashCommand(scope, inner, depth + 1);
8770
+ if (verdict.decision === "deny") return verdict;
8771
+ if (verdict.decision === "ask") {
8772
+ const detail = verdict.reason ?? (verdict.resolvedPath ? `target ${verdict.resolvedPath}` : verdict.matchedRule);
8773
+ return {
8774
+ decision: "ask",
8775
+ matchedRule: "dynamic-shell",
8776
+ reason: `command substitution ${substitution.raw} needs approval: ${detail}`,
8777
+ // Carry the inner hit through, so an approval request still names the
8778
+ // path (and the sensitive entry) the substituted command would touch.
8779
+ ...verdict.resolvedPath !== void 0 && { resolvedPath: verdict.resolvedPath },
8780
+ ...verdict.matchedEntry !== void 0 && {
8781
+ matchedEntry: verdict.matchedEntry,
8782
+ matchedSource: verdict.matchedSource,
8783
+ sensitive: true
8784
+ }
8785
+ };
8786
+ }
7780
8787
  }
7781
- return { decision: verdict.decision, matchedRule: verdict.matchedRule };
8788
+ return void 0;
8789
+ }
8790
+ function fallbackSensitiveWrite(scope, expandedCommand) {
8791
+ for (const raw of expandedCommand.split(/[\s=]+/)) {
8792
+ const word = raw.replace(/^['"]+/, "").replace(/['"]+$/, "");
8793
+ if (!word.startsWith("/") && !word.startsWith("~")) continue;
8794
+ const expanded = expandHome(word, scope.homeDir);
8795
+ if (expanded.unresolvable) continue;
8796
+ const verdict = decidePath(scope, expanded.path, "write");
8797
+ if (verdict.decision === "deny") return toToolDecision(verdict, "write");
8798
+ }
8799
+ return void 0;
7782
8800
  }
7783
8801
  function evaluateBashAccess(scope, command) {
8802
+ return evaluateBashCommand(scope, command, 0);
8803
+ }
8804
+ function evaluateBashCommand(scope, command, depth) {
7784
8805
  const expandedCommand = expandHomeVars(command, scope.homeDir);
8806
+ const tokenized = tokenizeShellLite(expandedCommand);
8807
+ if (!tokenized.ok) {
8808
+ const refusal = fallbackSensitiveWrite(scope, expandedCommand);
8809
+ if (refusal) return refusal;
8810
+ return {
8811
+ decision: "ask",
8812
+ matchedRule: "dynamic-shell",
8813
+ reason: `could not parse the shell command (${tokenized.error})`
8814
+ };
8815
+ }
8816
+ const segments = tokenized.segments;
8817
+ const redirections = classifyRedirections(segments);
7785
8818
  const candidates = [];
7786
8819
  let hasUnresolvableHome = false;
7787
- for (const raw of extractPathCandidates(expandedCommand)) {
7788
- const expanded = expandHome(raw, scope.homeDir);
8820
+ for (const candidate of extractPathCandidates(segments)) {
8821
+ const expanded = expandHome(candidate.path, scope.homeDir);
7789
8822
  if (expanded.unresolvable) {
7790
8823
  hasUnresolvableHome = true;
7791
8824
  continue;
7792
8825
  }
7793
- candidates.push(expanded.path);
8826
+ candidates.push({ ...candidate, path: expanded.path });
7794
8827
  }
7795
8828
  let firstAsk;
7796
8829
  let firstAllow;
7797
8830
  for (const candidate of candidates) {
7798
- const verdict = decidePath(scope, candidate, "write");
7799
- if (verdict.decision === "deny") return verdict;
7800
- if (verdict.decision === "ask") firstAsk ??= verdict;
7801
- else firstAllow ??= verdict;
8831
+ const verdict = decidePath(scope, candidate.path, candidate.access);
8832
+ if (verdict.decision === "deny") return toToolDecision(verdict, candidate.access);
8833
+ if (verdict.decision === "ask") {
8834
+ firstAsk ??= {
8835
+ ...toToolDecision(verdict, candidate.access),
8836
+ ...candidate.reason !== void 0 && { reason: candidate.reason }
8837
+ };
8838
+ } else firstAllow ??= toToolDecision(verdict, candidate.access);
7802
8839
  }
8840
+ const lineAccess = commandAccess(candidates);
8841
+ if (firstAsk) firstAsk = { ...firstAsk, access: lineAccess };
8842
+ if (firstAllow) firstAllow = { ...firstAllow, access: lineAccess };
7803
8843
  if (DANGEROUS_BASH_PATTERN.test(expandedCommand)) {
7804
- return { decision: "ask", matchedRule: "dangerous-bash" };
8844
+ const hit = firstAsk?.matchedEntry !== void 0 ? firstAsk : void 0;
8845
+ return {
8846
+ decision: "ask",
8847
+ matchedRule: "dangerous-bash",
8848
+ access: lineAccess,
8849
+ ...hit !== void 0 && {
8850
+ matchedEntry: hit.matchedEntry,
8851
+ matchedSource: hit.matchedSource,
8852
+ resolvedPath: hit.resolvedPath,
8853
+ sensitive: true
8854
+ }
8855
+ };
8856
+ }
8857
+ const substitution = evaluateSubstitutions(scope, segments, depth);
8858
+ if (substitution) return substitution;
8859
+ if (redirections.unresolved !== void 0) {
8860
+ return {
8861
+ decision: "ask",
8862
+ matchedRule: "dynamic-shell",
8863
+ reason: `output redirection target cannot be determined statically (${redirections.unresolved})`
8864
+ };
8865
+ }
8866
+ if (redirections.writeTargets.length > 0 && !firstAsk) {
8867
+ return {
8868
+ decision: "ask",
8869
+ matchedRule: "dynamic-shell",
8870
+ reason: `output redirection writes a file ("${redirections.writeTargets.join('", "')}")`
8871
+ };
7805
8872
  }
7806
- const dynamic = detectUnsafeDynamicTarget(expandedCommand);
7807
- if (dynamic) return { decision: "ask", matchedRule: "dynamic-shell", reason: dynamic };
7808
8873
  if (hasUnresolvableHome) {
7809
8874
  return {
7810
8875
  decision: "ask",
@@ -7815,12 +8880,13 @@ function evaluateBashAccess(scope, command) {
7815
8880
  if (firstAsk) return firstAsk;
7816
8881
  return firstAllow ?? { decision: "allow", matchedRule: "no-path" };
7817
8882
  }
7818
- function denyMessage(toolName, requestedPath) {
8883
+ function denyMessage(toolName, requestedPath, matchedRule) {
8884
+ const write = matchedRule === "sensitive-list-write";
7819
8885
  if (toolName === "bash") {
7820
- return "[ERROR] bash: refusing to run this command \u2014 it touches a path on the deny list (a sensitive location).";
8886
+ return write ? "[ERROR] bash: refusing to run this command \u2014 it would write to a path on the sensitive list (sensitive paths are never writable)." : "[ERROR] bash: refusing to run this command \u2014 it touches a path on the sensitive list (a protected location).";
7821
8887
  }
7822
8888
  const target = requestedPath ? ` "${requestedPath}"` : "";
7823
- return `[ERROR] ${toolName}: refusing access to${target} \u2014 the path is on the deny list (a sensitive location).`;
8889
+ return write ? `[ERROR] ${toolName}: refusing to write${target} \u2014 the path is on the sensitive list (sensitive paths are never writable).` : `[ERROR] ${toolName}: refusing access to${target} \u2014 the path is on the sensitive list (a protected location).`;
7824
8890
  }
7825
8891
  function decideToolAccess(scope, toolName, args) {
7826
8892
  let verdict;
@@ -7832,7 +8898,51 @@ function decideToolAccess(scope, toolName, args) {
7832
8898
  return { decision: "allow", matchedRule: "no-path" };
7833
8899
  }
7834
8900
  if (verdict.decision !== "deny") return verdict;
7835
- return { ...verdict, message: denyMessage(toolName, typeof args.path === "string" ? args.path : void 0) };
8901
+ return {
8902
+ ...verdict,
8903
+ message: denyMessage(toolName, typeof args.path === "string" ? args.path : void 0, verdict.matchedRule)
8904
+ };
8905
+ }
8906
+
8907
+ // src/services/permission/permission-audit.ts
8908
+ function operationForTool(toolName) {
8909
+ if (toolName === "bash") return "exec";
8910
+ if (toolName === "read_file" || toolName === "search_files" || toolName === "search_content") return "read";
8911
+ return "write";
8912
+ }
8913
+ function auditAccessFor(access) {
8914
+ return access ?? "unknown";
8915
+ }
8916
+ function recordPermissionAudit(logger, entry) {
8917
+ if (!logger) return;
8918
+ logger.info(
8919
+ {
8920
+ audit: "permission",
8921
+ decision: entry.decision,
8922
+ operation: entry.operation,
8923
+ ...entry.access !== void 0 && { access: entry.access },
8924
+ ts: (/* @__PURE__ */ new Date()).toISOString(),
8925
+ ...entry.resolvedPath !== void 0 && { resolvedPath: entry.resolvedPath },
8926
+ ...entry.requestedPath !== void 0 && { requestedPath: entry.requestedPath },
8927
+ ...entry.matchedEntry !== void 0 && { matchedEntry: entry.matchedEntry },
8928
+ ...entry.matchedSource !== void 0 && { matchedSource: entry.matchedSource },
8929
+ ...entry.matchedRule !== void 0 && { matchedRule: entry.matchedRule },
8930
+ ...entry.taskId !== void 0 && { taskId: entry.taskId },
8931
+ ...entry.workerId !== void 0 && { workerId: entry.workerId },
8932
+ ...entry.toolName !== void 0 && { toolName: entry.toolName },
8933
+ ...entry.source !== void 0 && { source: entry.source }
8934
+ },
8935
+ "permission audit"
8936
+ );
8937
+ }
8938
+ function verdictDetailSuffix(detail) {
8939
+ const parts = [];
8940
+ if (detail.matchedEntry) {
8941
+ parts.push(`hit: ${detail.matchedEntry}${detail.matchedSource ? ` (${detail.matchedSource})` : ""}`);
8942
+ }
8943
+ if (detail.matchedRule) parts.push(`rule: ${detail.matchedRule}`);
8944
+ if (detail.operation) parts.push(`op: ${detail.operation}`);
8945
+ return parts.length > 0 ? ` [${parts.join(" ; ")}]` : "";
7836
8946
  }
7837
8947
 
7838
8948
  // src/infra/blocking-requests.ts
@@ -7882,7 +8992,7 @@ var BlockingRequestMap = class {
7882
8992
 
7883
8993
  // src/services/agents/claude-code-worker.ts
7884
8994
  import { basename as basename7, dirname as dirname8 } from "path";
7885
- import { homedir } from "os";
8995
+ import { homedir as homedir2 } from "os";
7886
8996
  var INQUIRY_STREAM_CLOSE_TIMEOUT_MS = 36e4;
7887
8997
  var ClaudeCodeWorker = class _ClaudeCodeWorker {
7888
8998
  id;
@@ -8146,11 +9256,11 @@ var ClaudeCodeWorker = class _ClaudeCodeWorker {
8146
9256
  return this.state.get("conversation") ?? [];
8147
9257
  }
8148
9258
  // --- Subscribable ---
8149
- subscribe(path9, callback) {
8150
- return this.state.on(path9, callback);
9259
+ subscribe(path10, callback) {
9260
+ return this.state.on(path10, callback);
8151
9261
  }
8152
- getSnapshot(path9) {
8153
- return this.state.get(path9);
9262
+ getSnapshot(path10) {
9263
+ return this.state.get(path10);
8154
9264
  }
8155
9265
  /**
8156
9266
  * Accept a message from the controller. The message is queued and handed
@@ -8238,15 +9348,16 @@ var ClaudeCodeWorker = class _ClaudeCodeWorker {
8238
9348
  effectiveAccessScope() {
8239
9349
  if (this._accessScope) return this._accessScope;
8240
9350
  const wayangDir = wayangDirForSessionDir(this.sessionDir);
8241
- const homeDir2 = wayangDir && basename7(wayangDir) === ".wayang" ? dirname8(wayangDir) : homedir();
9351
+ const homeDir2 = wayangDir && basename7(wayangDir) === ".wayang" ? dirname8(wayangDir) : homedir2();
8242
9352
  return { homeDir: homeDir2, workspace: this.workspaceDir };
8243
9353
  }
8244
9354
  /**
8245
9355
  * Route a Claude Code tool call through the SAME access policy a puppet
8246
- * worker uses (`decideToolAccess`). A denylisted path is denied outright
8247
- * (no approval prompt); an ambiguous target asks the controller; a path
8248
- * inside the allowed scope runs immediately. This closes the parity gap where
8249
- * the old regex-only gate let denylisted paths through silently.
9356
+ * worker uses (`decideToolAccess`). A sensitive WRITE is denied outright
9357
+ * (no approval prompt), a sensitive READ asks with the matched entry named,
9358
+ * an ambiguous target asks the controller and a path inside the allowed scope
9359
+ * runs immediately. This closes the parity gap where the old regex-only gate
9360
+ * let sensitive paths through silently.
8250
9361
  */
8251
9362
  async handleCanUseTool(toolName, input2) {
8252
9363
  if (!this._signalQueue || !this._runContext) {
@@ -8255,42 +9366,91 @@ var ClaudeCodeWorker = class _ClaudeCodeWorker {
8255
9366
  const scope = this.effectiveAccessScope();
8256
9367
  if (toolName === "Bash") {
8257
9368
  const verdict = decideToolAccess(scope, "bash", { command: String(input2.command ?? "") });
8258
- if (verdict.decision === "deny") {
8259
- return { behavior: "deny", message: verdict.message ?? "Denied by the permission policy" };
8260
- }
8261
- if (verdict.decision === "ask") return this.requestPermission(toolName, input2, verdict.reason);
9369
+ const operation = operationForTool("bash");
9370
+ if (verdict.decision === "deny") return this.denyWithAudit(toolName, input2, verdict, operation);
9371
+ if (verdict.decision === "ask") return this.requestPermission(toolName, input2, this.askOf(verdict, operation));
8262
9372
  return { behavior: "allow" };
8263
9373
  }
8264
9374
  const fileTool = _ClaudeCodeWorker.SDK_FILE_TOOL_MAP[toolName];
8265
9375
  if (fileTool) {
9376
+ const operation = operationForTool(fileTool.policyTool);
8266
9377
  const rawPath = input2[fileTool.pathKey];
8267
9378
  if (typeof rawPath !== "string" || rawPath.length === 0) {
8268
9379
  if (fileTool.pathRequired) {
8269
- return this.requestPermission(toolName, input2, "the target path could not be determined");
9380
+ return this.requestPermission(toolName, input2, {
9381
+ reason: "the target path could not be determined",
9382
+ operation
9383
+ });
8270
9384
  }
8271
9385
  return { behavior: "allow" };
8272
9386
  }
8273
9387
  const verdict = decideToolAccess(scope, fileTool.policyTool, { path: rawPath });
8274
- if (verdict.decision === "deny") {
8275
- return { behavior: "deny", message: verdict.message ?? "Denied by the permission policy" };
8276
- }
8277
- if (verdict.decision === "ask") return this.requestPermission(toolName, input2, verdict.reason);
9388
+ if (verdict.decision === "deny") return this.denyWithAudit(toolName, input2, verdict, operation);
9389
+ if (verdict.decision === "ask") return this.requestPermission(toolName, input2, this.askOf(verdict, operation));
8278
9390
  return { behavior: "allow" };
8279
9391
  }
8280
9392
  if (_ClaudeCodeWorker.SDK_NON_PATH_TOOLS.has(toolName)) {
8281
9393
  return { behavior: "allow" };
8282
9394
  }
8283
- return this.requestPermission(toolName, input2, "the tool is not recognized by the permission policy");
9395
+ return this.requestPermission(toolName, input2, {
9396
+ reason: "the tool is not recognized by the permission policy",
9397
+ operation: operationForTool("bash")
9398
+ });
9399
+ }
9400
+ /**
9401
+ * Refuse a call the policy hard-denied (sensitive write) and record it.
9402
+ *
9403
+ * The worker receives the policy's disclosure-safe message (its own path
9404
+ * spelling, never the matched entry); the audit trail gets the metadata.
9405
+ * Same shape as the puppet worker's audit, so both entries agree.
9406
+ */
9407
+ denyWithAudit(toolName, input2, verdict, operation) {
9408
+ recordPermissionAudit(this.logger, {
9409
+ decision: "hard-denied",
9410
+ operation,
9411
+ access: auditAccessFor(verdict.access),
9412
+ resolvedPath: verdict.resolvedPath,
9413
+ requestedPath: this.requestedPathOf(input2),
9414
+ matchedEntry: verdict.matchedEntry,
9415
+ matchedSource: verdict.matchedSource,
9416
+ matchedRule: verdict.matchedRule,
9417
+ taskId: this._runContext?.taskId,
9418
+ workerId: this._runContext?.workerId,
9419
+ toolName
9420
+ });
9421
+ return { behavior: "deny", message: verdict.message ?? "Denied by the permission policy" };
9422
+ }
9423
+ /** Project a policy verdict onto the approval-request/audit fields. */
9424
+ askOf(verdict, operation) {
9425
+ return {
9426
+ reason: verdict.reason,
9427
+ matchedRule: verdict.matchedRule,
9428
+ matchedEntry: verdict.matchedEntry,
9429
+ matchedSource: verdict.matchedSource,
9430
+ resolvedPath: verdict.resolvedPath,
9431
+ operation,
9432
+ sensitive: verdict.sensitive,
9433
+ access: verdict.access
9434
+ };
9435
+ }
9436
+ /** The target as the worker spelled it (for auditing against the prompt). */
9437
+ requestedPathOf(input2) {
9438
+ for (const key of ["file_path", "notebook_path", "path"]) {
9439
+ const value = input2[key];
9440
+ if (typeof value === "string" && value.length > 0) return value;
9441
+ }
9442
+ return void 0;
8284
9443
  }
8285
9444
  /**
8286
9445
  * Emit a permission_request and block until the controller answers (or it
8287
9446
  * times out → deny). Shared by every `ask` verdict so the request flow stays
8288
9447
  * identical to the puppet worker's.
8289
9448
  */
8290
- async requestPermission(toolName, input2, reason) {
9449
+ async requestPermission(toolName, input2, ask) {
8291
9450
  if (!this._signalQueue || !this._runContext) return { behavior: "allow" };
8292
9451
  const requestId = generateId("perm");
8293
- const description = this.describePermissionRequest(toolName, input2, reason);
9452
+ const description = this.describePermissionRequest(toolName, input2, ask);
9453
+ const operation = ask.operation;
8294
9454
  this._signalQueue.enqueue({
8295
9455
  source: "worker",
8296
9456
  type: "permission_request",
@@ -8303,16 +9463,43 @@ var ClaudeCodeWorker = class _ClaudeCodeWorker {
8303
9463
  emoji: this._runContext.emoji,
8304
9464
  toolName,
8305
9465
  toolArgs: input2,
8306
- description
9466
+ description,
9467
+ ...ask.matchedRule !== void 0 && { matchedRule: ask.matchedRule },
9468
+ ...ask.matchedEntry !== void 0 && { matchedEntry: ask.matchedEntry },
9469
+ ...ask.matchedSource !== void 0 && { matchedSource: ask.matchedSource },
9470
+ ...ask.resolvedPath !== void 0 && { resolvedPath: ask.resolvedPath },
9471
+ ...ask.sensitive !== void 0 && { sensitive: ask.sensitive },
9472
+ ...ask.access !== void 0 && { access: ask.access },
9473
+ operation
8307
9474
  },
8308
9475
  ...this._turnLineage
8309
9476
  });
8310
- return this._pendingPermissions.wait(requestId, _ClaudeCodeWorker.PERMISSION_TIMEOUT_MS, () => {
8311
- return { behavior: "deny", message: "Permission request timed out" };
9477
+ const result2 = await this._pendingPermissions.wait(
9478
+ requestId,
9479
+ _ClaudeCodeWorker.PERMISSION_TIMEOUT_MS,
9480
+ () => ({ behavior: "deny", message: "Permission request timed out" })
9481
+ );
9482
+ const denied = result2.behavior === "deny";
9483
+ recordPermissionAudit(this.logger, {
9484
+ decision: denied ? "denied" : "approved",
9485
+ operation,
9486
+ access: auditAccessFor(ask.access),
9487
+ resolvedPath: ask.resolvedPath,
9488
+ requestedPath: this.requestedPathOf(input2),
9489
+ matchedEntry: ask.matchedEntry,
9490
+ matchedSource: ask.matchedSource,
9491
+ matchedRule: ask.matchedRule,
9492
+ taskId: this._runContext.taskId,
9493
+ workerId: this._runContext.workerId,
9494
+ toolName,
9495
+ ...denied && {
9496
+ source: result2.message === "Permission request timed out" ? "timeout" : "controller"
9497
+ }
8312
9498
  });
9499
+ return result2;
8313
9500
  }
8314
9501
  /** Human-readable summary of a gated call for the approval prompt. */
8315
- describePermissionRequest(toolName, input2, reason) {
9502
+ describePermissionRequest(toolName, input2, ask) {
8316
9503
  let detail;
8317
9504
  if (toolName === "Bash") {
8318
9505
  detail = `Execute shell command: ${String(input2.command ?? "")}`;
@@ -8325,7 +9512,15 @@ var ClaudeCodeWorker = class _ClaudeCodeWorker {
8325
9512
  } else {
8326
9513
  detail = `${toolName}: ${JSON.stringify(input2)}`;
8327
9514
  }
8328
- return reason ? `${detail} (${reason})` : detail;
9515
+ const hit = ask.sensitive ? verdictDetailSuffix({
9516
+ matchedRule: ask.matchedRule,
9517
+ matchedEntry: ask.matchedEntry,
9518
+ matchedSource: ask.matchedSource,
9519
+ resolvedPath: ask.resolvedPath,
9520
+ operation: ask.operation
9521
+ }) : "";
9522
+ const withHit = `${detail}${hit}`;
9523
+ return ask.reason ? `${withHit} (${ask.reason})` : withHit;
8329
9524
  }
8330
9525
  // --- Prompt as AsyncIterable (supports follow-up messages) ---
8331
9526
  buildPromptIterable(initialPrompt) {
@@ -8630,11 +9825,14 @@ controller: ${answer}`,
8630
9825
  };
8631
9826
 
8632
9827
  // src/services/tools/add-task.ts
9828
+ import { mkdirSync as mkdirSync7 } from "fs";
9829
+ import { homedir as homedir3 } from "os";
9830
+ import * as path8 from "path";
8633
9831
  import { z as z3 } from "zod";
8634
9832
 
8635
9833
  // src/services/tools/workspace-rules.ts
8636
- import { readFileSync as readFileSync9, statSync as statSync3 } from "fs";
8637
- import { join as join16 } from "path";
9834
+ import { readFileSync as readFileSync10, statSync as statSync5 } from "fs";
9835
+ import { join as join18 } from "path";
8638
9836
  var RULE_FILE_NAMES2 = ["AGENTS.md", "CLAUDE.md"];
8639
9837
  var WORKSPACE_RULES_MAX_LINES = 40;
8640
9838
  var WORKSPACE_RULES_MAX_CHARS = 2e3;
@@ -8642,7 +9840,7 @@ function buildWorkspaceRulesSummary(input2) {
8642
9840
  const workspacePath = input2.workspacePath;
8643
9841
  if (!workspacePath) return "";
8644
9842
  for (const name of RULE_FILE_NAMES2) {
8645
- const rulesPath = join16(workspacePath, name);
9843
+ const rulesPath = join18(workspacePath, name);
8646
9844
  if (!isRegularFile(rulesPath)) continue;
8647
9845
  const header = `Workspace rules (from ${rulesPath}):`;
8648
9846
  const refusal = describeRefusal(rulesPath, workspacePath, input2);
@@ -8664,16 +9862,17 @@ function describeRefusal(rulesPath, workspacePath, input2) {
8664
9862
  if (!homeDir2) {
8665
9863
  return "unavailable \u2014 the permission policy cannot be evaluated here (no home directory in this context), so the file was not read.";
8666
9864
  }
8667
- const { decision } = decideAccess({
9865
+ const { decision, matchedEntry } = decideAccess({
8668
9866
  access: "read",
8669
9867
  path: rulesPath,
8670
9868
  cwd: workspacePath,
8671
9869
  homeDir: homeDir2,
8672
9870
  taskWorkspace: workspacePath,
8673
- extraDeniedDirs: input2.permissionConfig?.deniedDirs
9871
+ extraSensitiveDirs: mergeSensitiveDirs(input2.permissionConfig),
9872
+ sensitiveMode: resolveSensitiveMode(input2.permissionConfig)
8674
9873
  });
8675
- if (decision === "deny") {
8676
- return "unavailable \u2014 the file resolves to a denied location (a sensitive one such as credentials or tool config), so its content was not read.";
9874
+ if (matchedEntry !== void 0 || decision === "deny") {
9875
+ return "unavailable \u2014 the file resolves to a sensitive location (a protected path such as credentials or tool config), so its content was not read.";
8677
9876
  }
8678
9877
  if (decision === "ask") {
8679
9878
  return "unavailable \u2014 reading it was not approved by the permission policy (the path is outside the readable scope), so its content was not read.";
@@ -8700,31 +9899,64 @@ function summarizeRules(content) {
8700
9899
  }
8701
9900
  function isRegularFile(p) {
8702
9901
  try {
8703
- return statSync3(p).isFile();
9902
+ return statSync5(p).isFile();
8704
9903
  } catch {
8705
9904
  return false;
8706
9905
  }
8707
9906
  }
8708
9907
  function readTextFile(p) {
8709
9908
  try {
8710
- return readFileSync9(p, "utf8");
9909
+ return readFileSync10(p, "utf8");
8711
9910
  } catch {
8712
9911
  return null;
8713
9912
  }
8714
9913
  }
8715
9914
 
8716
9915
  // src/services/tools/add-task.ts
9916
+ var HOME_VAR_PREFIX = /^\$(?:\{HOME\}|HOME)(?=\/|$)/;
9917
+ function expandHomePrefix(raw, homeDir2) {
9918
+ if (raw === "~") return homeDir2;
9919
+ if (raw.startsWith("~/")) return path8.join(homeDir2, raw.slice(2));
9920
+ const match = HOME_VAR_PREFIX.exec(raw);
9921
+ if (match) {
9922
+ const rest = raw.slice(match[0].length);
9923
+ return rest === "" ? homeDir2 : path8.join(homeDir2, rest);
9924
+ }
9925
+ return raw.startsWith("~") ? null : raw;
9926
+ }
9927
+ function ensureWorkspaceDir(dirPath) {
9928
+ try {
9929
+ mkdirSync7(dirPath, { recursive: true });
9930
+ return null;
9931
+ } catch (err) {
9932
+ return `Cannot create workspace ${dirPath}: ${err.message}. The task was NOT created.`;
9933
+ }
9934
+ }
8717
9935
  function resolveTaskWorkspace(requested, deps) {
8718
9936
  const current = deps.workspaceDir;
8719
- const requestedPath = requested !== void 0 && requested.trim() !== "" ? normalizeWorkspacePath(requested) : void 0;
9937
+ let declared;
9938
+ if (requested !== void 0 && requested.trim() !== "") {
9939
+ const expanded = expandHomePrefix(requested.trim(), deps.homeDir ?? homedir3());
9940
+ if (expanded === null) {
9941
+ return {
9942
+ error: `Cannot resolve workspace "${requested}": only "~" and "$HOME" expand to the home directory, so no readable path was left. The task was NOT created.`
9943
+ };
9944
+ }
9945
+ declared = expanded;
9946
+ }
8720
9947
  if (!current) {
8721
- if (requestedPath !== void 0) {
9948
+ if (declared !== void 0) {
8722
9949
  return {
8723
- error: `Cannot resolve workspace ${requestedPath}: the current workspace is unknown in this context, so the task was NOT created.`
9950
+ error: `Cannot resolve workspace ${normalizeWorkspacePath(declared)}: the current workspace is unknown in this context, so the task was NOT created.`
8724
9951
  };
8725
9952
  }
8726
9953
  return {};
8727
9954
  }
9955
+ if (declared !== void 0 && path8.isAbsolute(declared)) {
9956
+ const dirError = ensureWorkspaceDir(declared);
9957
+ if (dirError !== null) return { error: dirError };
9958
+ }
9959
+ const requestedPath = declared !== void 0 ? normalizeWorkspacePath(declared) : void 0;
8728
9960
  const targetPath = requestedPath ?? normalizeWorkspacePath(current);
8729
9961
  const ref = resolveWorkspaceRef(deps.wayangDir ?? null, targetPath);
8730
9962
  return { workspace: { path: ref.path, slug: ref.slug } };
@@ -8739,9 +9971,9 @@ function resolveTaskAllowedDirs(requested, workspacePath, deps) {
8739
9971
  }
8740
9972
  const home = normalizePath(homeDir2);
8741
9973
  const base = workspacePath !== void 0 ? normalizePath(workspacePath, { base: home }) : home;
8742
- const deniedPaths = [
8743
- ...defaultDeniedPaths(home),
8744
- ...(deps.permissionConfig?.deniedDirs ?? []).filter((d) => typeof d === "string" && d.length > 0).map((d) => normalizePath(d, { base: home }))
9974
+ const sensitivePaths = [
9975
+ ...defaultSensitivePaths(home),
9976
+ ...mergeSensitiveDirs(deps.permissionConfig).map((d) => normalizePath(d, { base: home }))
8745
9977
  ].map((d) => normalizePath(d));
8746
9978
  const seen = /* @__PURE__ */ new Set();
8747
9979
  const allowedDirs = [];
@@ -8757,9 +9989,9 @@ function resolveTaskAllowedDirs(requested, workspacePath, deps) {
8757
9989
  error: `Invalid allowedDirs entry "${raw}" (resolved to ${resolved}): it is outside the home directory (${home}). Only directories under home can be granted to a task. The task was NOT created.`
8758
9990
  };
8759
9991
  }
8760
- if (isDenied(resolved, deniedPaths)) {
9992
+ if (isDenied(resolved, sensitivePaths)) {
8761
9993
  return {
8762
- error: `Invalid allowedDirs entry "${raw}" (resolved to ${resolved}): it matches the denied-path list (a sensitive location such as credentials or tool config). The task was NOT created.`
9994
+ error: `Invalid allowedDirs entry "${raw}" (resolved to ${resolved}): it matches the sensitive list (a protected location such as credentials or tool config). The task was NOT created.`
8763
9995
  };
8764
9996
  }
8765
9997
  if (!seen.has(resolved)) {
@@ -8780,6 +10012,7 @@ function withWorkspaceRules(resultText, workspacePath, deps) {
8780
10012
  ${summary}`;
8781
10013
  }
8782
10014
  function addTaskTool(deps) {
10015
+ const generalWorkspaceNote = deps.generalWorkspaceDir ? ` Tasks that belong to no codebase (research, collecting material, drafting a document) run in the general workspace ${deps.generalWorkspaceDir} \u2014 it is created on first use.` : "";
8783
10016
  return defineTool({
8784
10017
  description: "Add a new task to the queue. A Worker will execute it automatically.",
8785
10018
  parameters: z3.object({
@@ -8794,7 +10027,7 @@ function addTaskTool(deps) {
8794
10027
  "Dispatch this task to an existing idle worker (the workerId returned from a prior multiStage task). The worker resumes with its accumulated context. Omit for a fresh worker."
8795
10028
  ),
8796
10029
  workspace: z3.string().optional().describe(
8797
- "Target workspace path; defaults to the current workspace. A different path runs this task there \u2014 the worker, its tools and its permission checks use it as their root."
10030
+ `Target workspace path; defaults to the current workspace. A different path runs this task there \u2014 the worker, its tools and its permission checks use it as their root. A leading ~ or $HOME expands to the home directory, and a missing directory is created.${generalWorkspaceNote}`
8798
10031
  ),
8799
10032
  allowedDirs: z3.array(z3.string()).optional().describe(
8800
10033
  "Extra directories this task may access, in addition to its workspace. Only this task is affected \u2014 the grant is NOT inherited by other tasks. Each entry must be inside the user home and off the denied-path list (credentials, tool config); an invalid entry fails the whole call and no task is created. Paths are normalized; relative entries resolve against the task workspace."
@@ -9362,7 +10595,7 @@ ${detail}`;
9362
10595
 
9363
10596
  // src/services/tools/read-file.ts
9364
10597
  import { z as z14 } from "zod";
9365
- import { readFileSync as readFileSync10 } from "fs";
10598
+ import { readFileSync as readFileSync11 } from "fs";
9366
10599
  import { resolve as resolve6 } from "path";
9367
10600
  var DEFAULT_MAX_LINES = 500;
9368
10601
  function readFileTool(deps = {}) {
@@ -9374,9 +10607,9 @@ function readFileTool(deps = {}) {
9374
10607
  offset: z14.number().int().min(1).optional().describe("Start line number (1-based)"),
9375
10608
  limit: z14.number().int().min(1).optional().describe("Number of lines to read")
9376
10609
  }),
9377
- execute: safeExecute("read_file", async ({ path: path9, offset, limit }) => {
9378
- const resolved = resolve6(workspace, path9);
9379
- const content = readFileSync10(resolved, "utf-8");
10610
+ execute: safeExecute("read_file", async ({ path: path10, offset, limit }) => {
10611
+ const resolved = resolve6(workspace, path10);
10612
+ const content = readFileSync11(resolved, "utf-8");
9380
10613
  const lines = content.split("\n");
9381
10614
  const totalLines = lines.length;
9382
10615
  const start = offset ? offset - 1 : 0;
@@ -9396,7 +10629,7 @@ function readFileTool(deps = {}) {
9396
10629
 
9397
10630
  // src/services/tools/write-file.ts
9398
10631
  import { z as z15 } from "zod";
9399
- import { writeFileSync as writeFileSync4, mkdirSync as mkdirSync6, existsSync as existsSync10 } from "fs";
10632
+ import { writeFileSync as writeFileSync4, mkdirSync as mkdirSync8, existsSync as existsSync10 } from "fs";
9400
10633
  import { dirname as dirname9, resolve as resolve7 } from "path";
9401
10634
  function writeFileTool(deps = {}) {
9402
10635
  const workspace = deps.cwd ?? process.cwd();
@@ -9406,12 +10639,12 @@ function writeFileTool(deps = {}) {
9406
10639
  path: z15.string().describe("File path (relative to workspace or absolute)"),
9407
10640
  content: z15.string().describe("File content")
9408
10641
  }),
9409
- execute: safeExecute("write_file", async ({ path: path9, content }) => {
9410
- const resolved = resolve7(workspace, path9);
10642
+ execute: safeExecute("write_file", async ({ path: path10, content }) => {
10643
+ const resolved = resolve7(workspace, path10);
9411
10644
  if (existsSync10(resolved)) {
9412
- return `[ERROR] write_file: File already exists: ${path9}. Use edit_file to modify existing files.`;
10645
+ return `[ERROR] write_file: File already exists: ${path10}. Use edit_file to modify existing files.`;
9413
10646
  }
9414
- mkdirSync6(dirname9(resolved), { recursive: true });
10647
+ mkdirSync8(dirname9(resolved), { recursive: true });
9415
10648
  writeFileSync4(resolved, content, "utf-8");
9416
10649
  return `Written ${content.length} chars to ${resolved}`;
9417
10650
  })
@@ -9419,7 +10652,7 @@ function writeFileTool(deps = {}) {
9419
10652
  }
9420
10653
 
9421
10654
  // src/services/tools/edit-file.ts
9422
- import { readFileSync as readFileSync11, writeFileSync as writeFileSync5 } from "fs";
10655
+ import { readFileSync as readFileSync12, writeFileSync as writeFileSync5 } from "fs";
9423
10656
  import { resolve as resolve8 } from "path";
9424
10657
  import { z as z16 } from "zod";
9425
10658
  function editFileTool(deps) {
@@ -9431,22 +10664,22 @@ function editFileTool(deps) {
9431
10664
  old_string: z16.string().describe("Exact text to find (must be unique in file)"),
9432
10665
  new_string: z16.string().describe("Replacement text")
9433
10666
  }),
9434
- execute: safeExecute("edit_file", async ({ path: path9, old_string, new_string }) => {
9435
- const resolved = resolve8(workspace, path9);
10667
+ execute: safeExecute("edit_file", async ({ path: path10, old_string, new_string }) => {
10668
+ const resolved = resolve8(workspace, path10);
9436
10669
  let content;
9437
10670
  try {
9438
- content = readFileSync11(resolved, "utf-8");
10671
+ content = readFileSync12(resolved, "utf-8");
9439
10672
  } catch {
9440
- return `[ERROR] edit_file: File not found: ${path9}`;
10673
+ return `[ERROR] edit_file: File not found: ${path10}`;
9441
10674
  }
9442
10675
  const count = content.split(old_string).length - 1;
9443
10676
  if (count === 0)
9444
- return `[ERROR] edit_file: old_string not found in ${path9}, read the file content and provide a correct old_string.`;
10677
+ return `[ERROR] edit_file: old_string not found in ${path10}, read the file content and provide a correct old_string.`;
9445
10678
  if (count > 1)
9446
- return `[ERROR] edit_file: old_string found ${count} times in ${path9}. Provide more surrounding context to make it unique.`;
10679
+ return `[ERROR] edit_file: old_string found ${count} times in ${path10}. Provide more surrounding context to make it unique.`;
9447
10680
  const newContent = content.replace(old_string, new_string);
9448
10681
  writeFileSync5(resolved, newContent, "utf-8");
9449
- return `Replaced ${old_string.length} chars with ${new_string.length} chars in ${path9}`;
10682
+ return `Replaced ${old_string.length} chars with ${new_string.length} chars in ${path10}`;
9450
10683
  })
9451
10684
  });
9452
10685
  }
@@ -9463,18 +10696,18 @@ function searchFilesTool(deps) {
9463
10696
  pattern: z17.string().describe('Glob pattern (e.g. "**/*.ts", "src/**/*.test.ts")'),
9464
10697
  path: z17.string().optional().describe("Base directory for search (default: workspace root)")
9465
10698
  }),
9466
- execute: safeExecute("search_files", async ({ pattern, path: path9 }) => {
9467
- const basePath = path9 ? normalizePath(path9, { base: workspace }) : normalizePath(workspace);
10699
+ execute: safeExecute("search_files", async ({ pattern, path: path10 }) => {
10700
+ const basePath = path10 ? normalizePath(path10, { base: workspace }) : normalizePath(workspace);
9468
10701
  if (homeDir2) {
9469
- const { decision } = decideAccess({
10702
+ const { decision, matchedEntry } = decideAccess({
9470
10703
  access: "read",
9471
10704
  homeDir: homeDir2,
9472
10705
  cwd: workspace,
9473
10706
  path: basePath
9474
10707
  });
9475
- const requested = path9 ?? workspace;
9476
- if (decision === "deny") {
9477
- return `[ERROR] search_files: refusing to search "${requested}" \u2014 the path is on the read denylist (a sensitive location).`;
10708
+ const requested = path10 ?? workspace;
10709
+ if (matchedEntry !== void 0 || decision === "deny") {
10710
+ return `[ERROR] search_files: refusing to search "${requested}" \u2014 the path is on the sensitive list (a protected location).`;
9478
10711
  }
9479
10712
  if (decision === "ask") {
9480
10713
  return `[ERROR] search_files: refusing to search "${requested}" \u2014 the path is outside the home boundary (${homeDir2}). The controller can only read files under the home directory.`;
@@ -9507,8 +10740,8 @@ function searchContentTool(deps) {
9507
10740
  path: z18.string().optional().describe("Directory to search in (default: workspace root)"),
9508
10741
  include: z18.string().optional().describe('File pattern to include (e.g. "*.ts", "*.py")')
9509
10742
  }),
9510
- execute: safeExecute("search_content", async ({ query: query2, path: path9, include }) => {
9511
- const searchDir = path9 ? resolve9(workspace, path9) : workspace;
10743
+ execute: safeExecute("search_content", async ({ query: query2, path: path10, include }) => {
10744
+ const searchDir = path10 ? resolve9(workspace, path10) : workspace;
9512
10745
  const cmd = `grep -rn${include ? ` --include=${shellQuote(include)}` : ""} -- ${shellQuote(query2)} ${shellQuote(searchDir)}`;
9513
10746
  try {
9514
10747
  const output = execSync3(cmd, {
@@ -9771,6 +11004,7 @@ function createControllerTools(deps) {
9771
11004
  onTaskDispatched: deps.onTaskDispatched,
9772
11005
  workspaceDir: deps.workspaceDir,
9773
11006
  wayangDir: deps.wayangDir,
11007
+ generalWorkspaceDir: deps.generalWorkspaceDir,
9774
11008
  homeDir: deps.homeDir,
9775
11009
  permissionConfig: deps.permissionConfig
9776
11010
  }),
@@ -9878,11 +11112,32 @@ function wrapWithPermissionMiddleware(tools, config, deps) {
9878
11112
  return originalExecute(args, options);
9879
11113
  }
9880
11114
  if (verdict.decision === "deny") {
11115
+ recordPermissionAudit(deps.logger, {
11116
+ decision: "hard-denied",
11117
+ operation: verdict.operation ?? operationForTool(toolName),
11118
+ access: auditAccessFor(verdict.access),
11119
+ resolvedPath: verdict.resolvedPath,
11120
+ requestedPath: typeof args.path === "string" ? args.path : void 0,
11121
+ matchedEntry: verdict.matchedEntry,
11122
+ matchedSource: verdict.matchedSource,
11123
+ matchedRule: verdict.matchedRule,
11124
+ taskId: deps.taskId,
11125
+ workerId: deps.workerId,
11126
+ toolName
11127
+ });
9881
11128
  return verdict.message ?? `[ERROR] ${toolName}: refused by the path permission policy`;
9882
11129
  }
9883
11130
  const requestId = generateId("perm");
11131
+ const operation = verdict.operation ?? operationForTool(toolName);
9884
11132
  const baseDescription = config.describeCall(toolName, args);
9885
- const description = verdict.reason ? `${baseDescription} [reason: ${verdict.reason}]` : baseDescription;
11133
+ const hitDetail = verdict.sensitive ? verdictDetailSuffix({
11134
+ matchedRule: verdict.matchedRule,
11135
+ matchedEntry: verdict.matchedEntry,
11136
+ matchedSource: verdict.matchedSource,
11137
+ resolvedPath: verdict.resolvedPath,
11138
+ operation
11139
+ }) : "";
11140
+ const description = `${baseDescription}${hitDetail}${verdict.reason ? ` [reason: ${verdict.reason}]` : ""}`;
9886
11141
  deps.signalQueue.enqueue({
9887
11142
  source: "worker",
9888
11143
  type: "permission_request",
@@ -9895,7 +11150,14 @@ function wrapWithPermissionMiddleware(tools, config, deps) {
9895
11150
  emoji: deps.emoji,
9896
11151
  toolName,
9897
11152
  toolArgs: args,
9898
- description
11153
+ description,
11154
+ ...verdict.matchedRule !== void 0 && { matchedRule: verdict.matchedRule },
11155
+ ...verdict.matchedEntry !== void 0 && { matchedEntry: verdict.matchedEntry },
11156
+ ...verdict.matchedSource !== void 0 && { matchedSource: verdict.matchedSource },
11157
+ ...verdict.resolvedPath !== void 0 && { resolvedPath: verdict.resolvedPath },
11158
+ ...verdict.sensitive !== void 0 && { sensitive: verdict.sensitive },
11159
+ ...verdict.access !== void 0 && { access: verdict.access },
11160
+ operation
9899
11161
  },
9900
11162
  ...deps.turnLineage ?? {}
9901
11163
  });
@@ -9913,6 +11175,22 @@ function wrapWithPermissionMiddleware(tools, config, deps) {
9913
11175
  timer
9914
11176
  });
9915
11177
  });
11178
+ recordPermissionAudit(deps.logger, {
11179
+ decision: result2.approved ? "approved" : "denied",
11180
+ operation,
11181
+ access: auditAccessFor(verdict.access),
11182
+ resolvedPath: verdict.resolvedPath,
11183
+ requestedPath: typeof args.path === "string" ? args.path : void 0,
11184
+ matchedEntry: verdict.matchedEntry,
11185
+ matchedSource: verdict.matchedSource,
11186
+ matchedRule: verdict.matchedRule,
11187
+ taskId: deps.taskId,
11188
+ workerId: deps.workerId,
11189
+ toolName,
11190
+ ...!result2.approved && {
11191
+ source: result2.reason === "timeout" ? "timeout" : result2.reason === "Worker aborted" ? "aborted" : "controller"
11192
+ }
11193
+ });
9916
11194
  if (!result2.approved) {
9917
11195
  if (result2.reason === "timeout") {
9918
11196
  return `[ERROR] Permission request timed out for ${toolName}`;
@@ -9985,7 +11263,7 @@ function formatApiCallError(err) {
9985
11263
 
9986
11264
  // src/services/task-execute-engine.ts
9987
11265
  import { basename as basename8, dirname as dirname10, resolve as resolve10 } from "path";
9988
- import { homedir as homedir2 } from "os";
11266
+ import { homedir as homedir4 } from "os";
9989
11267
  var RuntimeState = class extends BaseWayangState {
9990
11268
  constructor(initialData) {
9991
11269
  super(initialData, []);
@@ -9996,7 +11274,7 @@ var RuntimeState = class extends BaseWayangState {
9996
11274
  var PUPPET_WORKER_TYPE = "puppet";
9997
11275
  function resolveUserHomeDir(sessionDir) {
9998
11276
  const wayangDir = wayangDirForSessionDir(sessionDir);
9999
- return wayangDir && basename8(wayangDir) === ".wayang" ? dirname10(wayangDir) : homedir2();
11277
+ return wayangDir && basename8(wayangDir) === ".wayang" ? dirname10(wayangDir) : homedir4();
10000
11278
  }
10001
11279
  var DEFAULT_IDLE_TIMEOUT_MS = 30 * 60 * 1e3;
10002
11280
  var INQUIRY_TIMEOUT_MS = 3e5;
@@ -10065,15 +11343,15 @@ var TaskExecuteEngine = class {
10065
11343
  // ---------------------------------------------------------------------------
10066
11344
  // Subscribable
10067
11345
  // ---------------------------------------------------------------------------
10068
- subscribe(path9, callback) {
10069
- if (path9.startsWith("tasks.")) return this.taskState.on(path9, callback);
10070
- if (path9 === "activeWorkers" || path9.startsWith("activeWorkers.")) return this.workerState.on(path9, callback);
10071
- throw new Error(`TaskExecuteEngine: unknown subscription path "${path9}"`);
11346
+ subscribe(path10, callback) {
11347
+ if (path10.startsWith("tasks.")) return this.taskState.on(path10, callback);
11348
+ if (path10 === "activeWorkers" || path10.startsWith("activeWorkers.")) return this.workerState.on(path10, callback);
11349
+ throw new Error(`TaskExecuteEngine: unknown subscription path "${path10}"`);
10072
11350
  }
10073
- getSnapshot(path9) {
10074
- if (path9.startsWith("tasks.")) return this.taskState.get(path9);
10075
- if (path9 === "activeWorkers" || path9.startsWith("activeWorkers.")) return this.workerState.get(path9);
10076
- throw new Error(`TaskExecuteEngine: unknown snapshot path "${path9}"`);
11351
+ getSnapshot(path10) {
11352
+ if (path10.startsWith("tasks.")) return this.taskState.get(path10);
11353
+ if (path10 === "activeWorkers" || path10.startsWith("activeWorkers.")) return this.workerState.get(path10);
11354
+ throw new Error(`TaskExecuteEngine: unknown snapshot path "${path10}"`);
10077
11355
  }
10078
11356
  // ---------------------------------------------------------------------------
10079
11357
  // Task management
@@ -10944,9 +12222,10 @@ var TaskExecuteEngine = class {
10944
12222
  if (workerType === PUPPET_WORKER_TYPE) {
10945
12223
  const middleware = wrapWithPermissionMiddleware(tools, {
10946
12224
  gatedTools: ["bash", ...FILE_TOOL_NAMES],
10947
- // Single decision source: the shared access policy (denylist →
12225
+ // Single decision source: the shared access policy (sensitive list →
10948
12226
  // config roots → task allowedDirs → task workspace → read-in-home →
10949
- // ask). `deny` refuses here without ever emitting a permission_request.
12227
+ // ask). `deny` refuses here without ever emitting a permission_request;
12228
+ // both `deny` and every answered `ask` are audited (see the middleware).
10950
12229
  decide: (toolName, args) => decideToolAccess(accessScope, toolName, args),
10951
12230
  describeCall: (toolName, args) => {
10952
12231
  if (toolName === "bash") return `Execute shell command: ${args.command}`;
@@ -10964,7 +12243,9 @@ var TaskExecuteEngine = class {
10964
12243
  workerType,
10965
12244
  emoji: workerMeta.emoji,
10966
12245
  signalQueue: this.signalQueue,
10967
- turnLineage: this.workerLineage(workerId)
12246
+ turnLineage: this.workerLineage(workerId),
12247
+ // Permission audit trail (metadata only) shares the session logger.
12248
+ logger: this.ctx.logger
10968
12249
  });
10969
12250
  tools = middleware.wrappedTools;
10970
12251
  this.permissionResolvers.set(workerId, middleware);
@@ -11218,7 +12499,7 @@ var ControllerLoop = class {
11218
12499
  };
11219
12500
 
11220
12501
  // src/services/agents/controller-state.ts
11221
- import { join as join17 } from "path";
12502
+ import { join as join20 } from "path";
11222
12503
 
11223
12504
  // src/services/conversation/conversation-index.ts
11224
12505
  function emptyUsage() {
@@ -11442,8 +12723,8 @@ var ControllerAgentState = class extends BaseWayangState {
11442
12723
  /** In-memory resolve function for the current inquiry — never persisted. */
11443
12724
  inquiryResolver = null;
11444
12725
  constructor(ctx) {
11445
- const jsonFile = new JSONFileHelper(join17(ctx.sessionDir, "runtime-state.json"));
11446
- const jsonlFile = new JSONLFileHelper(join17(ctx.sessionDir, "conversation.jsonl"));
12726
+ const jsonFile = new JSONFileHelper(join20(ctx.sessionDir, "runtime-state.json"));
12727
+ const jsonlFile = new JSONLFileHelper(join20(ctx.sessionDir, "conversation.jsonl"));
11447
12728
  const initialData = {
11448
12729
  runtimeState: {
11449
12730
  session: { id: "", startedAt: 0 },
@@ -11468,9 +12749,9 @@ var ControllerAgentState = class extends BaseWayangState {
11468
12749
  * shared conversation-envelope module — the single source of truth. This
11469
12750
  * override only feeds the in-memory turn index incrementally.
11470
12751
  */
11471
- append(path9, entry) {
11472
- super.append(path9, entry);
11473
- if (path9 === "conversation" && this.convIndex) {
12752
+ append(path10, entry) {
12753
+ super.append(path10, entry);
12754
+ if (path10 === "conversation" && this.convIndex) {
11474
12755
  this.convIndex.onAppend(entry);
11475
12756
  }
11476
12757
  }
@@ -11613,8 +12894,8 @@ function buildSummaryInput(entries, maxChars) {
11613
12894
  lines = formatAll(entries, caps);
11614
12895
  if (totalLength(lines) <= maxChars) return lines;
11615
12896
  }
11616
- const noteFor = (n) => `[${n} older entries omitted \u2014 over the summarizer input budget]`;
11617
- const reserve = noteFor(lines.length).length + 1;
12897
+ const noteFor2 = (n) => `[${n} older entries omitted \u2014 over the summarizer input budget]`;
12898
+ const reserve = noteFor2(lines.length).length + 1;
11618
12899
  const kept = [];
11619
12900
  let used = 0;
11620
12901
  for (let i = lines.length - 1; i >= 0; i--) {
@@ -11624,12 +12905,12 @@ function buildSummaryInput(entries, maxChars) {
11624
12905
  used += size;
11625
12906
  }
11626
12907
  const omitted = lines.length - kept.length;
11627
- return omitted > 0 ? [noteFor(omitted), ...kept] : kept;
12908
+ return omitted > 0 ? [noteFor2(omitted), ...kept] : kept;
11628
12909
  }
11629
12910
 
11630
12911
  // src/services/agents/controller-agent.ts
11631
12912
  import { basename as basename9, dirname as dirname11 } from "path";
11632
- import { homedir as homedir3 } from "os";
12913
+ import { homedir as homedir5 } from "os";
11633
12914
  var SUMMARIZER_INPUT_RATIO = 0.5;
11634
12915
  var ControllerAgent = class _ControllerAgent extends BaseAgent {
11635
12916
  state;
@@ -11662,7 +12943,7 @@ var ControllerAgent = class _ControllerAgent extends BaseAgent {
11662
12943
  * cancelled while its workers are still running.
11663
12944
  */
11664
12945
  turnInterruptRecorded = false;
11665
- constructor(ctx, state, provider, tools, skills, recorder = new ToolCallRecorder()) {
12946
+ constructor(ctx, state, provider, tools, skills, recorder = new ToolCallRecorder(), controllerRules) {
11666
12947
  super(provider);
11667
12948
  this.ctx = ctx;
11668
12949
  this.state = state;
@@ -11673,7 +12954,7 @@ var ControllerAgent = class _ControllerAgent extends BaseAgent {
11673
12954
  this.workspaceSlug = ctx.workspaceDir ? resolveWorkspaceRef(wayangDirForSessionDir(ctx.sessionDir), ctx.workspaceDir).slug : void 0;
11674
12955
  this.contextManager = new ContextManager(
11675
12956
  state,
11676
- buildControllerSystemPrompt(this.ctx, skills),
12957
+ buildControllerSystemPrompt(this.ctx, skills, { rules: controllerRules }),
11677
12958
  provider.contextWindowTokens
11678
12959
  );
11679
12960
  this.setHooks({
@@ -11695,7 +12976,17 @@ var ControllerAgent = class _ControllerAgent extends BaseAgent {
11695
12976
  const state = new ControllerAgentState(ctx);
11696
12977
  const recorder = new ToolCallRecorder(ctx.logger);
11697
12978
  const wayangDir = wayangDirForSessionDir(ctx.sessionDir);
11698
- const homeDir2 = wayangDir && basename9(wayangDir) === ".wayang" ? dirname11(wayangDir) : homedir3();
12979
+ const homeDir2 = wayangDir && basename9(wayangDir) === ".wayang" ? dirname11(wayangDir) : homedir5();
12980
+ const generalWorkspaceDir = resolveGeneralWorkspace(ctx);
12981
+ try {
12982
+ ensureGeneralWorkspace(ctx);
12983
+ } catch {
12984
+ ctx.logger.warn(`could not create the general workspace at ${generalWorkspaceDir}`);
12985
+ }
12986
+ const controllerRules = loadControllerRules({
12987
+ homeDir: homeDir2,
12988
+ cwd: ctx.workspaceDir || process.cwd()
12989
+ });
11699
12990
  const tools = createControllerTools({
11700
12991
  addTask: (task) => engine.add(task),
11701
12992
  validateWorkerType: (type) => engine.validateWorkerType(type),
@@ -11725,7 +13016,8 @@ var ControllerAgent = class _ControllerAgent extends BaseAgent {
11725
13016
  cwd: ctx.workspaceDir,
11726
13017
  homeDir: homeDir2,
11727
13018
  // User-config permission section: add_task validates a task's allowedDirs
11728
- // against the built-in denylist plus these configured deniedDirs.
13019
+ // against the built-in sensitive list plus these configured
13020
+ // sensitiveDirs (the deprecated deniedDirs alias is merged in).
11729
13021
  permissionConfig: opts.config.permission,
11730
13022
  getNotebook: () => state.get("runtimeState.notebook") ?? "",
11731
13023
  setNotebook: (content, mode) => {
@@ -11748,9 +13040,10 @@ var ControllerAgent = class _ControllerAgent extends BaseAgent {
11748
13040
  // Default workspace for tasks created by add_task; a declared workspace
11749
13041
  // is stamped instead and the engine executes the task there.
11750
13042
  workspaceDir: ctx.workspaceDir,
11751
- wayangDir
13043
+ wayangDir,
13044
+ generalWorkspaceDir
11752
13045
  });
11753
- return new _ControllerAgent(ctx, state, provider, tools, skills, recorder);
13046
+ return new _ControllerAgent(ctx, state, provider, tools, skills, recorder, controllerRules);
11754
13047
  }
11755
13048
  /** Run the controller agent — streams text chunks while updating streamingEntries in state. */
11756
13049
  async run(signals) {
@@ -12015,11 +13308,11 @@ var ControllerAgent = class _ControllerAgent extends BaseAgent {
12015
13308
  return this.state.getTurn(turnId);
12016
13309
  }
12017
13310
  // --- Subscribable ---
12018
- subscribe(path9, callback) {
12019
- return this.state.on(path9, callback);
13311
+ subscribe(path10, callback) {
13312
+ return this.state.on(path10, callback);
12020
13313
  }
12021
- getSnapshot(path9) {
12022
- return this.state.get(path9);
13314
+ getSnapshot(path10) {
13315
+ return this.state.get(path10);
12023
13316
  }
12024
13317
  /** Check if context needs compaction. */
12025
13318
  needsCompaction() {
@@ -12347,8 +13640,8 @@ function signalToEntry(sig, ts, sessionId) {
12347
13640
  }
12348
13641
 
12349
13642
  // src/services/skills/registry.ts
12350
- import { readFileSync as readFileSync12, readdirSync as readdirSync2, statSync as statSync4 } from "fs";
12351
- import { join as join18 } from "path";
13643
+ import { readFileSync as readFileSync13, readdirSync as readdirSync2, statSync as statSync6 } from "fs";
13644
+ import { join as join21 } from "path";
12352
13645
  import { parse as parseYaml } from "yaml";
12353
13646
  var SKILL_FILE = "SKILL.md";
12354
13647
  function parseFrontmatter(raw) {
@@ -12383,7 +13676,7 @@ function listResources(skillDir) {
12383
13676
  const walk = (relDir) => {
12384
13677
  let entries;
12385
13678
  try {
12386
- entries = readdirSync2(join18(skillDir, relDir), { withFileTypes: true });
13679
+ entries = readdirSync2(join21(skillDir, relDir), { withFileTypes: true });
12387
13680
  } catch {
12388
13681
  return;
12389
13682
  }
@@ -12430,10 +13723,10 @@ var SkillRegistry = class {
12430
13723
  if (existing) return existing;
12431
13724
  const desc = this.descriptors.get(name);
12432
13725
  if (!desc) return void 0;
12433
- const filePath = join18(desc.dir, SKILL_FILE);
13726
+ const filePath = join21(desc.dir, SKILL_FILE);
12434
13727
  let raw;
12435
13728
  try {
12436
- raw = readFileSync12(filePath, "utf-8");
13729
+ raw = readFileSync13(filePath, "utf-8");
12437
13730
  } catch (err) {
12438
13731
  this.logger?.warn({ skill: name, error: err instanceof Error ? err.message : String(err) }, "Failed to read skill file");
12439
13732
  return void 0;
@@ -12461,8 +13754,8 @@ var SkillRegistry = class {
12461
13754
  for (const entry of entries) {
12462
13755
  if (!entry.isDirectory()) continue;
12463
13756
  if (entry.name.startsWith(".") || entry.name === "node_modules") continue;
12464
- const skillDir = join18(dir, entry.name);
12465
- const skillFile = join18(skillDir, SKILL_FILE);
13757
+ const skillDir = join21(dir, entry.name);
13758
+ const skillFile = join21(skillDir, SKILL_FILE);
12466
13759
  if (!existsAsFile(skillFile)) continue;
12467
13760
  const { frontmatter } = safeReadFrontmatter(skillFile);
12468
13761
  const description = asString(frontmatter.description);
@@ -12475,16 +13768,16 @@ var SkillRegistry = class {
12475
13768
  }
12476
13769
  }
12477
13770
  };
12478
- function existsAsFile(path9) {
13771
+ function existsAsFile(path10) {
12479
13772
  try {
12480
- return statSync4(path9).isFile();
13773
+ return statSync6(path10).isFile();
12481
13774
  } catch {
12482
13775
  return false;
12483
13776
  }
12484
13777
  }
12485
- function safeReadFrontmatter(path9) {
13778
+ function safeReadFrontmatter(path10) {
12486
13779
  try {
12487
- return parseFrontmatter(readFileSync12(path9, "utf-8"));
13780
+ return parseFrontmatter(readFileSync13(path10, "utf-8"));
12488
13781
  } catch {
12489
13782
  return { frontmatter: {}, body: "" };
12490
13783
  }
@@ -12492,12 +13785,12 @@ function safeReadFrontmatter(path9) {
12492
13785
 
12493
13786
  // src/services/skills/skill-dir.ts
12494
13787
  import { existsSync as existsSync11 } from "fs";
12495
- import { homedir as homedir4 } from "os";
12496
- import { join as join19, resolve as resolve11 } from "path";
13788
+ import { homedir as homedir6 } from "os";
13789
+ import { join as join22, resolve as resolve11 } from "path";
12497
13790
  function resolveSkillDirs(workspaceDir2, configDirs) {
12498
13791
  const candidates = [
12499
- join19(homedir4(), ".wayang", "skills"),
12500
- join19(workspaceDir2, ".wayang", "skills"),
13792
+ join22(homedir6(), ".wayang", "skills"),
13793
+ join22(workspaceDir2, ".wayang", "skills"),
12501
13794
  ...(configDirs ?? []).map((d) => resolve11(d))
12502
13795
  ];
12503
13796
  const seen = /* @__PURE__ */ new Set();
@@ -12824,11 +14117,11 @@ async function runWayangServer(options, io = process) {
12824
14117
  let nextSubscriptionId = 1;
12825
14118
  rpc.register(METHOD.SUBSCRIBE, (params) => {
12826
14119
  const target = resolveTarget(params.target);
12827
- const path9 = requiredString(params, "path");
12828
- const data = target.getSnapshot(path9);
14120
+ const path10 = requiredString(params, "path");
14121
+ const data = target.getSnapshot(path10);
12829
14122
  const subscriptionId = `sub-${nextSubscriptionId++}`;
12830
- subscriptions.set(subscriptionId, target.subscribe(path9, (event) => {
12831
- rpc.notify(NOTIFY.STATE_EVENT, { subscriptionId, target: params.target, path: path9, data: event.data });
14123
+ subscriptions.set(subscriptionId, target.subscribe(path10, (event) => {
14124
+ rpc.notify(NOTIFY.STATE_EVENT, { subscriptionId, target: params.target, path: path10, data: event.data });
12832
14125
  }));
12833
14126
  return { subscriptionId, data: data === void 0 ? null : data };
12834
14127
  });
@@ -13009,9 +14302,9 @@ if (result.status === "error") {
13009
14302
  process.exit(1);
13010
14303
  }
13011
14304
  var { input, flags } = result;
13012
- var homeDir = path8.resolve(flags.homeDir ?? (process.env.HOME || "~"));
14305
+ var homeDir = path9.resolve(flags.homeDir ?? (process.env.HOME || "~"));
13013
14306
  var workspaceDir = normalizeWorkspacePath(flags.workspaceDir ?? process.cwd());
13014
- var configPath = flags.config ? path8.resolve(flags.config) : path8.join(homeDir, ".wayang.config.json");
14307
+ var configPath = flags.config ? path9.resolve(flags.config) : path9.join(homeDir, ".wayang.config.json");
13015
14308
  function fatal(err) {
13016
14309
  if (err instanceof SessionLockedError) {
13017
14310
  console.error(formatSessionLockHint(err));
@@ -13023,7 +14316,7 @@ function fatal(err) {
13023
14316
  if (input[0] === "server") {
13024
14317
  serverMain({
13025
14318
  configPath,
13026
- homeDir: path8.join(homeDir, ".wayang"),
14319
+ homeDir: path9.join(homeDir, ".wayang"),
13027
14320
  workspaceDir,
13028
14321
  session: flags.session ?? "new",
13029
14322
  injectPreviousTasks: flags.injectPreviousTasks,