wayang-ai 0.7.2 → 0.8.0

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 +2552 -933
  2. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -1,14 +1,14 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/cli.ts
4
- import * as path6 from "path";
4
+ import * as path8 from "path";
5
5
 
6
6
  // src/cli-args.ts
7
7
  import meow from "meow";
8
8
  var CLI_HELP_TEXT = `
9
9
  Usage
10
10
  $ wayang Resume latest session for this workspace (or new)
11
- $ wayang --fresh Start a brand-new session (ignore prior context)
11
+ $ wayang --new Start a brand-new session (ignore prior context)
12
12
  $ wayang --resume Interactive session select
13
13
  $ wayang --resume <id> Resume specific session
14
14
  $ wayang --resume --all Show all workspaces
@@ -17,8 +17,13 @@ var CLI_HELP_TEXT = `
17
17
  Options
18
18
  --home-dir <path> Root directory for sessions and config (default: $HOME)
19
19
  --workspace-dir, -w Working directory for tools (default: pwd)
20
+ Alias: --workspace.
21
+ New session: recorded as its initial workspace.
22
+ --resume: never rewrites the session's recorded
23
+ workspaces; it only filters the picker (and picks
24
+ the default when resuming without an id).
20
25
  --config, -c Config file path
21
- --fresh, -n Start a new session (do not inherit prior context)
26
+ --new, -n Start a new session (do not inherit prior context)
22
27
  --resume, -r Pick a session to resume (interactive, or <id>)
23
28
  --all List sessions across all workspaces (with --resume)
24
29
  --verbose Enable debug logging
@@ -27,12 +32,21 @@ var CLI_HELP_TEXT = `
27
32
  --session <mode> (server) "new" or "resume:<id>"
28
33
  --inject-previous-tasks (server) surface unfinished tasks on resume
29
34
  --log-level <level> (server) log level
35
+
36
+ Deprecated
37
+ --fresh Alias of --new (same behavior); use --new instead
30
38
  `;
31
39
  var CLI_FLAGS = {
32
40
  homeDir: { type: "string" },
33
- workspaceDir: { type: "string", shortFlag: "w" },
41
+ // `aliases` is asserted mutable because meow types it as `string[]`; the
42
+ // surrounding `as const` would otherwise make the array readonly.
43
+ workspaceDir: { type: "string", shortFlag: "w", aliases: ["workspace"] },
34
44
  config: { type: "string", shortFlag: "c" },
35
- fresh: { type: "boolean", shortFlag: "n", default: false },
45
+ new: { type: "boolean", shortFlag: "n", default: false },
46
+ // Deprecated alias of `--new` (the pre-0.8 spelling). Registered as its own
47
+ // key so the parser accepts it and the unknown-flag/negation passes cover
48
+ // it; the result is folded back into `new` (see parseCliArgs).
49
+ fresh: { type: "boolean", default: false },
36
50
  resume: { type: "boolean", shortFlag: "r", default: false },
37
51
  all: { type: "boolean", default: false },
38
52
  verbose: { type: "boolean", default: false },
@@ -46,6 +60,13 @@ var FLAG_ENTRIES = Object.entries(CLI_FLAGS);
46
60
  var CANONICAL_KEYS = new Set(FLAG_ENTRIES.map(([key]) => key));
47
61
  var SHORT_KEYS = new Set(FLAG_ENTRIES.flatMap(([, flag]) => flag.shortFlag ? [flag.shortFlag] : []));
48
62
  var LONG_NAMES = new Set(FLAG_ENTRIES.map(([key]) => decamelizeKey(key)));
63
+ var ALIAS_LONG_NAMES = new Set(
64
+ FLAG_ENTRIES.flatMap(([, flag]) => (flag.aliases ?? []).map(decamelizeKey))
65
+ );
66
+ var ALIAS_KEYS = new Set(
67
+ FLAG_ENTRIES.flatMap(([, flag]) => (flag.aliases ?? []).flatMap((name) => [name, camelizeKey(name)]))
68
+ );
69
+ var KNOWN_LONG_NAMES = /* @__PURE__ */ new Set([...LONG_NAMES, ...ALIAS_LONG_NAMES]);
49
70
  var BOOLEAN_LONG_NAMES = new Set(
50
71
  FLAG_ENTRIES.filter(([, flag]) => flag.type === "boolean").map(([key]) => decamelizeKey(key))
51
72
  );
@@ -57,10 +78,13 @@ for (const [key, flag] of FLAG_ENTRIES) {
57
78
  function fold(name) {
58
79
  return name.toLowerCase().replace(/[-_]/g, "");
59
80
  }
60
- var FOLDED_LONG_NAMES = new Set([...LONG_NAMES].map(fold));
81
+ var FOLDED_LONG_NAMES = new Set([...KNOWN_LONG_NAMES].map(fold));
61
82
  function decamelizeKey(key) {
62
83
  return key.replace(/([a-z0-9])([A-Z])/g, "$1-$2").toLowerCase();
63
84
  }
85
+ function camelizeKey(name) {
86
+ return name.replace(/-([a-z0-9])/g, (_match, char) => char.toUpperCase());
87
+ }
64
88
  function renderUnknownKey(argv, key) {
65
89
  const keyFolded = fold(key);
66
90
  for (const token of argv) {
@@ -85,13 +109,13 @@ function scanArgvForSwallowedFlags(argv, problems) {
85
109
  if (token.startsWith("--")) {
86
110
  const long = (eqIndex === -1 ? token : token.slice(0, eqIndex)).slice(2);
87
111
  if (!long) continue;
88
- if (LONG_NAMES.has(long)) {
112
+ if (KNOWN_LONG_NAMES.has(long)) {
89
113
  if (eqIndex !== -1 && BOOLEAN_LONG_NAMES.has(long)) {
90
114
  problems.add(`Option --${long} does not accept a value`);
91
115
  }
92
116
  continue;
93
117
  }
94
- if (FOLDED_LONG_NAMES.has(fold(long)) || long.startsWith("no-") && LONG_NAMES.has(long.slice(3))) {
118
+ if (FOLDED_LONG_NAMES.has(fold(long)) || long.startsWith("no-") && KNOWN_LONG_NAMES.has(long.slice(3))) {
95
119
  problems.add(`Unknown option: --${long}`);
96
120
  }
97
121
  continue;
@@ -105,6 +129,23 @@ function scanArgvForSwallowedFlags(argv, problems) {
105
129
  }
106
130
  }
107
131
  }
132
+ var NEW_FLAG_LONG_NAMES = /* @__PURE__ */ new Set(["new", "fresh"]);
133
+ function countNewFlagOccurrences(argv) {
134
+ let count = 0;
135
+ for (const token of argv) {
136
+ if (token === "--") break;
137
+ if (!token.startsWith("-") || token === "-") continue;
138
+ const isLong = token.startsWith("--");
139
+ const name = (isLong ? token.slice(2) : token.slice(1)).split("=")[0];
140
+ if (!name) continue;
141
+ if (isLong) {
142
+ if (NEW_FLAG_LONG_NAMES.has(name)) count += 1;
143
+ } else {
144
+ count += [...name].filter((ch) => ch === "n").length;
145
+ }
146
+ }
147
+ return count;
148
+ }
108
149
  function parseCliArgs(argv) {
109
150
  let cli;
110
151
  try {
@@ -117,9 +158,12 @@ function parseCliArgs(argv) {
117
158
  };
118
159
  }
119
160
  const problems = /* @__PURE__ */ new Set();
161
+ if (countNewFlagOccurrences(argv) > 1) {
162
+ problems.add("The flag --new can only be set once.");
163
+ }
120
164
  const unnormalized = cli.unnormalizedFlags;
121
165
  for (const key of Object.keys(unnormalized)) {
122
- if (!CANONICAL_KEYS.has(key) && !SHORT_KEYS.has(key)) {
166
+ if (!CANONICAL_KEYS.has(key) && !SHORT_KEYS.has(key) && !ALIAS_KEYS.has(key)) {
123
167
  problems.add(`Unknown option: ${renderUnknownKey(argv, key)}`);
124
168
  }
125
169
  }
@@ -146,7 +190,9 @@ function parseCliArgs(argv) {
146
190
  homeDir: readString(flags2.homeDir),
147
191
  workspaceDir: readString(flags2.workspaceDir),
148
192
  config: readString(flags2.config),
149
- fresh: flags2.fresh === true,
193
+ // `--fresh` is the deprecated alias, so either key means the same flag.
194
+ // A double set is already rejected above, never reaching this point.
195
+ new: flags2.new === true || flags2.fresh === true,
150
196
  resume: flags2.resume === true,
151
197
  all: flags2.all === true,
152
198
  verbose: flags2.verbose === true,
@@ -175,7 +221,8 @@ function renderHelp() {
175
221
  }
176
222
 
177
223
  // src/bootstrap.ts
178
- import * as path4 from "path";
224
+ import * as fs6 from "fs";
225
+ import * as path7 from "path";
179
226
 
180
227
  // src/client/connect.ts
181
228
  import { spawn } from "child_process";
@@ -362,9 +409,9 @@ var RpcClient = class {
362
409
  return Promise.reject(new Error("RPC transport closed: cannot send request"));
363
410
  }
364
411
  const id = this.nextId++;
365
- return new Promise((resolve12, reject) => {
412
+ return new Promise((resolve13, reject) => {
366
413
  this.pending.set(id, {
367
- resolve: (v) => resolve12(v),
414
+ resolve: (v) => resolve13(v),
368
415
  reject
369
416
  });
370
417
  this.transport.send({ id, method, params });
@@ -416,11 +463,11 @@ var RemoteStateStore = class {
416
463
  rpc;
417
464
  entries = /* @__PURE__ */ new Map();
418
465
  byId = /* @__PURE__ */ new Map();
419
- subscribe(target, path7, callback) {
420
- const key = `${target}:${path7}`;
466
+ subscribe(target, path9, callback) {
467
+ const key = `${target}:${path9}`;
421
468
  let entry = this.entries.get(key);
422
469
  if (!entry) {
423
- entry = { subscriptionId: "", target, path: path7, data: void 0, listeners: /* @__PURE__ */ new Set(), settled: false };
470
+ entry = { subscriptionId: "", target, path: path9, data: void 0, listeners: /* @__PURE__ */ new Set(), settled: false };
424
471
  this.entries.set(key, entry);
425
472
  this.connect(entry, key);
426
473
  }
@@ -430,8 +477,8 @@ var RemoteStateStore = class {
430
477
  if (entry.listeners.size === 0) this.teardown(entry, key);
431
478
  };
432
479
  }
433
- getSnapshot(target, path7) {
434
- return this.entries.get(`${target}:${path7}`)?.data;
480
+ getSnapshot(target, path9) {
481
+ return this.entries.get(`${target}:${path9}`)?.data;
435
482
  }
436
483
  connect(entry, key) {
437
484
  this.rpc.request(METHOD.SUBSCRIBE, { target: entry.target, path: entry.path }).then((res) => {
@@ -469,21 +516,21 @@ var RemoteSubscribable = class {
469
516
  }
470
517
  store;
471
518
  target;
472
- subscribe(path7, callback) {
473
- return this.store.subscribe(this.target, path7, callback);
519
+ subscribe(path9, callback) {
520
+ return this.store.subscribe(this.target, path9, callback);
474
521
  }
475
- getSnapshot(path7) {
476
- return this.store.getSnapshot(this.target, path7);
522
+ getSnapshot(path9) {
523
+ return this.store.getSnapshot(this.target, path9);
477
524
  }
478
525
  };
479
526
 
480
527
  // src/client/remote-supervisor.ts
481
528
  var CONTEXT_USAGE_STATE_PATH = "dynamicState.contextUsage";
482
529
  var RemoteSupervisor = class {
483
- constructor(rpc, store, workspaceDir2, hooks) {
530
+ constructor(rpc, store, workspaceDir2, hooks, workspaces = []) {
484
531
  this.rpc = rpc;
485
532
  this.hooks = hooks;
486
- this.ctx = { workspaceDir: workspaceDir2 };
533
+ this.ctx = { workspaceDir: workspaceDir2, workspaces };
487
534
  this.controllerAgent = Object.assign(new RemoteSubscribable(store, "controllerAgent"), {
488
535
  abort: () => {
489
536
  void this.rpc.request(METHOD.ABORT_CONTROLLER).catch(() => {
@@ -568,8 +615,8 @@ async function connectRemoteServer(options) {
568
615
  child.stderr?.on("data", (chunk) => {
569
616
  stderrTail = (stderrTail + chunk).slice(-4e3);
570
617
  });
571
- const exited = new Promise((resolve12) => {
572
- child.on("exit", (code) => resolve12(code));
618
+ const exited = new Promise((resolve13) => {
619
+ child.on("exit", (code) => resolve13(code));
573
620
  });
574
621
  const transport = createStreamTransport(child.stdout, child.stdin);
575
622
  const rpc = new RpcClient(transport);
@@ -588,10 +635,10 @@ async function connectRemoteServer(options) {
588
635
  }
589
636
  const exitOrKill = Promise.race([
590
637
  exited,
591
- new Promise((resolve12) => {
638
+ new Promise((resolve13) => {
592
639
  setTimeout(() => {
593
640
  child.kill("SIGKILL");
594
- resolve12(null);
641
+ resolve13(null);
595
642
  }, SHUTDOWN_GRACE_MS);
596
643
  })
597
644
  ]);
@@ -605,11 +652,11 @@ ${stderrTail}`);
605
652
  process.exit(1);
606
653
  }
607
654
  });
608
- const remote = new RemoteSupervisor(rpc, store, options.workspaceDir, { stop });
655
+ const remote = new RemoteSupervisor(rpc, store, options.workspaceDir, { stop }, options.workspaces);
609
656
  return { remote, stop };
610
657
  }
611
658
  function waitForReady(child, rpc, exited, getStderrTail, timeoutMs) {
612
- return new Promise((resolve12, reject) => {
659
+ return new Promise((resolve13, reject) => {
613
660
  let settled = false;
614
661
  const finish = (err) => {
615
662
  if (settled) return;
@@ -617,7 +664,7 @@ function waitForReady(child, rpc, exited, getStderrTail, timeoutMs) {
617
664
  clearTimeout(timer);
618
665
  offReady();
619
666
  if (err) reject(err);
620
- else resolve12();
667
+ else resolve13();
621
668
  };
622
669
  const timer = setTimeout(() => {
623
670
  child.kill("SIGKILL");
@@ -635,139 +682,1110 @@ ${getStderrTail()}`));
635
682
  }
636
683
 
637
684
  // src/infra/session-helpers.ts
685
+ import * as fs3 from "fs";
686
+ import * as path3 from "path";
687
+
688
+ // src/infra/session-index.ts
638
689
  import * as fs2 from "fs";
639
690
  import * as path2 from "path";
640
691
 
641
- // src/infra/session-index.ts
692
+ // src/infra/workspace-registry.ts
642
693
  import * as fs from "fs";
643
694
  import * as path from "path";
695
+
696
+ // src/infra/state/persistence/atomic-write.ts
697
+ import { openSync, writeSync, fsyncSync, closeSync, renameSync, unlinkSync, mkdirSync, existsSync } from "fs";
698
+ import { dirname, join, basename } from "path";
699
+ function tempPathFor(filePath) {
700
+ tempCounter += 1;
701
+ return join(dirname(filePath), `.${basename(filePath)}.${process.pid}.${tempCounter}.tmp`);
702
+ }
703
+ var tempCounter = 0;
704
+ function atomicWriteFileSync(filePath, content) {
705
+ mkdirSync(dirname(filePath), { recursive: true });
706
+ const tmp = tempPathFor(filePath);
707
+ let fd = null;
708
+ try {
709
+ fd = openSync(tmp, "w");
710
+ writeSync(fd, content);
711
+ fsyncSync(fd);
712
+ closeSync(fd);
713
+ fd = null;
714
+ renameSync(tmp, filePath);
715
+ } catch (err) {
716
+ if (fd !== null) {
717
+ try {
718
+ closeSync(fd);
719
+ } catch {
720
+ }
721
+ }
722
+ try {
723
+ if (existsSync(tmp)) unlinkSync(tmp);
724
+ } catch {
725
+ }
726
+ throw err;
727
+ }
728
+ }
729
+
730
+ // src/infra/workspace-registry.ts
731
+ var REGISTRY_VERSION = 1;
732
+ var REGISTRY_FILENAME = "workspaces.json";
733
+ var FALLBACK_SLUG = "workspace";
734
+ var MAX_COLLISION_ATTEMPTS = 1e4;
735
+ function registryFilePath(wayangDir) {
736
+ return path.join(wayangDir, REGISTRY_FILENAME);
737
+ }
738
+ function normalizeWorkspacePath(rawPath) {
739
+ try {
740
+ return fs.realpathSync(rawPath);
741
+ } catch {
742
+ return path.resolve(rawPath);
743
+ }
744
+ }
745
+ function workspaceSlugFromPath(workspacePath) {
746
+ const basename10 = path.basename(workspacePath);
747
+ const slug = basename10.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
748
+ return slug.length > 0 ? slug : FALLBACK_SLUG;
749
+ }
750
+ function emptyRegistry() {
751
+ return { version: REGISTRY_VERSION, workspaces: {} };
752
+ }
753
+ function isPlainObject(value) {
754
+ return typeof value === "object" && value !== null && !Array.isArray(value);
755
+ }
756
+ var WorkspaceRegistryCorruptionError = class extends Error {
757
+ filePath;
758
+ backupPath;
759
+ constructor(message, filePath, backupPath) {
760
+ super(message);
761
+ this.name = "WorkspaceRegistryCorruptionError";
762
+ this.filePath = filePath;
763
+ this.backupPath = backupPath;
764
+ }
765
+ };
766
+ function plannedBackupPath(filePath) {
767
+ const primaryBackup = `${filePath}.corrupt`;
768
+ return fs.existsSync(primaryBackup) ? `${filePath}.${Date.now()}.corrupt` : primaryBackup;
769
+ }
770
+ function quarantineCorruptFile(filePath, detail) {
771
+ const backupPath = plannedBackupPath(filePath);
772
+ try {
773
+ fs.renameSync(filePath, backupPath);
774
+ } catch (renameErr) {
775
+ throw new WorkspaceRegistryCorruptionError(
776
+ `Workspace registry at ${filePath} is corrupt (${detail}) and could not be moved to ${backupPath}: ${renameErr.message}`,
777
+ filePath,
778
+ backupPath
779
+ );
780
+ }
781
+ return new WorkspaceRegistryCorruptionError(
782
+ `Workspace registry at ${filePath} is corrupt (${detail}); original moved to ${backupPath}`,
783
+ filePath,
784
+ backupPath
785
+ );
786
+ }
787
+ function reportCorruptFile(filePath, detail) {
788
+ const backupPath = plannedBackupPath(filePath);
789
+ return new WorkspaceRegistryCorruptionError(
790
+ `Workspace registry at ${filePath} is corrupt (${detail}); file left untouched (a later write will quarantine it to ${backupPath})`,
791
+ filePath,
792
+ backupPath
793
+ );
794
+ }
795
+ function unsupportedVersionError(filePath, found) {
796
+ return new WorkspaceRegistryCorruptionError(
797
+ `Workspace registry at ${filePath} has an unsupported version (${String(found)}); file left untouched`,
798
+ filePath
799
+ );
800
+ }
801
+ function corruptionError(mode, filePath, detail) {
802
+ return mode === "write" ? quarantineCorruptFile(filePath, detail) : reportCorruptFile(filePath, detail);
803
+ }
804
+ function parseRegistry(filePath, raw, mode) {
805
+ let parsed;
806
+ try {
807
+ parsed = JSON.parse(raw);
808
+ } catch (err) {
809
+ throw corruptionError(mode, filePath, `invalid JSON: ${err.message}`);
810
+ }
811
+ if (!isPlainObject(parsed)) {
812
+ throw corruptionError(mode, filePath, "top-level value is not an object");
813
+ }
814
+ if (parsed.version !== REGISTRY_VERSION) {
815
+ throw unsupportedVersionError(filePath, parsed.version);
816
+ }
817
+ if (!isPlainObject(parsed.workspaces)) {
818
+ throw corruptionError(mode, filePath, '"workspaces" is not an object');
819
+ }
820
+ const workspaces = {};
821
+ for (const [key, value] of Object.entries(parsed.workspaces)) {
822
+ if (!isPlainObject(value) || typeof value.slug !== "string" || value.slug.length === 0 || typeof value.path !== "string") {
823
+ throw corruptionError(mode, filePath, `malformed entry for ${JSON.stringify(key)}`);
824
+ }
825
+ workspaces[key] = {
826
+ slug: value.slug,
827
+ path: value.path,
828
+ firstSeenAt: typeof value.firstSeenAt === "string" ? value.firstSeenAt : "",
829
+ lastSeenAt: typeof value.lastSeenAt === "string" ? value.lastSeenAt : "",
830
+ reserved: value.reserved === true
831
+ };
832
+ }
833
+ return { version: REGISTRY_VERSION, workspaces };
834
+ }
835
+ function readRegistry(wayangDir, mode) {
836
+ const filePath = registryFilePath(wayangDir);
837
+ let raw;
838
+ try {
839
+ raw = fs.readFileSync(filePath, "utf-8");
840
+ } catch (err) {
841
+ if (err.code === "ENOENT") return emptyRegistry();
842
+ throw err;
843
+ }
844
+ return parseRegistry(filePath, raw, mode);
845
+ }
846
+ function writeRegistry(wayangDir, registry) {
847
+ atomicWriteFileSync(registryFilePath(wayangDir), `${JSON.stringify(registry, null, 2)}
848
+ `);
849
+ }
850
+ function allocateSlug(registry, base) {
851
+ const taken = new Set(Object.values(registry.workspaces).map((entry) => entry.slug));
852
+ if (!taken.has(base)) return base;
853
+ for (let suffix = 2; suffix <= MAX_COLLISION_ATTEMPTS; suffix += 1) {
854
+ const candidate = `${base}-${suffix}`;
855
+ if (!taken.has(candidate)) return candidate;
856
+ }
857
+ throw new Error(`Could not allocate a unique slug for "${base}" after ${MAX_COLLISION_ATTEMPTS} attempts`);
858
+ }
859
+ function getOrCreateWorkspace(wayangDir, rawPath) {
860
+ const resolvedPath = normalizeWorkspacePath(rawPath);
861
+ const registry = readRegistry(wayangDir, "write");
862
+ const now = (/* @__PURE__ */ new Date()).toISOString();
863
+ const existing = registry.workspaces[resolvedPath];
864
+ if (existing) {
865
+ existing.path = resolvedPath;
866
+ existing.lastSeenAt = now;
867
+ writeRegistry(wayangDir, registry);
868
+ return { path: resolvedPath, slug: existing.slug };
869
+ }
870
+ const slug = allocateSlug(registry, workspaceSlugFromPath(resolvedPath));
871
+ registry.workspaces[resolvedPath] = {
872
+ slug,
873
+ path: resolvedPath,
874
+ firstSeenAt: now,
875
+ lastSeenAt: now,
876
+ reserved: false
877
+ };
878
+ writeRegistry(wayangDir, registry);
879
+ return { path: resolvedPath, slug };
880
+ }
881
+ function resolveWorkspaceRef(wayangDir, rawPath) {
882
+ const resolvedPath = normalizeWorkspacePath(rawPath);
883
+ const fallbackSlug = workspaceSlugFromPath(resolvedPath);
884
+ if (!wayangDir) {
885
+ return { path: resolvedPath, slug: fallbackSlug, degraded: true };
886
+ }
887
+ try {
888
+ const registered = findWorkspace(wayangDir, resolvedPath);
889
+ if (registered) return { path: registered.path, slug: registered.slug };
890
+ return getOrCreateWorkspace(wayangDir, resolvedPath);
891
+ } catch (err) {
892
+ const corruption = err instanceof WorkspaceRegistryCorruptionError ? err : void 0;
893
+ const backupNote = corruption?.backupPath ? ` (backup path: ${corruption.backupPath})` : "";
894
+ console.error(
895
+ `[workspace-registry] resolveWorkspaceRef could not read the registry for ${resolvedPath}: ${err.message}${backupNote}; degrading to rule-based slug "${fallbackSlug}"`
896
+ );
897
+ return { path: resolvedPath, slug: fallbackSlug, degraded: true };
898
+ }
899
+ }
900
+ function findWorkspace(wayangDir, rawPath) {
901
+ const registry = readRegistry(wayangDir, "read");
902
+ const entry = registry.workspaces[normalizeWorkspacePath(rawPath)];
903
+ return entry ? { ...entry } : void 0;
904
+ }
905
+
906
+ // src/infra/session-index.ts
644
907
  function sortNewestFirst(entries) {
645
908
  return entries.sort((a, b) => b.meta.createdAt - a.meta.createdAt);
646
909
  }
910
+ function sessionWorkspacePaths(meta) {
911
+ if (meta.workspaces && meta.workspaces.length > 0) {
912
+ return meta.workspaces.map((w) => w.path).filter((p) => typeof p === "string" && p.length > 0);
913
+ }
914
+ return meta.workspace ? [meta.workspace] : [];
915
+ }
916
+ function matchesWorkspace(meta, normalizedTarget) {
917
+ return sessionWorkspacePaths(meta).some((p) => normalizeWorkspacePath(p) === normalizedTarget);
918
+ }
647
919
  function readEntriesFromDisk(homeDir2) {
648
- const sessionsDir = path.join(homeDir2, "sessions");
920
+ const sessionsDir = path2.join(homeDir2, "sessions");
649
921
  let names;
650
922
  try {
651
- names = fs.readdirSync(sessionsDir);
923
+ names = fs2.readdirSync(sessionsDir);
652
924
  } catch {
653
925
  return [];
654
926
  }
655
- const entries = [];
656
- for (const name of names) {
657
- const sessionDir = path.join(sessionsDir, name);
658
- const metaPath = path.join(sessionDir, "meta.json");
659
- try {
660
- const raw = fs.readFileSync(metaPath, "utf-8");
661
- const meta = JSON.parse(raw);
662
- if (!meta || typeof meta.sessionId !== "string") continue;
663
- entries.push({ sessionId: meta.sessionId, sessionDir, meta });
664
- } catch {
665
- }
927
+ const entries = [];
928
+ for (const name of names) {
929
+ const sessionDir = path2.join(sessionsDir, name);
930
+ const metaPath = path2.join(sessionDir, "meta.json");
931
+ try {
932
+ const raw = fs2.readFileSync(metaPath, "utf-8");
933
+ const meta = JSON.parse(raw);
934
+ if (!meta || typeof meta.sessionId !== "string") continue;
935
+ entries.push({ sessionId: meta.sessionId, sessionDir, meta });
936
+ } catch {
937
+ }
938
+ }
939
+ return sortNewestFirst(entries);
940
+ }
941
+ function buildCache(homeDir2) {
942
+ const entries = readEntriesFromDisk(homeDir2);
943
+ return {
944
+ entries,
945
+ byId: new Map(entries.map((e) => [e.sessionId, e]))
946
+ };
947
+ }
948
+ var InMemorySessionIndex = class {
949
+ constructor(homeDir2) {
950
+ this.homeDir = homeDir2;
951
+ }
952
+ homeDir;
953
+ cache = null;
954
+ ensureCache() {
955
+ if (!this.cache) {
956
+ this.cache = buildCache(this.homeDir);
957
+ }
958
+ return this.cache;
959
+ }
960
+ list(query2 = {}) {
961
+ const { entries } = this.ensureCache();
962
+ const includeArchived = query2.includeArchived ?? false;
963
+ let result2 = entries;
964
+ if (query2.workspace !== void 0) {
965
+ const target = normalizeWorkspacePath(query2.workspace);
966
+ result2 = result2.filter((e) => matchesWorkspace(e.meta, target));
967
+ }
968
+ if (!includeArchived) {
969
+ result2 = result2.filter((e) => !e.meta.archived);
970
+ }
971
+ if (query2.cursor) {
972
+ const idx = result2.findIndex((e) => e.sessionId === query2.cursor);
973
+ if (idx >= 0) {
974
+ result2 = result2.slice(idx + 1);
975
+ }
976
+ }
977
+ if (query2.limit !== void 0 && query2.limit >= 0) {
978
+ result2 = result2.slice(0, query2.limit);
979
+ }
980
+ return result2;
981
+ }
982
+ get(sessionId) {
983
+ return this.ensureCache().byId.get(sessionId) ?? null;
984
+ }
985
+ latestForWorkspace(workspace, opts = {}) {
986
+ const matches = this.list({ workspace, includeArchived: opts.includeArchived });
987
+ return matches[0] ?? null;
988
+ }
989
+ upsert(entry) {
990
+ const cache = this.ensureCache();
991
+ const existingIndex = cache.entries.findIndex((e) => e.sessionId === entry.sessionId);
992
+ if (existingIndex >= 0) {
993
+ cache.entries[existingIndex] = entry;
994
+ } else {
995
+ cache.entries.push(entry);
996
+ }
997
+ sortNewestFirst(cache.entries);
998
+ cache.byId.set(entry.sessionId, entry);
999
+ }
1000
+ invalidate() {
1001
+ this.cache = null;
1002
+ }
1003
+ };
1004
+ function createSessionIndex(homeDir2) {
1005
+ return new InMemorySessionIndex(homeDir2);
1006
+ }
1007
+
1008
+ // src/infra/session-helpers.ts
1009
+ function sessionDisplayTitle(meta) {
1010
+ if (meta.title && meta.title.trim().length > 0) return meta.title;
1011
+ return meta.firstInput;
1012
+ }
1013
+ var SESSIONS_DIRNAME = "sessions";
1014
+ function wayangDirForSessionDir(sessionDir) {
1015
+ const sessionsDir = path3.dirname(path3.resolve(sessionDir));
1016
+ return path3.basename(sessionsDir) === SESSIONS_DIRNAME ? path3.dirname(sessionsDir) : null;
1017
+ }
1018
+ function listSessions(homeDir2, query2 = {}) {
1019
+ return createSessionIndex(homeDir2).list(query2).map((e) => e.meta);
1020
+ }
1021
+ function getSession(homeDir2, sessionId) {
1022
+ return createSessionIndex(homeDir2).get(sessionId);
1023
+ }
1024
+ function getLatestSessionForWorkspace(homeDir2, workspaceDir2) {
1025
+ return createSessionIndex(homeDir2).latestForWorkspace(workspaceDir2);
1026
+ }
1027
+ function readSessionUnfinishedTasks(sessionDir) {
1028
+ const tasksPath = path3.join(sessionDir, "tasks.json");
1029
+ if (!fs3.existsSync(tasksPath)) return null;
1030
+ try {
1031
+ const data = JSON.parse(fs3.readFileSync(tasksPath, "utf-8"));
1032
+ const pending = data.tasks?.pending ?? [];
1033
+ const running = data.tasks?.running ?? [];
1034
+ const pick = (status) => (t) => ({
1035
+ id: t.id,
1036
+ title: t.title,
1037
+ description: t.description,
1038
+ status
1039
+ });
1040
+ const tagged = [
1041
+ ...running.filter(Boolean).map(pick("running")),
1042
+ ...pending.filter(Boolean).map(pick("pending"))
1043
+ ];
1044
+ return tagged.filter((t) => typeof t.id === "string" && typeof t.title === "string");
1045
+ } catch {
1046
+ return null;
1047
+ }
1048
+ }
1049
+
1050
+ // src/infra/session-lock.ts
1051
+ import * as fs4 from "fs";
1052
+ import * as os from "os";
1053
+ import * as path4 from "path";
1054
+ import { randomUUID } from "crypto";
1055
+ var SESSION_OWNER_FILE = ".owner.json";
1056
+ var DEFAULT_STALE_MS = 6e4;
1057
+ var DEFAULT_HEARTBEAT_MS = 2e4;
1058
+ var SESSION_LOCKED_MARKER = "[SESSION_LOCKED]";
1059
+ var SessionLockedError = class extends Error {
1060
+ holder;
1061
+ sessionDir;
1062
+ constructor(holder, sessionDir) {
1063
+ super(
1064
+ `${SESSION_LOCKED_MARKER} Session "${sessionDir}" is already in use by another wayang process (pid ${holder.pid} on ${holder.hostname}, holding since ${new Date(holder.startedAt).toISOString()})`
1065
+ );
1066
+ this.name = "SessionLockedError";
1067
+ this.holder = holder;
1068
+ this.sessionDir = sessionDir;
1069
+ }
1070
+ };
1071
+ function formatSessionLockHint(err) {
1072
+ return [
1073
+ err.message,
1074
+ "That process may still be working in this session; wait for it to finish and retry.",
1075
+ "To use a different session instead: wayang --resume",
1076
+ "To start a new session instead: wayang --new"
1077
+ ].join("\n");
1078
+ }
1079
+ var SessionLock = class {
1080
+ sessionDir;
1081
+ ownerFile;
1082
+ staleMs;
1083
+ heartbeatMs;
1084
+ hostname;
1085
+ pid;
1086
+ now;
1087
+ /** Currently held identity, or null when not holding. */
1088
+ owner = null;
1089
+ timer = null;
1090
+ constructor(sessionDir, options = {}) {
1091
+ this.sessionDir = sessionDir;
1092
+ this.ownerFile = path4.join(sessionDir, SESSION_OWNER_FILE);
1093
+ this.staleMs = options.staleMs ?? DEFAULT_STALE_MS;
1094
+ this.heartbeatMs = options.heartbeatMs ?? DEFAULT_HEARTBEAT_MS;
1095
+ this.hostname = options.hostname ?? os.hostname();
1096
+ this.pid = options.pid ?? process.pid;
1097
+ this.now = options.now ?? Date.now;
1098
+ }
1099
+ /** Whether this instance currently holds the lock. */
1100
+ get held() {
1101
+ return this.owner !== null;
1102
+ }
1103
+ /** Copy of the held identity (null when not holding). */
1104
+ get ownerInfo() {
1105
+ return this.owner ? { ...this.owner } : null;
1106
+ }
1107
+ /**
1108
+ * Take ownership of the session directory.
1109
+ *
1110
+ * @throws SessionLockedError when a live process already owns it.
1111
+ * @throws Error when the file system refuses the write for another reason.
1112
+ */
1113
+ acquire() {
1114
+ if (this.owner) {
1115
+ throw new Error(`SessionLock for ${this.sessionDir} is already held by this instance`);
1116
+ }
1117
+ const existing = this.readOwner();
1118
+ let staleTakeover = false;
1119
+ if (existing.kind === "owner") {
1120
+ if (!this.isStale(existing.owner)) {
1121
+ throw new SessionLockedError(existing.owner, this.sessionDir);
1122
+ }
1123
+ staleTakeover = true;
1124
+ } else if (existing.kind === "unreadable") {
1125
+ staleTakeover = true;
1126
+ }
1127
+ const owner = this.claim();
1128
+ this.owner = owner;
1129
+ this.startHeartbeat();
1130
+ return { owner: { ...owner }, staleTakeover };
1131
+ }
1132
+ /**
1133
+ * Refresh `heartbeatAt` on disk. Called by the heartbeat timer; exposed so
1134
+ * callers and tests can tick it explicitly.
1135
+ *
1136
+ * If the file on disk no longer carries our token, our lease was taken over
1137
+ * (e.g. this process was suspended past `staleMs`): stop writing and drop
1138
+ * ownership instead of clobbering the new owner's heartbeat.
1139
+ */
1140
+ heartbeat() {
1141
+ const owner = this.owner;
1142
+ if (!owner) return;
1143
+ const current = this.readOwner();
1144
+ if (current.kind !== "owner" || current.owner.token !== owner.token) {
1145
+ this.owner = null;
1146
+ this.stopHeartbeat();
1147
+ return;
1148
+ }
1149
+ const next = { ...owner, heartbeatAt: this.now() };
1150
+ this.writeOwner(next);
1151
+ this.owner = next;
1152
+ }
1153
+ /**
1154
+ * Drop the lock. Idempotent: safe to call repeatedly, and safe to call
1155
+ * without ever having acquired (a no-op).
1156
+ *
1157
+ * The file is deleted only when the token on disk is still ours, so a release
1158
+ * delayed past a takeover cannot delete the new owner's lock.
1159
+ */
1160
+ release() {
1161
+ this.stopHeartbeat();
1162
+ const owner = this.owner;
1163
+ if (!owner) return;
1164
+ this.owner = null;
1165
+ const current = this.readOwner();
1166
+ if (current.kind !== "owner" || current.owner.token !== owner.token) return;
1167
+ try {
1168
+ fs4.unlinkSync(this.ownerFile);
1169
+ } catch (err) {
1170
+ if (err.code === "ENOENT") return;
1171
+ throw err;
1172
+ }
1173
+ }
1174
+ // --- Internals ---
1175
+ /**
1176
+ * Write our owner document atomically and confirm it survived any concurrent
1177
+ * claimant. Two processes can both pass the "does not exist" check, so after
1178
+ * the rename each reads the file back: whoever reads a foreign token lost.
1179
+ */
1180
+ claim() {
1181
+ const at = this.now();
1182
+ const owner = {
1183
+ pid: this.pid,
1184
+ hostname: this.hostname,
1185
+ startedAt: at,
1186
+ heartbeatAt: at,
1187
+ token: randomUUID()
1188
+ };
1189
+ this.writeOwner(owner);
1190
+ const readback = this.readOwner();
1191
+ if (readback.kind !== "owner" || readback.owner.token !== owner.token) {
1192
+ if (readback.kind === "owner") {
1193
+ throw new SessionLockedError(readback.owner, this.sessionDir);
1194
+ }
1195
+ throw new Error(`Failed to claim session lock at ${this.ownerFile}`);
1196
+ }
1197
+ return owner;
1198
+ }
1199
+ /** Temp-file + rename, so readers never observe a partially written document. */
1200
+ writeOwner(owner) {
1201
+ fs4.mkdirSync(this.sessionDir, { recursive: true });
1202
+ const tmp = path4.join(this.sessionDir, `${SESSION_OWNER_FILE}.${owner.pid}.${randomUUID()}.tmp`);
1203
+ fs4.writeFileSync(tmp, `${JSON.stringify(owner, null, 2)}
1204
+ `, "utf-8");
1205
+ try {
1206
+ fs4.renameSync(tmp, this.ownerFile);
1207
+ } catch (err) {
1208
+ try {
1209
+ fs4.unlinkSync(tmp);
1210
+ } catch {
1211
+ }
1212
+ throw err;
1213
+ }
1214
+ }
1215
+ readOwner() {
1216
+ let raw;
1217
+ try {
1218
+ raw = fs4.readFileSync(this.ownerFile, "utf-8");
1219
+ } catch (err) {
1220
+ if (err.code === "ENOENT") return { kind: "missing" };
1221
+ throw err;
1222
+ }
1223
+ let parsed;
1224
+ try {
1225
+ parsed = JSON.parse(raw);
1226
+ } catch {
1227
+ return { kind: "unreadable" };
1228
+ }
1229
+ if (!isSessionOwner(parsed)) return { kind: "unreadable" };
1230
+ return { kind: "owner", owner: parsed };
1231
+ }
1232
+ /** See the zombie rules in the module docstring. */
1233
+ isStale(owner) {
1234
+ const heartbeatFresh = this.now() - owner.heartbeatAt < this.staleMs;
1235
+ if (owner.hostname !== this.hostname) {
1236
+ return !heartbeatFresh;
1237
+ }
1238
+ return !heartbeatFresh || !isProcessAlive(owner.pid);
1239
+ }
1240
+ startHeartbeat() {
1241
+ this.stopHeartbeat();
1242
+ const timer = setInterval(() => this.heartbeat(), this.heartbeatMs);
1243
+ timer.unref?.();
1244
+ this.timer = timer;
1245
+ }
1246
+ stopHeartbeat() {
1247
+ if (!this.timer) return;
1248
+ clearInterval(this.timer);
1249
+ this.timer = null;
1250
+ }
1251
+ };
1252
+ function isSessionOwner(value) {
1253
+ if (!value || typeof value !== "object") return false;
1254
+ const v = value;
1255
+ return typeof v.pid === "number" && Number.isInteger(v.pid) && v.pid > 0 && typeof v.hostname === "string" && v.hostname.length > 0 && typeof v.startedAt === "number" && Number.isFinite(v.startedAt) && typeof v.heartbeatAt === "number" && Number.isFinite(v.heartbeatAt) && typeof v.token === "string" && v.token.length > 0;
1256
+ }
1257
+ function isProcessAlive(pid) {
1258
+ try {
1259
+ process.kill(pid, 0);
1260
+ return true;
1261
+ } catch (err) {
1262
+ return err.code === "EPERM";
1263
+ }
1264
+ }
1265
+
1266
+ // src/services/session/session-manager.ts
1267
+ import * as path5 from "path";
1268
+
1269
+ // src/utils/id.ts
1270
+ function generateId(prefix) {
1271
+ return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
1272
+ }
1273
+ function formatTimestamp(date = /* @__PURE__ */ new Date()) {
1274
+ const Y = date.getFullYear();
1275
+ const M = String(date.getMonth() + 1).padStart(2, "0");
1276
+ const D = String(date.getDate()).padStart(2, "0");
1277
+ const h = String(date.getHours()).padStart(2, "0");
1278
+ const m = String(date.getMinutes()).padStart(2, "0");
1279
+ const s = String(date.getSeconds()).padStart(2, "0");
1280
+ return `${Y}${M}${D}-${h}${m}${s}`;
1281
+ }
1282
+
1283
+ // src/services/session/session-state.ts
1284
+ import { join as join7 } from "path";
1285
+
1286
+ // src/infra/state/base-state.ts
1287
+ import { get, set, cloneDeep } from "lodash-es";
1288
+
1289
+ // src/types/config.ts
1290
+ function validateConfig(config) {
1291
+ for (const [name, provider] of Object.entries(config.providers)) {
1292
+ if (!provider.endpoint) return `providers.${name}.endpoint is required`;
1293
+ if (!provider.apiKey) return `providers.${name}.apiKey is required (set WAYANG_LLM_API_KEY or configure in config file)`;
1294
+ if (!provider.modelName) return `providers.${name}.modelName is required`;
1295
+ const window = provider.contextWindowTokens;
1296
+ if (window !== void 0 && !(Number.isInteger(window) && window > 0)) {
1297
+ return `providers.${name}.contextWindowTokens must be a positive integer (got ${JSON.stringify(window)})`;
1298
+ }
1299
+ }
1300
+ const cp = config.controller.provider;
1301
+ if (!config.providers[cp]) return `controller.provider "${cp}" not found`;
1302
+ const wp = config.worker.provider;
1303
+ if (!config.providers[wp]) return `worker.provider "${wp}" not found`;
1304
+ const permission = config.permission;
1305
+ if (permission !== void 0 && permission !== null) {
1306
+ const keys = ["allowedDirs", "autoAllowRoots", "deniedDirs"];
1307
+ for (const key of keys) {
1308
+ const value = permission[key];
1309
+ if (value === void 0) continue;
1310
+ if (!Array.isArray(value) || value.some((entry) => typeof entry !== "string")) {
1311
+ return `permission.${key} must be an array of strings (got ${JSON.stringify(value)})`;
1312
+ }
1313
+ }
1314
+ }
1315
+ return null;
1316
+ }
1317
+
1318
+ // src/types/conversation.ts
1319
+ function isCompactMarker(entry) {
1320
+ return entry.type === "system" /* System */ && entry.subtype === "compact" /* Compact */ && entry.compactMeta !== void 0;
1321
+ }
1322
+
1323
+ // src/types/signal.ts
1324
+ function getSignalField(sig, field) {
1325
+ return sig.payload[field];
1326
+ }
1327
+
1328
+ // src/types/inquiry.ts
1329
+ var INQUIRE_OTHER_OPTION = "\u5176\u4ED6\uFF08\u81EA\u884C\u8F93\u5165\uFF09";
1330
+ var INQUIRE_CUSTOM_ANSWER_PREFIX = "[Other] ";
1331
+ function buildSelectChoices(options, allowOther) {
1332
+ return allowOther === false ? options : [...options, INQUIRE_OTHER_OPTION];
1333
+ }
1334
+ function isOtherChoice(choice) {
1335
+ return choice === INQUIRE_OTHER_OPTION;
1336
+ }
1337
+ function formatCustomAnswer(text) {
1338
+ return `${INQUIRE_CUSTOM_ANSWER_PREFIX}${text}`;
1339
+ }
1340
+
1341
+ // src/infra/state/conversation-envelope.ts
1342
+ var ENVELOPE_VERSION = 1;
1343
+ function applyAppendEnvelope(entry, conversation) {
1344
+ if (entry === null || typeof entry !== "object") return entry;
1345
+ if (entry.v === void 0) entry.v = ENVELOPE_VERSION;
1346
+ if (entry.parentId === void 0) {
1347
+ const last = conversation.length > 0 ? conversation[conversation.length - 1] : void 0;
1348
+ const lastIsMarker = last !== null && typeof last === "object" && isCompactMarker(last);
1349
+ entry.parentId = !last || lastIsMarker || isCompactMarker(entry) || typeof last.uuid !== "string" ? null : last.uuid;
1350
+ }
1351
+ return entry;
1352
+ }
1353
+
1354
+ // src/infra/state/base-state.ts
1355
+ var BaseWayangState = class {
1356
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- state-tree root is intentionally untyped at this layer; typed reads happen via get<T>() at call sites (changing the default cascades across every bare get() in the repo)
1357
+ data;
1358
+ persistMappings;
1359
+ subscriptions = /* @__PURE__ */ new Map();
1360
+ constructor(initialData, persistMappings = []) {
1361
+ this.data = initialData;
1362
+ this.persistMappings = persistMappings;
1363
+ }
1364
+ // --- Read ---
1365
+ // 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);
1368
+ }
1369
+ // --- 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);
1375
+ }
1376
+ // 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);
1380
+ if (target && typeof target === "object") {
1381
+ Object.assign(target, partial);
1382
+ } else {
1383
+ set(this.data, path9, partial);
1384
+ }
1385
+ this.persist(path9, "update");
1386
+ this.notify(path9, "update", prev);
1387
+ }
1388
+ append(path9, entry) {
1389
+ const arr = get(this.data, path9);
1390
+ if (!Array.isArray(arr)) {
1391
+ throw new Error(`State.append: path "${path9}" is not an array`);
1392
+ }
1393
+ const stamped = path9 === "conversation" ? applyAppendEnvelope(entry, arr) : entry;
1394
+ arr.push(stamped);
1395
+ set(this.data, path9, [...arr]);
1396
+ this.persist(path9, "append", stamped);
1397
+ this.notify(path9, "append", void 0);
1398
+ }
1399
+ remove(path9, idx) {
1400
+ const arr = get(this.data, path9);
1401
+ if (!Array.isArray(arr)) {
1402
+ throw new Error(`State.remove: path "${path9}" is not an array`);
1403
+ }
1404
+ const prev = idx !== void 0 ? arr[idx] : arr[arr.length - 1];
1405
+ if (idx === void 0) {
1406
+ arr.pop();
1407
+ } else {
1408
+ arr.splice(idx, 1);
1409
+ }
1410
+ this.persist(path9, "remove");
1411
+ this.notify(path9, "remove", prev);
1412
+ }
1413
+ // --- Subscription ---
1414
+ on(path9, callback) {
1415
+ if (!this.subscriptions.has(path9)) {
1416
+ this.subscriptions.set(path9, /* @__PURE__ */ new Set());
1417
+ }
1418
+ this.subscriptions.get(path9).add(callback);
1419
+ return () => this.subscriptions.get(path9)?.delete(callback);
1420
+ }
1421
+ // --- Persistence ---
1422
+ /**
1423
+ * Hand the change to whichever helper owns this field, with enough context
1424
+ * for the helper to pick its own strategy. Snapshot helpers replace the
1425
+ * file; append helpers write only the new entry and ignore window edits.
1426
+ */
1427
+ persist(path9, op, entry) {
1428
+ const topKey = path9.split(".")[0];
1429
+ for (const mapping of this.persistMappings) {
1430
+ if (mapping.path !== topKey) continue;
1431
+ mapping.helper.write({
1432
+ path: topKey,
1433
+ op,
1434
+ value: this.data[topKey],
1435
+ entry
1436
+ });
1437
+ }
1438
+ }
1439
+ // --- Notification ---
1440
+ notify(changePath, type, prev) {
1441
+ const event = { type, path: changePath, data: get(this.data, changePath), prev };
1442
+ for (const [subPath, callbacks] of this.subscriptions) {
1443
+ if (changePath === subPath) {
1444
+ for (const cb of callbacks) cb(event);
1445
+ continue;
1446
+ }
1447
+ if (changePath.startsWith(subPath + ".")) {
1448
+ for (const cb of callbacks) {
1449
+ cb({ ...event, data: get(this.data, subPath) });
1450
+ }
1451
+ continue;
1452
+ }
1453
+ if (subPath.startsWith(changePath + ".")) {
1454
+ const newVal = get(this.data, subPath);
1455
+ for (const cb of callbacks) {
1456
+ cb({ ...event, data: newVal });
1457
+ }
1458
+ }
1459
+ }
1460
+ }
1461
+ };
1462
+
1463
+ // src/infra/state/persistence/json-file.ts
1464
+ import {
1465
+ readFileSync as readFileSync5,
1466
+ existsSync as existsSync4,
1467
+ mkdirSync as mkdirSync3,
1468
+ renameSync as renameSync4,
1469
+ openSync as openSync2,
1470
+ writeSync as writeSync2,
1471
+ fsyncSync as fsyncSync2,
1472
+ closeSync as closeSync2,
1473
+ unlinkSync as unlinkSync3,
1474
+ copyFileSync
1475
+ } from "fs";
1476
+ import { dirname as dirname3, basename as basename4, join as join6 } from "path";
1477
+ var tempSeq = 0;
1478
+ var JSONFileHelper = class {
1479
+ constructor(filePath) {
1480
+ this.filePath = filePath;
1481
+ }
1482
+ filePath;
1483
+ mode = "save";
1484
+ /** Absolute path of the backing file. */
1485
+ get path() {
1486
+ return this.filePath;
1487
+ }
1488
+ /** Whether the backing file exists, regardless of parseability. */
1489
+ exists() {
1490
+ return existsSync4(this.filePath);
1491
+ }
1492
+ write(change) {
1493
+ this.writeValue(change.value);
1494
+ }
1495
+ /** Write an arbitrary value atomically. Used by migration paths. */
1496
+ writeValue(value) {
1497
+ mkdirSync3(dirname3(this.filePath), { recursive: true });
1498
+ const tmp = this.tempPath();
1499
+ let fd = null;
1500
+ try {
1501
+ fd = openSync2(tmp, "w");
1502
+ writeSync2(fd, JSON.stringify(value, null, 2));
1503
+ fsyncSync2(fd);
1504
+ closeSync2(fd);
1505
+ fd = null;
1506
+ renameSync4(tmp, this.filePath);
1507
+ } catch (err) {
1508
+ if (fd !== null) {
1509
+ try {
1510
+ closeSync2(fd);
1511
+ } catch {
1512
+ }
1513
+ }
1514
+ try {
1515
+ if (existsSync4(tmp)) unlinkSync3(tmp);
1516
+ } catch {
1517
+ }
1518
+ throw err;
1519
+ }
1520
+ }
1521
+ readonly() {
1522
+ if (!existsSync4(this.filePath)) return null;
1523
+ let raw;
1524
+ try {
1525
+ raw = readFileSync5(this.filePath, "utf-8");
1526
+ } catch (err) {
1527
+ throw new Error(
1528
+ `Failed to read snapshot "${this.filePath}": ${err.message}`
1529
+ );
1530
+ }
1531
+ if (raw.trim().length === 0) return null;
1532
+ try {
1533
+ return JSON.parse(raw);
1534
+ } catch (err) {
1535
+ const quarantine = this.quarantineCorrupt();
1536
+ throw new Error(
1537
+ `Corrupt snapshot "${this.filePath}" (${err.message}). The damaged file was moved to "${quarantine}". Refusing to treat it as empty state.`
1538
+ );
1539
+ }
1540
+ }
1541
+ read() {
1542
+ return this.readonly();
1543
+ }
1544
+ /**
1545
+ * Read without throwing on corruption — returns null instead.
1546
+ * Only for callers that can tolerate absence (e.g. optional side files).
1547
+ * State-critical reads must use readonly()/read().
1548
+ */
1549
+ raw() {
1550
+ if (!existsSync4(this.filePath)) return null;
1551
+ const text = readFileSync5(this.filePath, "utf-8");
1552
+ if (text.trim().length === 0) return null;
1553
+ try {
1554
+ return JSON.parse(text);
1555
+ } catch {
1556
+ return null;
1557
+ }
1558
+ }
1559
+ clear() {
1560
+ mkdirSync3(dirname3(this.filePath), { recursive: true });
1561
+ this.writeValue(null);
1562
+ }
1563
+ tempPath() {
1564
+ tempSeq += 1;
1565
+ return join6(dirname3(this.filePath), `.${basename4(this.filePath)}.${process.pid}.${tempSeq}.tmp`);
1566
+ }
1567
+ quarantineCorrupt() {
1568
+ const target = `${this.filePath}.corrupt.${Date.now()}`;
1569
+ try {
1570
+ copyFileSync(this.filePath, target);
1571
+ } catch {
1572
+ }
1573
+ return target;
1574
+ }
1575
+ };
1576
+
1577
+ // src/services/session/session-state.ts
1578
+ var SessionState = class extends BaseWayangState {
1579
+ jsonFile;
1580
+ constructor(sessionDir) {
1581
+ const jsonFile = new JSONFileHelper(join7(sessionDir, "meta.json"));
1582
+ super(
1583
+ {
1584
+ meta: {
1585
+ sessionId: "",
1586
+ workspace: "",
1587
+ firstInput: null,
1588
+ createdAt: 0,
1589
+ lastActiveAt: 0,
1590
+ title: void 0,
1591
+ archived: false,
1592
+ archivedAt: void 0
1593
+ }
1594
+ },
1595
+ [{ path: "meta", helper: jsonFile }]
1596
+ );
1597
+ this.jsonFile = jsonFile;
1598
+ }
1599
+ async restore() {
1600
+ const data = this.jsonFile.read();
1601
+ if (data) {
1602
+ this.data.meta = data;
1603
+ }
1604
+ }
1605
+ /**
1606
+ * Attach a synthesized `workspaces` entry to the in-memory meta only — no
1607
+ * write happens here, so reading a legacy session never rewrites its file.
1608
+ * The value reaches disk with the next normal meta write (input, rename,
1609
+ * archive), because snapshot persistence serializes the whole meta object.
1610
+ */
1611
+ fillWorkspacesInMemory(workspace) {
1612
+ const meta = this.data.meta;
1613
+ if (!meta) return;
1614
+ meta.workspaces = [workspace];
1615
+ }
1616
+ };
1617
+
1618
+ // src/services/session/session-manager.ts
1619
+ var SessionManager = class _SessionManager {
1620
+ sessionId;
1621
+ sessionDir;
1622
+ isResume;
1623
+ /** Wayang home dir (parent of `sessions/`), or null for ad-hoc session dirs. */
1624
+ wayangDir;
1625
+ state;
1626
+ constructor(sessionId, sessionDir, isResume, wayangDir) {
1627
+ this.sessionId = sessionId;
1628
+ this.sessionDir = sessionDir;
1629
+ this.isResume = isResume;
1630
+ this.wayangDir = wayangDir;
1631
+ this.state = new SessionState(sessionDir);
1632
+ }
1633
+ /** Create a new session: generates ID, initializes meta via WayangState (auto mkdir + write). */
1634
+ static create(homeDir2, workspaceDir2) {
1635
+ const sessionId = `${formatTimestamp()}-${Math.random().toString(36).slice(2, 6)}`;
1636
+ const sessionDir = path5.join(homeDir2, "sessions", sessionId);
1637
+ const mgr = new _SessionManager(sessionId, sessionDir, false, homeDir2);
1638
+ mgr.state.set("meta", {
1639
+ sessionId,
1640
+ workspace: workspaceDir2,
1641
+ workspaces: [resolveWorkspaceRef(homeDir2, workspaceDir2)],
1642
+ firstInput: null,
1643
+ createdAt: Date.now(),
1644
+ lastActiveAt: Date.now()
1645
+ });
1646
+ return mgr;
1647
+ }
1648
+ /**
1649
+ * Resume an existing session. State is restored in restore().
1650
+ *
1651
+ * @param wayangDir Wayang home dir, used to resolve workspace slugs. Omitted
1652
+ * callers fall back to the layout-derived parent of `sessions/`.
1653
+ */
1654
+ static resume(sessionId, sessionDir, wayangDir) {
1655
+ return new _SessionManager(sessionId, sessionDir, true, wayangDir ?? wayangDirForSessionDir(sessionDir));
666
1656
  }
667
- return sortNewestFirst(entries);
668
- }
669
- function buildCache(homeDir2) {
670
- const entries = readEntriesFromDisk(homeDir2);
671
- return {
672
- entries,
673
- byId: new Map(entries.map((e) => [e.sessionId, e]))
674
- };
675
- }
676
- var InMemorySessionIndex = class {
677
- constructor(homeDir2) {
678
- this.homeDir = homeDir2;
1657
+ /** Restore state from disk (resume only, no-op for new sessions). */
1658
+ async restore() {
1659
+ if (!this.isResume) return;
1660
+ await this.state.restore();
1661
+ this.backfillWorkspaces();
679
1662
  }
680
- homeDir;
681
- cache = null;
682
- ensureCache() {
683
- if (!this.cache) {
684
- this.cache = buildCache(this.homeDir);
685
- }
686
- return this.cache;
1663
+ /** Workspace directory of this session. */
1664
+ get workspace() {
1665
+ return this.state.get("meta.workspace");
687
1666
  }
688
- list(query2 = {}) {
689
- const { entries } = this.ensureCache();
690
- const includeArchived = query2.includeArchived ?? false;
691
- let result2 = entries;
692
- if (query2.workspace !== void 0) {
693
- result2 = result2.filter((e) => e.meta.workspace === query2.workspace);
694
- }
695
- if (!includeArchived) {
696
- result2 = result2.filter((e) => !e.meta.archived);
697
- }
698
- if (query2.cursor) {
699
- const idx = result2.findIndex((e) => e.sessionId === query2.cursor);
700
- if (idx >= 0) {
701
- result2 = result2.slice(idx + 1);
702
- }
1667
+ /**
1668
+ * Workspace identities of this session, always including the one matching
1669
+ * `workspace`. Legacy meta (written before `workspaces` existed) is
1670
+ * synthesized on read — see `backfillWorkspaces`.
1671
+ */
1672
+ get workspaces() {
1673
+ this.backfillWorkspaces();
1674
+ return this.state.get("meta.workspaces") ?? [];
1675
+ }
1676
+ /**
1677
+ * Workspace this session was *started* in — the backfill target for records
1678
+ * that predate workspace attribution (e.g. legacy tasks.json entries).
1679
+ *
1680
+ * Deliberately not `workspaces[0]`: once a session accumulates more than one
1681
+ * workspace, the first array element is merely "the oldest one we happen to
1682
+ * have", not necessarily the startup workspace, and stamping old tasks with
1683
+ * it would point them at the wrong directory. `meta.workspace` stays
1684
+ * authoritative for "where this session was started" (see SessionMeta), so we
1685
+ * look up the entry that matches it after canonicalization — the array's
1686
+ * `path` is canonical, while `meta.workspace` keeps the original spelling
1687
+ * (symlinks, trailing slash).
1688
+ *
1689
+ * Falls back to the first entry when nothing matches (the startup directory
1690
+ * was renamed or removed since), and returns undefined when the session has
1691
+ * no workspace identity at all — callers then simply skip the backfill.
1692
+ */
1693
+ get startupWorkspace() {
1694
+ const workspaces = this.workspaces;
1695
+ if (workspaces.length === 0) return void 0;
1696
+ const current = this.state.get("meta.workspace");
1697
+ if (current) {
1698
+ const normalized = normalizeWorkspacePath(current);
1699
+ const match = workspaces.find((ws) => normalizeWorkspacePath(ws.path) === normalized);
1700
+ if (match) return match;
703
1701
  }
704
- if (query2.limit !== void 0 && query2.limit >= 0) {
705
- result2 = result2.slice(0, query2.limit);
1702
+ return workspaces[0];
1703
+ }
1704
+ /**
1705
+ * Record a workspace this session has touched at runtime.
1706
+ *
1707
+ * Called when a task is accepted for a workspace other than the one the
1708
+ * session was started in, so the session stays discoverable through every
1709
+ * directory it dispatched work into — `getLatestSessionForWorkspace`, the
1710
+ * interactive session picker and the TUI's "first + N" banner all read
1711
+ * `meta.workspaces`. Without this, dispatching into a second workspace left
1712
+ * the array frozen at creation time and those readers blind to it.
1713
+ *
1714
+ * Idempotent by canonical path: re-declaring an already-recorded directory
1715
+ * is a no-op (no write, slug untouched). The `slug` always comes from the
1716
+ * caller's registry-resolved ref, so one directory keeps one handle for the
1717
+ * lifetime of the session. `meta.workspace` (the startup directory) is never
1718
+ * rewritten here.
1719
+ *
1720
+ * Persists through the normal meta write path — a whole-meta snapshot — so
1721
+ * the array lands together with every other meta field, exactly like an
1722
+ * ordinary input/rename/archive write. Returns true when a new entry was
1723
+ * appended (and the write happened).
1724
+ */
1725
+ addWorkspace(workspace) {
1726
+ const canonical = normalizeWorkspacePath(workspace.path);
1727
+ const existing = this.workspaces;
1728
+ if (existing.some((w) => normalizeWorkspacePath(w.path) === canonical)) {
1729
+ return false;
706
1730
  }
707
- return result2;
1731
+ this.state.set("meta.workspaces", [...existing, { path: canonical, slug: workspace.slug }]);
1732
+ return true;
708
1733
  }
709
- get(sessionId) {
710
- return this.ensureCache().byId.get(sessionId) ?? null;
1734
+ /**
1735
+ * Synthesize `meta.workspaces` from the single-valued `meta.workspace` when a
1736
+ * session predates the array. Memory only: nothing is written, so neither
1737
+ * resume nor `workspaces` ever rewrites a historical meta.json.
1738
+ *
1739
+ * Idempotent — a filled array short-circuits, and `resolveWorkspaceRef`
1740
+ * returns the same slug for the same canonical path, so repeated reads of one
1741
+ * legacy session yield an identical result.
1742
+ */
1743
+ backfillWorkspaces() {
1744
+ const existing = this.state.get("meta.workspaces");
1745
+ if (existing && existing.length > 0) return;
1746
+ const workspace = this.state.get("meta.workspace");
1747
+ if (!workspace) return;
1748
+ this.state.fillWorkspacesInMemory(resolveWorkspaceRef(this.wayangDir, workspace));
711
1749
  }
712
- latestForWorkspace(workspace, opts = {}) {
713
- const matches = this.list({ workspace, includeArchived: opts.includeArchived });
714
- return matches[0] ?? null;
1750
+ /** Whether firstInput has already been recorded. */
1751
+ get hasFirstInput() {
1752
+ return !!this.state.get("meta.firstInput");
715
1753
  }
716
- upsert(entry) {
717
- const cache = this.ensureCache();
718
- const existingIndex = cache.entries.findIndex((e) => e.sessionId === entry.sessionId);
719
- if (existingIndex >= 0) {
720
- cache.entries[existingIndex] = entry;
721
- } else {
722
- cache.entries.push(entry);
1754
+ /**
1755
+ * Notify that a user input occurred.
1756
+ * Records firstInput (idempotent) and updates lastActiveAt.
1757
+ */
1758
+ onUserInput(text) {
1759
+ if (!this.hasFirstInput) {
1760
+ this.state.set("meta.firstInput", text);
723
1761
  }
724
- sortNewestFirst(cache.entries);
725
- cache.byId.set(entry.sessionId, entry);
1762
+ this.state.set("meta.lastActiveAt", Date.now());
726
1763
  }
727
- invalidate() {
728
- this.cache = null;
1764
+ /** Current display label: explicit title, else first input. */
1765
+ get displayTitle() {
1766
+ return sessionDisplayTitle(this.state.get("meta"));
729
1767
  }
730
- };
731
- function createSessionIndex(homeDir2) {
732
- return new InMemorySessionIndex(homeDir2);
733
- }
734
-
735
- // src/infra/session-helpers.ts
736
- function sessionDisplayTitle(meta) {
737
- if (meta.title && meta.title.trim().length > 0) return meta.title;
738
- return meta.firstInput;
739
- }
740
- function listSessions(homeDir2, query2 = {}) {
741
- return createSessionIndex(homeDir2).list(query2).map((e) => e.meta);
742
- }
743
- function getSession(homeDir2, sessionId) {
744
- return createSessionIndex(homeDir2).get(sessionId);
745
- }
746
- function getLatestSessionForWorkspace(homeDir2, workspaceDir2) {
747
- return createSessionIndex(homeDir2).latestForWorkspace(workspaceDir2);
748
- }
749
- function readSessionUnfinishedTasks(sessionDir) {
750
- const tasksPath = path2.join(sessionDir, "tasks.json");
751
- if (!fs2.existsSync(tasksPath)) return null;
752
- try {
753
- const data = JSON.parse(fs2.readFileSync(tasksPath, "utf-8"));
754
- const pending = data.tasks?.pending ?? [];
755
- const running = data.tasks?.running ?? [];
756
- const pick = (status) => (t) => ({
757
- id: t.id,
758
- title: t.title,
759
- description: t.description,
760
- status
761
- });
762
- const tagged = [
763
- ...running.filter(Boolean).map(pick("running")),
764
- ...pending.filter(Boolean).map(pick("pending"))
765
- ];
766
- return tagged.filter((t) => typeof t.id === "string" && typeof t.title === "string");
767
- } catch {
768
- return null;
1768
+ /** Whether this session is archived (hidden from the default list). */
1769
+ get archived() {
1770
+ return this.state.get("meta.archived") ?? false;
769
1771
  }
770
- }
1772
+ /**
1773
+ * Rename the session. Passing null/empty clears the explicit title, so the
1774
+ * session falls back to showing its first input.
1775
+ */
1776
+ rename(title) {
1777
+ const trimmed = title?.trim();
1778
+ this.state.set("meta.title", trimmed && trimmed.length > 0 ? trimmed : void 0);
1779
+ }
1780
+ /**
1781
+ * Toggle the archived flag. Archiving hides the session from the default
1782
+ * list without deleting its transcript; unarchiving restores visibility.
1783
+ */
1784
+ setArchived(archived) {
1785
+ this.state.set("meta.archived", archived);
1786
+ this.state.set("meta.archivedAt", archived ? Date.now() : void 0);
1787
+ }
1788
+ };
771
1789
 
772
1790
  // src/tui/render.ts
773
1791
  import React6 from "react";
@@ -1019,6 +2037,18 @@ function truncateToWidth(s, maxWidth) {
1019
2037
  }
1020
2038
  return out + "\u2026";
1021
2039
  }
2040
+ function sliceToWidth(s, maxWidth) {
2041
+ if (maxWidth <= 0) return "";
2042
+ let w = 0;
2043
+ let out = "";
2044
+ for (const ch of s) {
2045
+ const cw = charWidth(ch.codePointAt(0) ?? 0);
2046
+ if (w + cw > maxWidth) break;
2047
+ out += ch;
2048
+ w += cw;
2049
+ }
2050
+ return out;
2051
+ }
1022
2052
  function collapseWhitespace(s) {
1023
2053
  return s.replace(/\s+/g, " ").trim();
1024
2054
  }
@@ -1113,27 +2143,27 @@ function toolTarget(toolName, args, budget) {
1113
2143
  return fitWithSuffix(lines[0].trim(), note, budget);
1114
2144
  }
1115
2145
  case "write_file": {
1116
- const path7 = argString(args, ["path", "file_path"]);
1117
- if (!path7) return "";
2146
+ const path9 = argString(args, ["path", "file_path"]);
2147
+ if (!path9) return "";
1118
2148
  const lines = lineCount(args.content);
1119
2149
  const note = lines > 0 ? ` (+${lines})` : "";
1120
- return fitWithSuffix(shortenPath(path7), note, budget);
2150
+ return fitWithSuffix(shortenPath(path9), note, budget);
1121
2151
  }
1122
2152
  case "edit_file": {
1123
- const path7 = argString(args, ["path", "file_path"]);
1124
- if (!path7) return "";
2153
+ const path9 = argString(args, ["path", "file_path"]);
2154
+ if (!path9) return "";
1125
2155
  const oldLines = lineCount(args.old_string);
1126
2156
  const newLines = lineCount(args.new_string);
1127
2157
  const note = oldLines > 0 || newLines > 0 ? ` (${oldLines}\u2192${newLines} \u884C)` : "";
1128
- return fitWithSuffix(shortenPath(path7), note, budget);
2158
+ return fitWithSuffix(shortenPath(path9), note, budget);
1129
2159
  }
1130
2160
  case "read_file": {
1131
- const path7 = argString(args, ["path", "file_path"]);
1132
- if (!path7) return "";
2161
+ const path9 = argString(args, ["path", "file_path"]);
2162
+ if (!path9) return "";
1133
2163
  const offset = argNumber(args, ["offset"]);
1134
2164
  const limit = argNumber(args, ["limit"]);
1135
2165
  const range = offset !== void 0 ? `:${offset}${limit !== void 0 ? `+${limit}` : ""}` : "";
1136
- return truncateToWidth(shortenPath(path7) + range, budget);
2166
+ return truncateToWidth(shortenPath(path9) + range, budget);
1137
2167
  }
1138
2168
  case "search_files":
1139
2169
  return truncateToWidth(argString(args, ["pattern"]) ?? "", budget);
@@ -1603,13 +2633,6 @@ function AddTaskRow({ section: section2, result: result2, lookups }) {
1603
2633
 
1604
2634
  // src/tui/components/chat-area/signal-row.tsx
1605
2635
  import { Box as Box3, Text as Text3 } from "ink";
1606
-
1607
- // src/types/conversation.ts
1608
- function isCompactMarker(entry) {
1609
- return entry.type === "system" /* System */ && entry.subtype === "compact" /* Compact */ && entry.compactMeta !== void 0;
1610
- }
1611
-
1612
- // src/tui/components/chat-area/signal-row.tsx
1613
2636
  import { jsx as jsx4, jsxs as jsxs3 } from "react/jsx-runtime";
1614
2637
  var MAX_CONTENT_LINES = 2;
1615
2638
  function trimContent(text, maxLines) {
@@ -1952,6 +2975,25 @@ function renderShortcutList() {
1952
2975
  return lines.join("\n");
1953
2976
  }
1954
2977
 
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}`;
2989
+ }
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;
2995
+ }
2996
+
1955
2997
  // src/tui/hooks/use-slash-commands.ts
1956
2998
  var SLASH_COMMANDS = [
1957
2999
  { name: "tasks", args: "[status]", description: "List tasks" },
@@ -1976,9 +3018,11 @@ function useSlashCommands(supervisor) {
1976
3018
  const status = parts[1];
1977
3019
  const tasks = await supervisor.engine.list(status);
1978
3020
  if (tasks.length === 0) return { handled: true, output: "No tasks" };
1979
- const lines = tasks.map(
1980
- (t) => `[${t.status}] ${t.id}: ${t.description}${t.result ? ` \u2192 ${t.result}` : ""}`
1981
- );
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
+ });
1982
3026
  return { handled: true, output: lines.join("\n") };
1983
3027
  }
1984
3028
  case "workers": {
@@ -2549,81 +3593,43 @@ function InputArea({ onSubmit, onExit, onEscape, busy, width }) {
2549
3593
  cmd.description
2550
3594
  ] })
2551
3595
  ] }, cmd.name);
2552
- }) }),
2553
- /* @__PURE__ */ jsx9(
2554
- Box6,
2555
- {
2556
- borderStyle: "single",
2557
- borderColor: "gray",
2558
- borderLeft: false,
2559
- borderRight: false,
2560
- paddingX: 1,
2561
- children: /* @__PURE__ */ jsx9(Box6, { flexDirection: "column", children: layout.lines.map((line, i) => {
2562
- const isCursorLine = i === layout.cursorLine;
2563
- const col = layout.cursorCol;
2564
- const charLen = isCursorLine ? nextCodePointLength(line.text, col) : 0;
2565
- const before = isCursorLine ? line.text.slice(0, col) : line.text;
2566
- const cursorCell = isCursorLine && charLen > 0 ? line.text.slice(col, col + charLen) : " ";
2567
- const after = isCursorLine ? line.text.slice(col + charLen) : "";
2568
- return /* @__PURE__ */ jsxs6(Box6, { children: [
2569
- i === 0 ? /* @__PURE__ */ jsx9(Text7, { color: theme.user.prefixColor, bold: true, children: theme.user.prefix }) : /* @__PURE__ */ jsx9(Text7, { children: " ".repeat(PREFIX_WIDTH2) }),
2570
- visibleSelection ? /* @__PURE__ */ jsxs6(Fragment3, { children: [
2571
- i === 0 && /* @__PURE__ */ jsx9(Text7, { children: " " }),
2572
- splitLineBySelection(line, sel).map((seg, j) => /* @__PURE__ */ jsx9(Text7, { inverse: seg.selected, children: seg.text }, j))
2573
- ] }) : /* @__PURE__ */ jsxs6(Fragment3, { children: [
2574
- /* @__PURE__ */ jsx9(Text7, { children: i === 0 ? ` ${before}` : before }),
2575
- isCursorLine && /* @__PURE__ */ jsx9(Text7, { color: "white", inverse: true, children: cursorCell }),
2576
- /* @__PURE__ */ jsx9(Text7, { children: after })
2577
- ] }),
2578
- isCursorLine && busy && /* @__PURE__ */ jsx9(Text7, { dimColor: true, children: " (thinking...)" })
2579
- ] }, i);
2580
- }) })
2581
- }
2582
- )
2583
- ] });
2584
- }
2585
-
2586
- // src/tui/components/inquiry-prompt.tsx
2587
- import React4, { useState as useState3 } from "react";
2588
- import { Box as Box7, Text as Text8, useInput as useInput2 } from "ink";
2589
-
2590
- // src/types/config.ts
2591
- function validateConfig(config) {
2592
- for (const [name, provider] of Object.entries(config.providers)) {
2593
- if (!provider.endpoint) return `providers.${name}.endpoint is required`;
2594
- if (!provider.apiKey) return `providers.${name}.apiKey is required (set WAYANG_LLM_API_KEY or configure in config file)`;
2595
- if (!provider.modelName) return `providers.${name}.modelName is required`;
2596
- const window = provider.contextWindowTokens;
2597
- if (window !== void 0 && !(Number.isInteger(window) && window > 0)) {
2598
- return `providers.${name}.contextWindowTokens must be a positive integer (got ${JSON.stringify(window)})`;
2599
- }
2600
- }
2601
- const cp = config.controller.provider;
2602
- if (!config.providers[cp]) return `controller.provider "${cp}" not found`;
2603
- const wp = config.worker.provider;
2604
- if (!config.providers[wp]) return `worker.provider "${wp}" not found`;
2605
- return null;
2606
- }
2607
-
2608
- // src/types/signal.ts
2609
- function getSignalField(sig, field) {
2610
- return sig.payload[field];
2611
- }
2612
-
2613
- // src/types/inquiry.ts
2614
- var INQUIRE_OTHER_OPTION = "\u5176\u4ED6\uFF08\u81EA\u884C\u8F93\u5165\uFF09";
2615
- var INQUIRE_CUSTOM_ANSWER_PREFIX = "[Other] ";
2616
- function buildSelectChoices(options, allowOther) {
2617
- return allowOther === false ? options : [...options, INQUIRE_OTHER_OPTION];
2618
- }
2619
- function isOtherChoice(choice) {
2620
- return choice === INQUIRE_OTHER_OPTION;
2621
- }
2622
- function formatCustomAnswer(text) {
2623
- return `${INQUIRE_CUSTOM_ANSWER_PREFIX}${text}`;
3596
+ }) }),
3597
+ /* @__PURE__ */ jsx9(
3598
+ Box6,
3599
+ {
3600
+ borderStyle: "single",
3601
+ borderColor: "gray",
3602
+ borderLeft: false,
3603
+ borderRight: false,
3604
+ paddingX: 1,
3605
+ children: /* @__PURE__ */ jsx9(Box6, { flexDirection: "column", children: layout.lines.map((line, i) => {
3606
+ const isCursorLine = i === layout.cursorLine;
3607
+ const col = layout.cursorCol;
3608
+ const charLen = isCursorLine ? nextCodePointLength(line.text, col) : 0;
3609
+ const before = isCursorLine ? line.text.slice(0, col) : line.text;
3610
+ const cursorCell = isCursorLine && charLen > 0 ? line.text.slice(col, col + charLen) : " ";
3611
+ const after = isCursorLine ? line.text.slice(col + charLen) : "";
3612
+ return /* @__PURE__ */ jsxs6(Box6, { children: [
3613
+ i === 0 ? /* @__PURE__ */ jsx9(Text7, { color: theme.user.prefixColor, bold: true, children: theme.user.prefix }) : /* @__PURE__ */ jsx9(Text7, { children: " ".repeat(PREFIX_WIDTH2) }),
3614
+ visibleSelection ? /* @__PURE__ */ jsxs6(Fragment3, { children: [
3615
+ i === 0 && /* @__PURE__ */ jsx9(Text7, { children: " " }),
3616
+ splitLineBySelection(line, sel).map((seg, j) => /* @__PURE__ */ jsx9(Text7, { inverse: seg.selected, children: seg.text }, j))
3617
+ ] }) : /* @__PURE__ */ jsxs6(Fragment3, { children: [
3618
+ /* @__PURE__ */ jsx9(Text7, { children: i === 0 ? ` ${before}` : before }),
3619
+ isCursorLine && /* @__PURE__ */ jsx9(Text7, { color: "white", inverse: true, children: cursorCell }),
3620
+ /* @__PURE__ */ jsx9(Text7, { children: after })
3621
+ ] }),
3622
+ isCursorLine && busy && /* @__PURE__ */ jsx9(Text7, { dimColor: true, children: " (thinking...)" })
3623
+ ] }, i);
3624
+ }) })
3625
+ }
3626
+ )
3627
+ ] });
2624
3628
  }
2625
3629
 
2626
3630
  // src/tui/components/inquiry-prompt.tsx
3631
+ import React4, { useState as useState3 } from "react";
3632
+ import { Box as Box7, Text as Text8, useInput as useInput2 } from "ink";
2627
3633
  import { jsx as jsx10, jsxs as jsxs7 } from "react/jsx-runtime";
2628
3634
  function InquiryPrompt({ inquiry, onAnswer }) {
2629
3635
  if (inquiry.type === "confirm") {
@@ -2762,21 +3768,23 @@ import { Box as Box8, Text as Text9, useStdout } from "ink";
2762
3768
 
2763
3769
  // src/tui/hooks/use-wayang-state.ts
2764
3770
  import { useState as useState4, useEffect as useEffect3 } from "react";
2765
- function useWayangState(source, path7) {
2766
- const [value, setValue] = useState4(() => source.getSnapshot(path7));
3771
+ function useWayangState(source, path9) {
3772
+ const [value, setValue] = useState4(() => source.getSnapshot(path9));
2767
3773
  useEffect3(() => {
2768
- const unSub = source.subscribe(path7, () => {
2769
- setValue(source.getSnapshot(path7));
3774
+ const unSub = source.subscribe(path9, () => {
3775
+ setValue(source.getSnapshot(path9));
2770
3776
  });
2771
- setValue(source.getSnapshot(path7));
3777
+ setValue(source.getSnapshot(path9));
2772
3778
  return unSub;
2773
- }, [source, path7]);
3779
+ }, [source, path9]);
2774
3780
  return value;
2775
3781
  }
2776
3782
 
2777
3783
  // src/tui/components/status-bar.tsx
2778
3784
  import { jsx as jsx11, jsxs as jsxs8 } from "react/jsx-runtime";
2779
3785
  var CONTEXT_SEGMENT_MAX_WIDTH = 22;
3786
+ var LABEL_MAX_WIDTH = 20;
3787
+ var ROW_SEPARATOR = " \xB7 ";
2780
3788
  function formatContextUsage(usedTokens, contextWindowTokens) {
2781
3789
  const percent = contextWindowTokens > 0 ? Math.round(usedTokens / contextWindowTokens * 100) : 0;
2782
3790
  const text = `${formatTokenCount(usedTokens)}/${formatTokenCount(contextWindowTokens)} (${percent}%)`;
@@ -2827,28 +3835,48 @@ function buildWorkerRows(activeWorkers, runningTasks, now) {
2827
3835
  return {
2828
3836
  workerId: w.workerId,
2829
3837
  taskId: w.taskId,
2830
- label: text ? text.slice(0, 20) : `${w.workerType || "worker"} ${idTail(w.taskId)}`,
3838
+ // Cap by DISPLAY columns, not code units: a 20-code-unit Chinese title is
3839
+ // 40 columns wide and would blow the row budget before fitting even runs.
3840
+ label: text ? sliceToWidth(text, LABEL_MAX_WIDTH) : `${w.workerType || "worker"} ${idTail(w.taskId)}`,
2831
3841
  elapsed,
2832
- idle
3842
+ idle,
3843
+ workspaceSlug: formatWorkspaceSlug(task?.workspace) ?? void 0
2833
3844
  };
2834
3845
  });
2835
3846
  disambiguateRowLabels(rows);
2836
3847
  return rows;
2837
3848
  }
3849
+ function rowSuffix(row) {
3850
+ return ` (${row.idle ? "idle " : ""}${formatElapsedSeconds(row.elapsed)})`;
3851
+ }
3852
+ function workerRowText(row, withSlug) {
3853
+ const slug = withSlug && row.workspaceSlug ? ` [${row.workspaceSlug}]` : "";
3854
+ return `${row.label}${slug}${rowSuffix(row)}`;
3855
+ }
2838
3856
  function fitWorkerRows(rows, budget) {
2839
3857
  if (budget <= 0) return [];
2840
3858
  const fitted = [];
2841
3859
  let used = 0;
2842
3860
  for (const row of rows) {
2843
- const text = `${row.label} (${row.idle ? "idle " : ""}${formatElapsedSeconds(row.elapsed)})`;
2844
- const sep = fitted.length > 0 ? 3 : 0;
2845
- if (used + sep + text.length <= budget) {
2846
- fitted.push(text);
2847
- used += sep + text.length;
3861
+ const sep2 = fitted.length > 0 ? stringWidth(ROW_SEPARATOR) : 0;
3862
+ const full = workerRowText(row, true);
3863
+ const fullWidth = stringWidth(full);
3864
+ if (used + sep2 + fullWidth <= budget) {
3865
+ fitted.push(full);
3866
+ used += sep2 + fullWidth;
2848
3867
  continue;
2849
3868
  }
2850
- const remaining = budget - used - sep;
2851
- if (remaining > 0) fitted.push(text.slice(0, remaining));
3869
+ const compact = workerRowText(row, false);
3870
+ const compactWidth = stringWidth(compact);
3871
+ if (used + sep2 + compactWidth <= budget) {
3872
+ fitted.push(compact);
3873
+ used += sep2 + compactWidth;
3874
+ continue;
3875
+ }
3876
+ const remaining = budget - used - sep2;
3877
+ if (remaining > 0) {
3878
+ fitted.push(fitWithSuffix(row.label, rowSuffix(row), remaining));
3879
+ }
2852
3880
  break;
2853
3881
  }
2854
3882
  return fitted;
@@ -2860,8 +3888,8 @@ function buildStatusSegments(input2) {
2860
3888
  if (activeCount > 0) parts.push(`active workers:${activeCount}`);
2861
3889
  const countsText = parts.length > 0 ? parts.join(" \u2502 ") : "idle";
2862
3890
  const contextText = contextUsage ? formatContextUsage(contextUsage.usedTokens, contextUsage.contextWindowTokens) : "";
2863
- const contextWidth = contextText ? contextText.length + 3 : 0;
2864
- const workerBudget = Math.max(0, terminalCols - 4 - countsText.length - contextWidth);
3891
+ const contextWidth = contextText ? stringWidth(contextText) + 3 : 0;
3892
+ const workerBudget = Math.max(0, terminalCols - 4 - stringWidth(countsText) - contextWidth);
2865
3893
  return {
2866
3894
  countsText,
2867
3895
  workerTexts: fitWorkerRows(workerRows, workerBudget),
@@ -2983,6 +4011,8 @@ var signalSubtypeMap = {
2983
4011
  completed: "worker_completed" /* WorkerCompleted */,
2984
4012
  failed: "worker_failed" /* WorkerFailed */,
2985
4013
  progress: "worker_progress" /* WorkerProgress */,
4014
+ task_queued: "task_queued" /* TaskQueued */,
4015
+ worker_started: "worker_started" /* WorkerStarted */,
2986
4016
  heartbeat: "heartbeat" /* Heartbeat */,
2987
4017
  cancelled: "worker_failed" /* WorkerFailed */,
2988
4018
  task_cancelled: "task_cancelled" /* TaskCancelled */,
@@ -3238,7 +4268,7 @@ function AppContent() {
3238
4268
  // package.json
3239
4269
  var package_default = {
3240
4270
  name: "wayang-ai",
3241
- version: "0.7.2",
4271
+ version: "0.8.0",
3242
4272
  description: "Multi-agent orchestration platform",
3243
4273
  type: "module",
3244
4274
  license: "MIT",
@@ -3323,7 +4353,11 @@ var logo3d = `
3323
4353
  \u255A\u2550\u2550\u255D\u255A\u2550\u2550\u255D \u255A\u2550\u255D \u255A\u2550\u255D \u255A\u2550\u255D \u255A\u2550\u255D \u255A\u2550\u255D\u255A\u2550\u255D \u255A\u2550\u2550\u2550\u255D \u255A\u2550\u2550\u2550\u2550\u2550\u255D
3324
4354
  `;
3325
4355
  var pkgVersion = package_default.version;
3326
- function writeWelcome(workspace) {
4356
+ function formatSessionWorkspaceLabel(workspace, workspaces) {
4357
+ if (!workspaces || workspaces.length <= 1) return workspace;
4358
+ return formatWorkspaceSummary(workspaces) ?? workspace;
4359
+ }
4360
+ function writeWelcome(workspace, workspaces) {
3327
4361
  const GREEN = "\x1B[32m";
3328
4362
  const BOLD = "\x1B[1m";
3329
4363
  const DIM = "\x1B[2m";
@@ -3333,16 +4367,16 @@ function writeWelcome(workspace) {
3333
4367
  process.stdout.write("\n");
3334
4368
  process.stdout.write(` Welcome to play with ${BOLD}Wayang${RESET}
3335
4369
  `);
3336
- process.stdout.write(` ${DIM}${pkgVersion} \xB7 ${workspace}${RESET}
4370
+ process.stdout.write(` ${DIM}${pkgVersion} \xB7 ${formatSessionWorkspaceLabel(workspace, workspaces)}${RESET}
3337
4371
  `);
3338
4372
  process.stdout.write("\n");
3339
4373
  }
3340
4374
 
3341
4375
  // src/tui/render.ts
3342
4376
  function renderInkUI(supervisor) {
3343
- return new Promise((resolve12) => {
4377
+ return new Promise((resolve13) => {
3344
4378
  process.stdout.write("\x1B[2J\x1B[H");
3345
- writeWelcome(supervisor.ctx.workspaceDir);
4379
+ writeWelcome(supervisor.ctx.workspaceDir, supervisor.ctx.workspaces);
3346
4380
  const { waitUntilExit } = render(
3347
4381
  React6.createElement(App, { supervisor }),
3348
4382
  // Ctrl+C ownership belongs to AppContent's useInput handler. Ctrl+C
@@ -3352,13 +4386,13 @@ function renderInkUI(supervisor) {
3352
4386
  // app-level handler ever sees the key.
3353
4387
  { exitOnCtrlC: false }
3354
4388
  );
3355
- waitUntilExit().then(() => resolve12());
4389
+ waitUntilExit().then(() => resolve13());
3356
4390
  });
3357
4391
  }
3358
4392
 
3359
4393
  // src/onboard.ts
3360
- import * as fs3 from "fs";
3361
- import * as path3 from "path";
4394
+ import * as fs5 from "fs";
4395
+ import * as path6 from "path";
3362
4396
  import * as childProcess from "child_process";
3363
4397
  import prompts from "prompts";
3364
4398
  function fillApiKeys(config) {
@@ -3479,9 +4513,9 @@ async function buildConfigInteractive() {
3479
4513
  async function loadConfig(configPath2, options = {}) {
3480
4514
  const { nonInteractive = false } = options;
3481
4515
  let config;
3482
- if (fs3.existsSync(configPath2)) {
4516
+ if (fs5.existsSync(configPath2)) {
3483
4517
  try {
3484
- config = JSON.parse(fs3.readFileSync(configPath2, "utf-8"));
4518
+ config = JSON.parse(fs5.readFileSync(configPath2, "utf-8"));
3485
4519
  } catch (err2) {
3486
4520
  if (nonInteractive) throw new Error(`Invalid config JSON at ${configPath2}: ${err2 instanceof Error ? err2.message : String(err2)}`);
3487
4521
  throw err2;
@@ -3510,8 +4544,8 @@ async function loadConfig(configPath2, options = {}) {
3510
4544
  config = await buildConfigInteractive();
3511
4545
  }
3512
4546
  if (!nonInteractive) {
3513
- fs3.mkdirSync(path3.dirname(configPath2), { recursive: true });
3514
- fs3.writeFileSync(configPath2, JSON.stringify(config, null, 2));
4547
+ fs5.mkdirSync(path6.dirname(configPath2), { recursive: true });
4548
+ fs5.writeFileSync(configPath2, JSON.stringify(config, null, 2));
3515
4549
  }
3516
4550
  }
3517
4551
  fillApiKeys(config);
@@ -3527,12 +4561,46 @@ async function loadConfig(configPath2, options = {}) {
3527
4561
 
3528
4562
  // src/session-select.ts
3529
4563
  import prompts2 from "prompts";
3530
- function formatSessionChoice(meta, showWorkspace) {
4564
+ var MINUTE_MS = 6e4;
4565
+ var HOUR_MS = 60 * MINUTE_MS;
4566
+ var DAY_MS = 24 * HOUR_MS;
4567
+ function sessionWorkspaceRefs(meta) {
4568
+ if (meta.workspaces && meta.workspaces.length > 0) {
4569
+ return meta.workspaces.filter((w) => typeof w.path === "string" && w.path.length > 0);
4570
+ }
4571
+ return meta.workspace ? [{ path: meta.workspace }] : [];
4572
+ }
4573
+ function workspaceHandle(ref) {
4574
+ return ref.slug && ref.slug.length > 0 ? ref.slug : workspaceSlugFromPath(ref.path);
4575
+ }
4576
+ function formatSessionWorkspaces(meta) {
4577
+ const refs = sessionWorkspaceRefs(meta);
4578
+ if (refs.length === 0) return "(no workspace)";
4579
+ const first = workspaceHandle(refs[0]);
4580
+ return refs.length > 1 ? `${first} (+${refs.length - 1})` : first;
4581
+ }
4582
+ function formatRelativeLastActive(lastActiveAt, now = Date.now()) {
4583
+ const elapsed = Math.max(0, now - lastActiveAt);
4584
+ if (elapsed < MINUTE_MS) return "just now";
4585
+ if (elapsed < HOUR_MS) return `${Math.floor(elapsed / MINUTE_MS)}m ago`;
4586
+ if (elapsed < DAY_MS) return `${Math.floor(elapsed / HOUR_MS)}h ago`;
4587
+ const days = Math.floor(elapsed / DAY_MS);
4588
+ if (days < 30) return `${days}d ago`;
4589
+ const months = Math.floor(days / 30);
4590
+ if (months < 12) return `${months}mo ago`;
4591
+ return `${Math.floor(months / 12)}y ago`;
4592
+ }
4593
+ function formatSessionChoice(meta, now = Date.now()) {
3531
4594
  const date = new Date(meta.createdAt).toLocaleString();
3532
4595
  const display = sessionDisplayTitle(meta);
3533
4596
  const label = display ? `"${display}"` : "(no input yet)";
3534
- const ws = showWorkspace ? ` [${meta.workspace}]` : "";
3535
- return `${meta.sessionId} ${date} ${label}${ws}`;
4597
+ const workspaces = formatSessionWorkspaces(meta);
4598
+ const lastActive = formatRelativeLastActive(meta.lastActiveAt, now);
4599
+ return `${meta.sessionId} ${date} ${label} [${workspaces}] active ${lastActive}`;
4600
+ }
4601
+ function isWorkspaceMismatch(meta, workspaceDir2) {
4602
+ const target = normalizeWorkspacePath(workspaceDir2);
4603
+ return !sessionWorkspacePaths(meta).some((p) => normalizeWorkspacePath(p) === target);
3536
4604
  }
3537
4605
  async function selectSessionInteractive(homeDir2, workspaceDir2, showAll) {
3538
4606
  const sessions = listSessions(homeDir2, showAll ? {} : { workspace: workspaceDir2 });
@@ -3546,7 +4614,7 @@ async function selectSessionInteractive(homeDir2, workspaceDir2, showAll) {
3546
4614
  name: "selected",
3547
4615
  message: "Select a session to resume",
3548
4616
  choices: sessions.map((m) => ({
3549
- title: formatSessionChoice(m, showAll),
4617
+ title: formatSessionChoice(m),
3550
4618
  value: m.sessionId
3551
4619
  }))
3552
4620
  });
@@ -3558,11 +4626,11 @@ async function selectSessionInteractive(homeDir2, workspaceDir2, showAll) {
3558
4626
  console.error(`Error: Session "${selected}" not found.`);
3559
4627
  process.exit(1);
3560
4628
  }
3561
- if (result2.meta.workspace !== workspaceDir2) {
4629
+ if (isWorkspaceMismatch(result2.meta, workspaceDir2)) {
3562
4630
  const { confirm } = await prompts2({
3563
4631
  type: "confirm",
3564
4632
  name: "confirm",
3565
- message: `Session workspace is ${result2.meta.workspace}, current dir is ${workspaceDir2}. Switch?`,
4633
+ message: `Session workspace is ${formatSessionWorkspaces(result2.meta)}, current dir is ${workspaceDir2}. Switch?`,
3566
4634
  initial: true
3567
4635
  });
3568
4636
  if (!confirm) {
@@ -3577,40 +4645,86 @@ function die(msg) {
3577
4645
  console.error(`Error: ${msg}`);
3578
4646
  process.exit(1);
3579
4647
  }
4648
+ async function connectToServer(options) {
4649
+ try {
4650
+ return await connectRemoteServer(options);
4651
+ } catch (err) {
4652
+ const message = err instanceof Error ? err.message : String(err);
4653
+ const markerAt = message.indexOf(SESSION_LOCKED_MARKER);
4654
+ if (markerAt === -1) throw err;
4655
+ die(message.slice(markerAt).trim());
4656
+ }
4657
+ }
4658
+ async function resolveStartupWorkspace(sessionId, sessionDir, wayangHome) {
4659
+ const manager = SessionManager.resume(sessionId, sessionDir, wayangHome);
4660
+ await manager.restore();
4661
+ return manager.startupWorkspace;
4662
+ }
4663
+ function assertRecoverableWorkspace(resolved, sessionId) {
4664
+ if (fs6.existsSync(resolved.path)) return;
4665
+ die(
4666
+ `Cannot resume session "${sessionId}": its startup workspace ${resolved.path} no longer exists (the directory was moved or deleted). Resume a different session with --resume (or --new to start a new one).`
4667
+ );
4668
+ }
4669
+ function dedupeDisplayWorkspaces(refs) {
4670
+ const seen = /* @__PURE__ */ new Set();
4671
+ const result2 = [];
4672
+ for (const ref of refs) {
4673
+ const normalized = normalizeWorkspacePath(ref.path);
4674
+ if (seen.has(normalized)) continue;
4675
+ seen.add(normalized);
4676
+ result2.push({ ...ref, path: normalized });
4677
+ }
4678
+ return result2;
4679
+ }
3580
4680
  async function bootstrap(options) {
3581
- const wayangHome = path4.join(options.homeDir, ".wayang");
4681
+ const wayangHome = path7.join(options.homeDir, ".wayang");
3582
4682
  await loadConfig(options.configPath);
3583
4683
  let session = "new";
3584
4684
  let injectPreviousTasks = false;
3585
4685
  let workspaceDir2 = options.workspaceDir;
3586
- if (options.fresh) {
4686
+ let sessionWorkspaces = [];
4687
+ if (options.new) {
3587
4688
  } else if (options.resume === void 0) {
3588
4689
  const latest = getLatestSessionForWorkspace(wayangHome, options.workspaceDir);
3589
4690
  if (latest) {
3590
4691
  session = { resume: latest.sessionId };
3591
4692
  injectPreviousTasks = true;
4693
+ sessionWorkspaces = latest.meta.workspaces ?? [];
3592
4694
  console.log(` Resuming session ${latest.sessionId}`);
3593
4695
  }
3594
4696
  } else if (options.resume === "") {
3595
4697
  const result2 = await selectSessionInteractive(wayangHome, options.workspaceDir, options.showAll ?? false);
3596
- workspaceDir2 = result2.meta.workspace;
4698
+ const startup = await resolveStartupWorkspace(result2.sessionId, result2.sessionDir, wayangHome);
4699
+ if (startup) assertRecoverableWorkspace(startup, result2.sessionId);
4700
+ workspaceDir2 = startup?.path ?? (result2.meta.workspace || workspaceDir2);
3597
4701
  session = { resume: result2.sessionId };
4702
+ sessionWorkspaces = result2.meta.workspaces ?? [];
3598
4703
  console.log(` Resuming session ${result2.sessionId}`);
3599
4704
  } else {
3600
4705
  const existing = getSession(wayangHome, options.resume);
3601
4706
  if (!existing) {
3602
4707
  die(`Session "${options.resume}" not found.`);
3603
4708
  }
3604
- workspaceDir2 = existing.meta.workspace;
4709
+ const startup = await resolveStartupWorkspace(existing.sessionId, existing.sessionDir, wayangHome);
4710
+ if (startup) assertRecoverableWorkspace(startup, existing.sessionId);
4711
+ workspaceDir2 = startup?.path ?? (existing.meta.workspace || workspaceDir2);
3605
4712
  session = { resume: existing.sessionId };
4713
+ sessionWorkspaces = existing.meta.workspaces ?? [];
3606
4714
  console.log(` Resuming session ${existing.sessionId}`);
3607
4715
  }
3608
- const { remote, stop } = await connectRemoteServer({
4716
+ workspaceDir2 = normalizeWorkspacePath(workspaceDir2);
4717
+ sessionWorkspaces = dedupeDisplayWorkspaces(sessionWorkspaces);
4718
+ if (sessionWorkspaces.length === 0) {
4719
+ sessionWorkspaces = [resolveWorkspaceRef(wayangHome, workspaceDir2)];
4720
+ }
4721
+ const { remote, stop } = await connectToServer({
3609
4722
  configPath: options.configPath,
3610
4723
  // Pass the ROOT dir: the server's `--home-dir` flag expects it and joins
3611
4724
  // `.wayang` itself. Passing the pre-joined dir caused a double join.
3612
4725
  homeDir: options.homeDir,
3613
4726
  workspaceDir: workspaceDir2,
4727
+ workspaces: sessionWorkspaces,
3614
4728
  session,
3615
4729
  injectPreviousTasks,
3616
4730
  logLevel: options.logLevel
@@ -3625,14 +4739,14 @@ async function bootstrap(options) {
3625
4739
  // src/infra/logger.ts
3626
4740
  import pino from "pino";
3627
4741
  function closeTransport(transport) {
3628
- return new Promise((resolve12) => {
4742
+ return new Promise((resolve13) => {
3629
4743
  transport.on("error", () => {
3630
4744
  });
3631
4745
  if (transport.closed || transport.destroyed) {
3632
- resolve12();
4746
+ resolve13();
3633
4747
  return;
3634
4748
  }
3635
- transport.once("close", () => resolve12());
4749
+ transport.once("close", () => resolve13());
3636
4750
  try {
3637
4751
  transport.end();
3638
4752
  } catch {
@@ -3692,297 +4806,58 @@ var SystemContext = class {
3692
4806
  sessionId;
3693
4807
  sessionDir;
3694
4808
  workspaceDir;
3695
- startedAt;
3696
- logLevel;
3697
- logger;
3698
- abortController;
3699
- config;
3700
- hooks;
3701
- /** The log sink this context owns; `logger` above is a child of it. */
3702
- logSink;
3703
- closeLoggerPromise;
3704
- constructor(config, sessionId, sessionDir, workspaceDir2, logLevel) {
3705
- this.config = config;
3706
- this.sessionId = sessionId;
3707
- this.sessionDir = sessionDir;
3708
- this.workspaceDir = workspaceDir2;
3709
- this.startedAt = Date.now();
3710
- this.logLevel = logLevel ?? "info";
3711
- this.abortController = new AbortController();
3712
- this.hooks = new LifecycleHooks();
3713
- this.logSink = createFileLogger(this.logLevel, `${sessionDir}/wayang.log`);
3714
- this.logger = this.logSink.logger.child({
3715
- session: sessionId
3716
- });
3717
- }
3718
- /**
3719
- * Flush and close the log sink, ending its transport worker thread.
3720
- *
3721
- * Once the returned promise resolves, this context's logger will never
3722
- * touch the session directory again: the log file is fully flushed and
3723
- * the worker thread has exited. Idempotent — repeated calls return the
3724
- * same promise — and log calls made afterwards are safely ignored.
3725
- */
3726
- closeLogger() {
3727
- this.closeLoggerPromise ??= this.logSink.close();
3728
- return this.closeLoggerPromise;
3729
- }
3730
- get controllerProvider() {
3731
- return this.config.providers[this.config.controller.provider];
3732
- }
3733
- get workerProvider() {
3734
- return this.config.providers[this.config.worker.provider];
3735
- }
3736
- get maxConcurrency() {
3737
- return this.config.worker.maxConcurrency;
3738
- }
3739
- };
3740
-
3741
- // src/services/signal/signal-state.ts
3742
- import { join as join5 } from "path";
3743
-
3744
- // src/infra/state/base-state.ts
3745
- import { get, set, cloneDeep } from "lodash-es";
3746
-
3747
- // src/infra/state/conversation-envelope.ts
3748
- var ENVELOPE_VERSION = 1;
3749
- function applyAppendEnvelope(entry, conversation) {
3750
- if (entry === null || typeof entry !== "object") return entry;
3751
- if (entry.v === void 0) entry.v = ENVELOPE_VERSION;
3752
- if (entry.parentId === void 0) {
3753
- const last = conversation.length > 0 ? conversation[conversation.length - 1] : void 0;
3754
- const lastIsMarker = last !== null && typeof last === "object" && isCompactMarker(last);
3755
- entry.parentId = !last || lastIsMarker || isCompactMarker(entry) || typeof last.uuid !== "string" ? null : last.uuid;
3756
- }
3757
- return entry;
3758
- }
3759
-
3760
- // src/infra/state/base-state.ts
3761
- var BaseWayangState = class {
3762
- // eslint-disable-next-line @typescript-eslint/no-explicit-any -- state-tree root is intentionally untyped at this layer; typed reads happen via get<T>() at call sites (changing the default cascades across every bare get() in the repo)
3763
- data;
3764
- persistMappings;
3765
- subscriptions = /* @__PURE__ */ new Map();
3766
- constructor(initialData, persistMappings = []) {
3767
- this.data = initialData;
3768
- this.persistMappings = persistMappings;
3769
- }
3770
- // --- Read ---
3771
- // 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
3772
- get(path7) {
3773
- return get(this.data, path7);
3774
- }
3775
- // --- Write operations ---
3776
- set(path7, value) {
3777
- const prev = get(this.data, path7);
3778
- set(this.data, path7, value);
3779
- this.persist(path7, "set");
3780
- this.notify(path7, "set", prev);
3781
- }
3782
- // eslint-disable-next-line @typescript-eslint/no-explicit-any -- partial update merges an untyped object into the state tree root (see `data`)
3783
- update(path7, partial) {
3784
- const prev = cloneDeep(get(this.data, path7));
3785
- const target = get(this.data, path7);
3786
- if (target && typeof target === "object") {
3787
- Object.assign(target, partial);
3788
- } else {
3789
- set(this.data, path7, partial);
3790
- }
3791
- this.persist(path7, "update");
3792
- this.notify(path7, "update", prev);
3793
- }
3794
- append(path7, entry) {
3795
- const arr = get(this.data, path7);
3796
- if (!Array.isArray(arr)) {
3797
- throw new Error(`State.append: path "${path7}" is not an array`);
3798
- }
3799
- const stamped = path7 === "conversation" ? applyAppendEnvelope(entry, arr) : entry;
3800
- arr.push(stamped);
3801
- set(this.data, path7, [...arr]);
3802
- this.persist(path7, "append", stamped);
3803
- this.notify(path7, "append", void 0);
3804
- }
3805
- remove(path7, idx) {
3806
- const arr = get(this.data, path7);
3807
- if (!Array.isArray(arr)) {
3808
- throw new Error(`State.remove: path "${path7}" is not an array`);
3809
- }
3810
- const prev = idx !== void 0 ? arr[idx] : arr[arr.length - 1];
3811
- if (idx === void 0) {
3812
- arr.pop();
3813
- } else {
3814
- arr.splice(idx, 1);
3815
- }
3816
- this.persist(path7, "remove");
3817
- this.notify(path7, "remove", prev);
3818
- }
3819
- // --- Subscription ---
3820
- on(path7, callback) {
3821
- if (!this.subscriptions.has(path7)) {
3822
- this.subscriptions.set(path7, /* @__PURE__ */ new Set());
3823
- }
3824
- this.subscriptions.get(path7).add(callback);
3825
- return () => this.subscriptions.get(path7)?.delete(callback);
3826
- }
3827
- // --- Persistence ---
3828
- /**
3829
- * Hand the change to whichever helper owns this field, with enough context
3830
- * for the helper to pick its own strategy. Snapshot helpers replace the
3831
- * file; append helpers write only the new entry and ignore window edits.
3832
- */
3833
- persist(path7, op, entry) {
3834
- const topKey = path7.split(".")[0];
3835
- for (const mapping of this.persistMappings) {
3836
- if (mapping.path !== topKey) continue;
3837
- mapping.helper.write({
3838
- path: topKey,
3839
- op,
3840
- value: this.data[topKey],
3841
- entry
3842
- });
3843
- }
3844
- }
3845
- // --- Notification ---
3846
- notify(changePath, type, prev) {
3847
- const event = { type, path: changePath, data: get(this.data, changePath), prev };
3848
- for (const [subPath, callbacks] of this.subscriptions) {
3849
- if (changePath === subPath) {
3850
- for (const cb of callbacks) cb(event);
3851
- continue;
3852
- }
3853
- if (changePath.startsWith(subPath + ".")) {
3854
- for (const cb of callbacks) {
3855
- cb({ ...event, data: get(this.data, subPath) });
3856
- }
3857
- continue;
3858
- }
3859
- if (subPath.startsWith(changePath + ".")) {
3860
- const newVal = get(this.data, subPath);
3861
- for (const cb of callbacks) {
3862
- cb({ ...event, data: newVal });
3863
- }
3864
- }
3865
- }
3866
- }
3867
- };
3868
-
3869
- // src/infra/state/persistence/json-file.ts
3870
- import {
3871
- readFileSync as readFileSync4,
3872
- existsSync as existsSync3,
3873
- mkdirSync as mkdirSync2,
3874
- renameSync,
3875
- openSync,
3876
- writeSync,
3877
- fsyncSync,
3878
- closeSync,
3879
- unlinkSync,
3880
- copyFileSync
3881
- } from "fs";
3882
- import { dirname as dirname2, basename, join as join4 } from "path";
3883
- var tempSeq = 0;
3884
- var JSONFileHelper = class {
3885
- constructor(filePath) {
3886
- this.filePath = filePath;
3887
- }
3888
- filePath;
3889
- mode = "save";
3890
- /** Absolute path of the backing file. */
3891
- get path() {
3892
- return this.filePath;
3893
- }
3894
- /** Whether the backing file exists, regardless of parseability. */
3895
- exists() {
3896
- return existsSync3(this.filePath);
3897
- }
3898
- write(change) {
3899
- this.writeValue(change.value);
3900
- }
3901
- /** Write an arbitrary value atomically. Used by migration paths. */
3902
- writeValue(value) {
3903
- mkdirSync2(dirname2(this.filePath), { recursive: true });
3904
- const tmp = this.tempPath();
3905
- let fd = null;
3906
- try {
3907
- fd = openSync(tmp, "w");
3908
- writeSync(fd, JSON.stringify(value, null, 2));
3909
- fsyncSync(fd);
3910
- closeSync(fd);
3911
- fd = null;
3912
- renameSync(tmp, this.filePath);
3913
- } catch (err) {
3914
- if (fd !== null) {
3915
- try {
3916
- closeSync(fd);
3917
- } catch {
3918
- }
3919
- }
3920
- try {
3921
- if (existsSync3(tmp)) unlinkSync(tmp);
3922
- } catch {
3923
- }
3924
- throw err;
3925
- }
3926
- }
3927
- readonly() {
3928
- if (!existsSync3(this.filePath)) return null;
3929
- let raw;
3930
- try {
3931
- raw = readFileSync4(this.filePath, "utf-8");
3932
- } catch (err) {
3933
- throw new Error(
3934
- `Failed to read snapshot "${this.filePath}": ${err.message}`
3935
- );
3936
- }
3937
- if (raw.trim().length === 0) return null;
3938
- try {
3939
- return JSON.parse(raw);
3940
- } catch (err) {
3941
- const quarantine = this.quarantineCorrupt();
3942
- throw new Error(
3943
- `Corrupt snapshot "${this.filePath}" (${err.message}). The damaged file was moved to "${quarantine}". Refusing to treat it as empty state.`
3944
- );
3945
- }
3946
- }
3947
- read() {
3948
- return this.readonly();
4809
+ startedAt;
4810
+ logLevel;
4811
+ logger;
4812
+ abortController;
4813
+ config;
4814
+ hooks;
4815
+ /** The log sink this context owns; `logger` above is a child of it. */
4816
+ logSink;
4817
+ closeLoggerPromise;
4818
+ constructor(config, sessionId, sessionDir, workspaceDir2, logLevel) {
4819
+ this.config = config;
4820
+ this.sessionId = sessionId;
4821
+ this.sessionDir = sessionDir;
4822
+ this.workspaceDir = workspaceDir2;
4823
+ this.startedAt = Date.now();
4824
+ this.logLevel = logLevel ?? "info";
4825
+ this.abortController = new AbortController();
4826
+ this.hooks = new LifecycleHooks();
4827
+ this.logSink = createFileLogger(this.logLevel, `${sessionDir}/wayang.log`);
4828
+ this.logger = this.logSink.logger.child({
4829
+ session: sessionId
4830
+ });
3949
4831
  }
3950
4832
  /**
3951
- * Read without throwing on corruption — returns null instead.
3952
- * Only for callers that can tolerate absence (e.g. optional side files).
3953
- * State-critical reads must use readonly()/read().
4833
+ * Flush and close the log sink, ending its transport worker thread.
4834
+ *
4835
+ * Once the returned promise resolves, this context's logger will never
4836
+ * touch the session directory again: the log file is fully flushed and
4837
+ * the worker thread has exited. Idempotent — repeated calls return the
4838
+ * same promise — and log calls made afterwards are safely ignored.
3954
4839
  */
3955
- raw() {
3956
- if (!existsSync3(this.filePath)) return null;
3957
- const text = readFileSync4(this.filePath, "utf-8");
3958
- if (text.trim().length === 0) return null;
3959
- try {
3960
- return JSON.parse(text);
3961
- } catch {
3962
- return null;
3963
- }
4840
+ closeLogger() {
4841
+ this.closeLoggerPromise ??= this.logSink.close();
4842
+ return this.closeLoggerPromise;
3964
4843
  }
3965
- clear() {
3966
- mkdirSync2(dirname2(this.filePath), { recursive: true });
3967
- this.writeValue(null);
4844
+ get controllerProvider() {
4845
+ return this.config.providers[this.config.controller.provider];
3968
4846
  }
3969
- tempPath() {
3970
- tempSeq += 1;
3971
- return join4(dirname2(this.filePath), `.${basename(this.filePath)}.${process.pid}.${tempSeq}.tmp`);
4847
+ get workerProvider() {
4848
+ return this.config.providers[this.config.worker.provider];
3972
4849
  }
3973
- quarantineCorrupt() {
3974
- const target = `${this.filePath}.corrupt.${Date.now()}`;
3975
- try {
3976
- copyFileSync(this.filePath, target);
3977
- } catch {
3978
- }
3979
- return target;
4850
+ get maxConcurrency() {
4851
+ return this.config.worker.maxConcurrency;
3980
4852
  }
3981
4853
  };
3982
4854
 
4855
+ // src/services/signal/signal-state.ts
4856
+ import { join as join10 } from "path";
4857
+
3983
4858
  // src/infra/state/persistence/jsonl-file.ts
3984
- import { readFileSync as readFileSync5, writeFileSync as writeFileSync2, appendFileSync, existsSync as existsSync4, mkdirSync as mkdirSync3 } from "fs";
3985
- import { dirname as dirname3 } from "path";
4859
+ import { readFileSync as readFileSync7, writeFileSync as writeFileSync3, appendFileSync, existsSync as existsSync7, mkdirSync as mkdirSync5 } from "fs";
4860
+ import { dirname as dirname5 } from "path";
3986
4861
  var JSONLFileHelper = class {
3987
4862
  constructor(filePath) {
3988
4863
  this.filePath = filePath;
@@ -3999,12 +4874,12 @@ var JSONLFileHelper = class {
3999
4874
  }
4000
4875
  /** Append a single entry to history. */
4001
4876
  append(entry) {
4002
- mkdirSync3(dirname3(this.filePath), { recursive: true });
4877
+ mkdirSync5(dirname5(this.filePath), { recursive: true });
4003
4878
  appendFileSync(this.filePath, JSON.stringify(entry) + "\n");
4004
4879
  }
4005
4880
  read() {
4006
- if (!existsSync4(this.filePath)) return [];
4007
- const text = readFileSync5(this.filePath, "utf-8");
4881
+ if (!existsSync7(this.filePath)) return [];
4882
+ const text = readFileSync7(this.filePath, "utf-8");
4008
4883
  if (text.length === 0) return [];
4009
4884
  const entries = [];
4010
4885
  for (const line of text.split("\n")) {
@@ -4027,8 +4902,8 @@ var JSONLFileHelper = class {
4027
4902
  return result2;
4028
4903
  }
4029
4904
  clear() {
4030
- mkdirSync3(dirname3(this.filePath), { recursive: true });
4031
- writeFileSync2(this.filePath, "");
4905
+ mkdirSync5(dirname5(this.filePath), { recursive: true });
4906
+ writeFileSync3(this.filePath, "");
4032
4907
  }
4033
4908
  };
4034
4909
 
@@ -4074,8 +4949,8 @@ var SignalState = class extends BaseWayangState {
4074
4949
  historyPath;
4075
4950
  historySeq = 0;
4076
4951
  constructor(ctx) {
4077
- const snapshotPath = join5(ctx.sessionDir, "signals.json");
4078
- const historyPath = join5(ctx.sessionDir, "signals.jsonl");
4952
+ const snapshotPath = join10(ctx.sessionDir, "signals.json");
4953
+ const historyPath = join10(ctx.sessionDir, "signals.jsonl");
4079
4954
  const snapshotFile = new JSONFileHelper(snapshotPath);
4080
4955
  const snapshotHelper = new SignalSnapshotHelper(snapshotFile);
4081
4956
  const historyFile = new JSONLFileHelper(historyPath);
@@ -4228,7 +5103,7 @@ var SignalState = class extends BaseWayangState {
4228
5103
  * whole signals array after a `set`.
4229
5104
  */
4230
5105
  replayLegacyStateEvents(byId) {
4231
- const eventsPath = join5(this.snapshotPath, "..", "state-events.jsonl");
5106
+ const eventsPath = join10(this.snapshotPath, "..", "state-events.jsonl");
4232
5107
  const eventsFile = new JSONLFileHelper(eventsPath);
4233
5108
  const events = eventsFile.read();
4234
5109
  if (events.length === 0) return;
@@ -4262,11 +5137,11 @@ var SignalQueue = class {
4262
5137
  this.counter = Math.max(mark, this.maxIdInWindow());
4263
5138
  }
4264
5139
  // --- Subscribable ---
4265
- subscribe(path7, callback) {
4266
- return this.state.on(path7, callback);
5140
+ subscribe(path9, callback) {
5141
+ return this.state.on(path9, callback);
4267
5142
  }
4268
- getSnapshot(path7) {
4269
- return this.state.get(path7);
5143
+ getSnapshot(path9) {
5144
+ return this.state.get(path9);
4270
5145
  }
4271
5146
  enqueue(sig) {
4272
5147
  const id = `sig-${++this.counter}`;
@@ -4322,10 +5197,10 @@ var SignalQueue = class {
4322
5197
  waitForSignal() {
4323
5198
  const unread = this.state.get("signals").filter((s) => s.status === "unread");
4324
5199
  if (unread.length > 0) return Promise.resolve();
4325
- return new Promise((resolve12) => {
5200
+ return new Promise((resolve13) => {
4326
5201
  this.resolveWait = () => {
4327
5202
  this.logger.debug("waitForSignal resolved");
4328
- resolve12();
5203
+ resolve13();
4329
5204
  };
4330
5205
  });
4331
5206
  }
@@ -4386,11 +5261,11 @@ var SignalQueue = class {
4386
5261
  };
4387
5262
 
4388
5263
  // src/services/worker-transcript.ts
4389
- import { closeSync as closeSync2, existsSync as existsSync5, openSync as openSync2, readSync, statSync } from "fs";
4390
- import { join as join7 } from "path";
5264
+ import { closeSync as closeSync3, existsSync as existsSync8, openSync as openSync3, readSync, statSync } from "fs";
5265
+ import { join as join12 } from "path";
4391
5266
 
4392
5267
  // src/services/agents/worker-state.ts
4393
- import { join as join6 } from "path";
5268
+ import { join as join11 } from "path";
4394
5269
 
4395
5270
  // src/services/agents/utils/conversation-to-sdk-messages.ts
4396
5271
  var USER_INTERRUPT_REPLAY = "[USER INTERRUPT] The user aborted the previous response while it was streaming (ESC in the TUI). Any assistant text immediately before this marker is a truncated partial \u2014 treat it as incomplete context, not as a finished answer or instructions.";
@@ -4467,9 +5342,10 @@ function conversationToSdkMessages(entries) {
4467
5342
  const sig = entry;
4468
5343
  const label = sig.subtype.replace("worker_", "").toUpperCase();
4469
5344
  const taskTag = sig.taskId ? ` task=${sig.taskId}` : "";
5345
+ const workerTag = sig.workerId ? ` worker=${sig.workerId}` : "";
4470
5346
  messages.push({
4471
5347
  role: "system",
4472
- content: `[WORKER PROGRESS SIGNAL: ${label}${taskTag}] ${sig.content}`
5348
+ content: `[WORKER PROGRESS SIGNAL: ${label}${taskTag}${workerTag}] ${sig.content}`
4473
5349
  });
4474
5350
  break;
4475
5351
  }
@@ -4568,20 +5444,6 @@ function toolOutputText(output) {
4568
5444
  return toolPayloadText(output);
4569
5445
  }
4570
5446
 
4571
- // src/utils/id.ts
4572
- function generateId(prefix) {
4573
- return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
4574
- }
4575
- function formatTimestamp(date = /* @__PURE__ */ new Date()) {
4576
- const Y = date.getFullYear();
4577
- const M = String(date.getMonth() + 1).padStart(2, "0");
4578
- const D = String(date.getDate()).padStart(2, "0");
4579
- const h = String(date.getHours()).padStart(2, "0");
4580
- const m = String(date.getMinutes()).padStart(2, "0");
4581
- const s = String(date.getSeconds()).padStart(2, "0");
4582
- return `${Y}${M}${D}-${h}${m}${s}`;
4583
- }
4584
-
4585
5447
  // src/utils/time.ts
4586
5448
  function nowISO() {
4587
5449
  const now = /* @__PURE__ */ new Date();
@@ -4834,17 +5696,23 @@ ${this.compactSummary}` : "";
4834
5696
 
4835
5697
  // src/services/agents/worker-state.ts
4836
5698
  function workerDir(sessionDir, workerId) {
4837
- return join6(sessionDir, "workers", workerId);
5699
+ return join11(sessionDir, "workers", workerId);
4838
5700
  }
4839
5701
  var WorkerState = class extends BaseWayangState {
4840
- constructor(sessionDir, workerId, logger) {
5702
+ /**
5703
+ * @param workspace Workspace identity for this worker, resolved by the
5704
+ * caller (see `resolveWorkspaceRef`). Stamped into runtime-state on the
5705
+ * first write, so a worker records where it ran for later inspection.
5706
+ */
5707
+ constructor(sessionDir, workerId, logger, workspace) {
4841
5708
  const dir = workerDir(sessionDir, workerId);
4842
- const jsonFile = new JSONFileHelper(join6(dir, "runtime-state.json"));
4843
- const jsonlFile = new JSONLFileHelper(join6(dir, "conversation.jsonl"));
5709
+ const jsonFile = new JSONFileHelper(join11(dir, "runtime-state.json"));
5710
+ const jsonlFile = new JSONLFileHelper(join11(dir, "conversation.jsonl"));
4844
5711
  const initialData = {
4845
5712
  runtimeState: {
4846
5713
  session: { id: workerId, startedAt: Date.now() },
4847
- task: { id: "", description: "" }
5714
+ task: { id: "", description: "" },
5715
+ ...workspace ? { workspace } : {}
4848
5716
  },
4849
5717
  conversation: [],
4850
5718
  inbox: []
@@ -4856,14 +5724,17 @@ var WorkerState = class extends BaseWayangState {
4856
5724
  this.logger = logger;
4857
5725
  this.jsonFile = jsonFile;
4858
5726
  this.jsonlFile = jsonlFile;
5727
+ this.workspace = workspace;
4859
5728
  }
4860
5729
  logger;
4861
5730
  jsonFile;
4862
5731
  jsonlFile;
5732
+ /** Identity stamped on construction, re-applied if a restored file lacks it. */
5733
+ workspace;
4863
5734
  async restore() {
4864
5735
  const runtimeState = this.jsonFile.read();
4865
5736
  if (runtimeState) {
4866
- this.data.runtimeState = runtimeState;
5737
+ this.data.runtimeState = runtimeState.workspace || !this.workspace ? runtimeState : { ...runtimeState, workspace: this.workspace };
4867
5738
  }
4868
5739
  const allEntries = this.jsonlFile.read();
4869
5740
  const compactIdx = allEntries.findLastIndex(isCompactMarker);
@@ -4895,13 +5766,13 @@ function isValidWorkerId(workerId) {
4895
5766
  return WORKER_ID_PATTERN.test(workerId) && workerId !== "." && workerId !== "..";
4896
5767
  }
4897
5768
  function workerTranscriptPath(sessionDir, workerId) {
4898
- return join7(workerDir(sessionDir, workerId), WORKER_TRANSCRIPT_FILE);
5769
+ return join12(workerDir(sessionDir, workerId), WORKER_TRANSCRIPT_FILE);
4899
5770
  }
4900
5771
  function readWorkerTranscript(sessionDir, workerId) {
4901
5772
  if (!isValidWorkerId(workerId)) return null;
4902
- const path7 = workerTranscriptPath(sessionDir, workerId);
4903
- if (!existsSync5(path7)) return null;
4904
- return new JSONLFileHelper(path7).read();
5773
+ const path9 = workerTranscriptPath(sessionDir, workerId);
5774
+ if (!existsSync8(path9)) return null;
5775
+ return new JSONLFileHelper(path9).read();
4905
5776
  }
4906
5777
  var MAX_TRANSCRIPT_WINDOW_BYTES = 2 * 1024 * 1024;
4907
5778
  var MIN_WINDOW_BYTES = 64 * 1024;
@@ -4931,10 +5802,10 @@ function parseWindow(text, startsAtFileHead) {
4931
5802
  }
4932
5803
  function readWorkerTranscriptWindow(sessionDir, workerId, opts = {}) {
4933
5804
  if (!isValidWorkerId(workerId)) return null;
4934
- const path7 = workerTranscriptPath(sessionDir, workerId);
5805
+ const path9 = workerTranscriptPath(sessionDir, workerId);
4935
5806
  let size;
4936
5807
  try {
4937
- size = statSync(path7).size;
5808
+ size = statSync(path9).size;
4938
5809
  } catch {
4939
5810
  return null;
4940
5811
  }
@@ -4948,11 +5819,11 @@ function readWorkerTranscriptWindow(sessionDir, workerId, opts = {}) {
4948
5819
  const readLen = Math.min(size, windowBytes);
4949
5820
  const start = size - readLen;
4950
5821
  const chunk = Buffer.alloc(readLen);
4951
- const fd = openSync2(path7, "r");
5822
+ const fd = openSync3(path9, "r");
4952
5823
  try {
4953
5824
  readSync(fd, chunk, 0, readLen, start);
4954
5825
  } finally {
4955
- closeSync2(fd);
5826
+ closeSync3(fd);
4956
5827
  }
4957
5828
  const parsed = parseWindow(chunk.toString("utf8"), start === 0);
4958
5829
  const enough = parsed.entries.filter(matches).length >= need;
@@ -5170,12 +6041,12 @@ function buildTranscriptHeader(meta) {
5170
6041
  }
5171
6042
 
5172
6043
  // src/services/task/task-pool-state.ts
5173
- import { join as join8 } from "path";
6044
+ import { join as join13 } from "path";
5174
6045
  var TaskPoolState = class extends BaseWayangState {
5175
6046
  jsonFile;
5176
6047
  logger;
5177
6048
  constructor(ctx) {
5178
- const jsonFile = new JSONFileHelper(join8(ctx.sessionDir, "tasks.json"));
6049
+ const jsonFile = new JSONFileHelper(join13(ctx.sessionDir, "tasks.json"));
5179
6050
  super(
5180
6051
  {
5181
6052
  tasks: {
@@ -5196,6 +6067,29 @@ var TaskPoolState = class extends BaseWayangState {
5196
6067
  }
5197
6068
  this.logger.info("TaskPoolState restored");
5198
6069
  }
6070
+ /**
6071
+ * Stamp `workspace` onto restored tasks that predate task workspace
6072
+ * attribution, using the session's own workspace as the fallback.
6073
+ *
6074
+ * Memory only — no persist call, so restoring a legacy session never rewrites
6075
+ * its tasks.json (the value reaches disk with the next normal task write,
6076
+ * exactly like `SessionState.fillWorkspacesInMemory`).
6077
+ *
6078
+ * Idempotent and non-destructive: a task that already carries a workspace is
6079
+ * left untouched, so repeated restores (and tasks stamped at add time) are
6080
+ * unaffected.
6081
+ */
6082
+ fillWorkspaceInMemory(fallback) {
6083
+ const buckets = this.data.tasks;
6084
+ if (!buckets) return;
6085
+ for (const bucket of [buckets.pending, buckets.running, buckets.history]) {
6086
+ if (!Array.isArray(bucket)) continue;
6087
+ for (const task of bucket) {
6088
+ if (!task || task.workspace) continue;
6089
+ task.workspace = { path: fallback.path, slug: fallback.slug };
6090
+ }
6091
+ }
6092
+ }
5199
6093
  };
5200
6094
 
5201
6095
  // src/services/agents/base-agent.ts
@@ -5594,6 +6488,7 @@ function applyTurnEnvelope(entry, env) {
5594
6488
  if (entry.rootTurnId === void 0 && env.rootTurnId !== void 0) entry.rootTurnId = env.rootTurnId;
5595
6489
  if (entry.causationId === void 0 && env.causationId !== void 0) entry.causationId = env.causationId;
5596
6490
  if (entry.actor === void 0 && env.actor !== void 0) entry.actor = env.actor;
6491
+ if (entry.workspaceSlug === void 0 && env.workspaceSlug !== void 0) entry.workspaceSlug = env.workspaceSlug;
5597
6492
  return entry;
5598
6493
  }
5599
6494
  function syncStepTimestamps(entry) {
@@ -5729,12 +6624,7 @@ import { z } from "zod";
5729
6624
 
5730
6625
  // src/services/tools/common.ts
5731
6626
  import { tool } from "ai";
5732
- import { resolve, relative } from "path";
5733
6627
  var MAX_RESULT_CHARS = 8e3;
5734
- function isInsideWorkspace(resolved, workspace) {
5735
- const rel = relative(workspace, resolved);
5736
- return !rel.startsWith("..") && resolve(workspace, rel) === resolved;
5737
- }
5738
6628
  function truncate(result2, maxChars = MAX_RESULT_CHARS) {
5739
6629
  if (result2.length <= maxChars) return result2;
5740
6630
  return result2.slice(0, maxChars) + "\n...(truncated)";
@@ -5792,7 +6682,7 @@ function getWorkerMeta(workerType, workerConfigs) {
5792
6682
  }
5793
6683
 
5794
6684
  // src/services/agents/prompts/prompt-utils.ts
5795
- import os from "os";
6685
+ import os2 from "os";
5796
6686
  function assemble(...sections) {
5797
6687
  return sections.filter((s) => typeof s === "string" && s.length > 0).join("\n\n");
5798
6688
  }
@@ -5813,10 +6703,10 @@ function buildEnvironment(ctx, opts) {
5813
6703
  const shellName = shell.split("/").pop() ?? shell;
5814
6704
  const pairs = {
5815
6705
  "Date": `${date} (${weekday})`,
5816
- "OS": `${os.platform()} (${os.arch()})`,
6706
+ "OS": `${os2.platform()} (${os2.arch()})`,
5817
6707
  "Shell": `${shellName} (${shell})`,
5818
- "Home": os.homedir(),
5819
- "WorkspaceDir": ctx.workspaceDir ?? process.cwd(),
6708
+ "Home": os2.homedir(),
6709
+ "WorkspaceDir": opts?.workspaceDir ?? ctx.workspaceDir ?? process.cwd(),
5820
6710
  ...opts?.extras
5821
6711
  };
5822
6712
  return section("Environment", kvBlock(pairs));
@@ -5853,7 +6743,7 @@ Always respond to user input \u2014 never leave the user without a reply.
5853
6743
 
5854
6744
  ## Responding to worker signals
5855
6745
  Worker signals are NOT user messages. Do NOT respond to them the same way.
5856
- See the "Handling signals" section for detailed rules. In short: stay silent on PROGRESS, speak only on COMPLETED or FAILED.
6746
+ See the "Handling signals" section for detailed rules. In short: stay silent on TASK_QUEUED, STARTED and PROGRESS, speak only on COMPLETED or FAILED.
5857
6747
 
5858
6748
  Examples of ideal responses to user messages:
5859
6749
 
@@ -5916,7 +6806,7 @@ Give the user a brief one-line acknowledgement, then STOP. Do not speculate abou
5916
6806
 
5917
6807
  Some work proceeds in stages with a checkpoint in between (e.g. draft a design \u2192 user/you review \u2192 implement). For these, create the first task with \`multiStage: true\`. When that task completes, its worker parks in **idle** instead of being destroyed, keeping its accumulated context.
5918
6808
 
5919
- To continue the work on the SAME worker, create the follow-up task with \`assignToWorker: "<workerId>"\` (the workerId from the completion). The worker resumes with full context \u2014 no need to re-explain what was already done.
6809
+ To continue the work on the SAME worker, create the follow-up task with \`assignToWorker: "<workerId>"\` (the worker id from the completed task \u2014 the \`worker=\u2026\` segment of its completion signal header, or the workerId returned by add_task). The worker resumes with full context \u2014 no need to re-explain what was already done.
5920
6810
 
5921
6811
  When to use multiStage:
5922
6812
  - The next stage genuinely depends on the worker's prior context (explored files, partial artifacts, decisions made).
@@ -6009,7 +6899,7 @@ Use sparingly \u2014 only when you need to:
6009
6899
  ## Handling permission requests (respond_permission)
6010
6900
 
6011
6901
  Workers may request permission for sensitive operations (shell commands, file writes, file edits).
6012
- You will see a [PERMISSION REQUEST] message with the worker ID, tool name, arguments, and a request ID.
6902
+ You will see a [WORKER PROGRESS SIGNAL: PERMISSION_REQUEST \u2026] message whose header carries the worker id (worker=\u2026), with the tool name, a description, and a request ID.
6013
6903
  Use respond_permission(requestId, approved, reason) to approve or deny.
6014
6904
  - If the operation seems safe and aligned with the task, approve it.
6015
6905
  - If it seems risky or unnecessary, deny it with a reason explaining what the worker should do differently.
@@ -6044,9 +6934,19 @@ var SIGNAL_HANDLING = section(
6044
6934
  "Handling signals",
6045
6935
  `Worker signals are delivered as messages with a special prefix. They are NOT from the user.
6046
6936
 
6047
- Format: [WORKER SIGNAL: TYPE task=TASK_ID] content
6937
+ Format: [WORKER PROGRESS SIGNAL: TYPE task=TASK_ID worker=WORKER_ID] content
6938
+ - task=TASK_ID \u2014 present when the signal concerns a dispatched task.
6939
+ - worker=WORKER_ID \u2014 present once a worker is assigned. This is the worker id you use for chat_worker / get_worker_status / read_worker_conversation / dispose_worker, and as the assignToWorker of a follow-up multi-stage task. It is absent while the task is still queued (TASK_QUEUED) and on signals that are not about one specific worker (e.g. HEARTBEAT).
6940
+
6941
+ ## [WORKER PROGRESS SIGNAL: TASK_QUEUED ...] \u2014 task is waiting for a slot
6942
+
6943
+ All concurrency slots are busy, so the task has not started yet. It will start automatically when a slot frees \u2014 a STARTED signal follows. Informational: call \`skip_reply()\` and do NOT re-dispatch or poll.
6944
+
6945
+ ## [WORKER PROGRESS SIGNAL: STARTED ...] \u2014 worker began the task
6048
6946
 
6049
- ## [WORKER SIGNAL: PROGRESS ...] \u2014 task is still running
6947
+ The task is now running on the identified worker. Informational: call \`skip_reply()\`. Only speak up if you must tell the user something right now (e.g. you changed your mind and want to cancel).
6948
+
6949
+ ## [WORKER PROGRESS SIGNAL: PROGRESS ...] \u2014 task is still running
6050
6950
 
6051
6951
  Call \`skip_reply()\` as your ONLY action. Do NOT output any text in the same response.
6052
6952
 
@@ -6062,16 +6962,16 @@ WHY: skip_reply terminates the turn immediately. Any text you generate in the sa
6062
6962
 
6063
6963
  The ONLY exception: speak up if the progress indicates an unexpected error or problem.
6064
6964
 
6065
- ## [WORKER SIGNAL: COMPLETED ...] \u2014 task is finished
6965
+ ## [WORKER PROGRESS SIGNAL: COMPLETED ...] \u2014 task is finished
6066
6966
 
6067
6967
  NOW report the result to the user. Summarize concisely.
6068
6968
  If the result says "(max steps reached)" or is empty, be honest \u2014 the task did not fully complete.
6069
6969
 
6070
- ## [WORKER SIGNAL: FAILED ...] \u2014 task failed
6970
+ ## [WORKER PROGRESS SIGNAL: FAILED ...] \u2014 task failed
6071
6971
 
6072
6972
  Explain the error. Suggest a retry or alternative.
6073
6973
 
6074
- ## [HEARTBEAT] \u2014 periodic check-in while workers are running
6974
+ ## [WORKER PROGRESS SIGNAL: HEARTBEAT] \u2014 periodic check-in while workers are running
6075
6975
 
6076
6976
  You were woken up because workers are running but no events arrived for a while.
6077
6977
  Review the worker status in the heartbeat message (runningForMs, last activity, current tool):
@@ -6079,18 +6979,18 @@ Review the worker status in the heartbeat message (runningForMs, last activity,
6079
6979
  - A worker has been running unusually long but activity is recent \u2192 inform the user proactively
6080
6980
  - Everything looks normal \u2192 call skip_reply()
6081
6981
 
6082
- ## [PERMISSION REQUEST ...] \u2014 worker needs approval
6982
+ ## [WORKER PROGRESS SIGNAL: PERMISSION_REQUEST ...] \u2014 worker needs approval
6083
6983
 
6084
6984
  A worker wants to execute a sensitive operation (shell command, file write/edit).
6085
6985
  Review the requested operation carefully. Use respond_permission(requestId, approved, reason) to decide.
6086
6986
  Do NOT call skip_reply for permission requests \u2014 you MUST respond with respond_permission.
6087
6987
 
6088
6988
  CRITICAL RULES:
6089
- - PROGRESS \u2260 COMPLETED. Even if the progress text says "\u5B8C\u6210" or "done", it is still just a progress update. Only [WORKER SIGNAL: COMPLETED] means the task actually finished.
6989
+ - PROGRESS \u2260 COMPLETED. Even if the progress text says "\u5B8C\u6210" or "done", it is still just a progress update. Only [WORKER PROGRESS SIGNAL: COMPLETED] means the task actually finished.
6090
6990
  - Do NOT call list_tasks or get_task_detail after receiving a signal. The signal already contains the information.
6091
6991
  - skip_reply is a TERMINAL action \u2014 it ends the turn. You MUST NOT generate any text content in the same response. Your response must contain ONLY the skip_reply tool call and nothing else.
6092
6992
 
6093
- ## [PREVIOUS SESSION ...] \u2014 unfinished tasks from the last run
6993
+ ## [WORKER PROGRESS SIGNAL: PREVIOUS_SESSION_TASKS] \u2014 unfinished tasks from the last run
6094
6994
 
6095
6995
  You resumed a previous session that had unfinished tasks. Workers were NOT automatically resumed \u2014 these tasks are informational only. Decide for each:
6096
6996
  - Still relevant \u2192 re-dispatch with add_task (you can reuse the description).
@@ -6166,18 +7066,18 @@ function buildControllerSystemPrompt(ctx, skills) {
6166
7066
  }
6167
7067
 
6168
7068
  // src/services/agents/prompts/project-rules.ts
6169
- import { existsSync as existsSync6, readFileSync as readFileSync6, statSync as statSync2 } from "fs";
6170
- import { basename as basename2, dirname as dirname4, join as join9, resolve as resolve2 } from "path";
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";
6171
7071
  var RULE_FILE_NAMES = ["AGENTS.md", "CLAUDE.md"];
6172
7072
  var MAX_LEVELS = 5;
6173
7073
  var PROJECT_RULES_MAX_LENGTH = 8e3;
6174
7074
  function findProjectRules(startDir) {
6175
- let current = resolve2(startDir);
7075
+ let current = resolve3(startDir);
6176
7076
  for (let level = 0; level < MAX_LEVELS; level++) {
6177
7077
  const found = readRuleFile(current);
6178
7078
  if (found) return found;
6179
- if (existsSync6(join9(current, ".git"))) break;
6180
- const parent = dirname4(current);
7079
+ if (existsSync9(join14(current, ".git"))) break;
7080
+ const parent = dirname6(current);
6181
7081
  if (parent === current) break;
6182
7082
  current = parent;
6183
7083
  }
@@ -6185,12 +7085,12 @@ function findProjectRules(startDir) {
6185
7085
  }
6186
7086
  function readRuleFile(dir) {
6187
7087
  for (const name of RULE_FILE_NAMES) {
6188
- const path7 = join9(dir, name);
7088
+ const path9 = join14(dir, name);
6189
7089
  try {
6190
- if (!statSync2(path7).isFile()) continue;
6191
- const content = readFileSync6(path7, "utf8");
7090
+ if (!statSync2(path9).isFile()) continue;
7091
+ const content = readFileSync8(path9, "utf8");
6192
7092
  if (!content.trim()) continue;
6193
- return { path: path7, content };
7093
+ return { path: path9, content };
6194
7094
  } catch {
6195
7095
  }
6196
7096
  }
@@ -6198,7 +7098,7 @@ function readRuleFile(dir) {
6198
7098
  }
6199
7099
  function buildProjectRulesSection(rules) {
6200
7100
  if (!rules) return "";
6201
- const name = basename2(rules.path);
7101
+ const name = basename5(rules.path);
6202
7102
  const truncated = rules.content.length > PROJECT_RULES_MAX_LENGTH;
6203
7103
  const content = truncated ? `${rules.content.slice(0, PROJECT_RULES_MAX_LENGTH).trimEnd()}
6204
7104
 
@@ -6357,7 +7257,8 @@ Good: "Created sanwen.txt (218 words, prose about autumn rain)"
6357
7257
  Bad: "Failed"
6358
7258
  Good: "Failed: npm install timed out after 30s, network may be unreachable"`
6359
7259
  );
6360
- function buildWorkerSystemPrompt(ctx, skills) {
7260
+ function buildWorkerSystemPrompt(ctx, skills, workspaceDir2) {
7261
+ const workspaceRoot = workspaceDir2 ?? ctx.workspaceDir ?? process.cwd();
6361
7262
  return assemble(
6362
7263
  IDENTITY2,
6363
7264
  LANGUAGE_RULES,
@@ -6367,13 +7268,13 @@ function buildWorkerSystemPrompt(ctx, skills) {
6367
7268
  // Project rules discovered near the task workspace (AGENTS.md, falling
6368
7269
  // back to CLAUDE.md). Empty when no rules file exists — assemble() drops
6369
7270
  // the section, so no empty paragraph leaks into the prompt.
6370
- buildProjectRulesSection(findProjectRules(ctx.workspaceDir ?? process.cwd())),
7271
+ buildProjectRulesSection(findProjectRules(workspaceRoot)),
6371
7272
  QUALITY_RULES,
6372
7273
  HARD_CONSTRAINTS2,
6373
7274
  CONTROLLER_COMMUNICATION,
6374
7275
  PROGRESS_REPORTING,
6375
7276
  buildSkillCatalog(skills),
6376
- buildEnvironment(ctx)
7277
+ buildEnvironment(ctx, { workspaceDir: workspaceRoot })
6377
7278
  );
6378
7279
  }
6379
7280
 
@@ -6415,6 +7316,7 @@ var WorkerAgent = class extends BaseAgent {
6415
7316
  state;
6416
7317
  logger;
6417
7318
  _terminalResult = null;
7319
+ /** Workspace this worker executes in — fixed at construction (IWorkerInstance). */
6418
7320
  workspaceDir;
6419
7321
  toolChoice;
6420
7322
  provider;
@@ -6422,17 +7324,30 @@ var WorkerAgent = class extends BaseAgent {
6422
7324
  ctx;
6423
7325
  /** Live tool execution records — merged into entries at step finish. */
6424
7326
  recorder = new ToolCallRecorder();
6425
- constructor(provider, ctx, skills, toolChoice) {
7327
+ /**
7328
+ * Registry identity of the workspace this worker executes in. Reused for the
7329
+ * `workspaceSlug` stamped on turn envelopes, so a conversation entry is
7330
+ * attributable to a directory even when the task carried no explicit
7331
+ * workspace stamp.
7332
+ */
7333
+ workspaceRef;
7334
+ constructor(provider, ctx, skills, toolChoice, workspaceDir2) {
6426
7335
  super(provider);
6427
7336
  this.provider = provider;
6428
7337
  this.ctx = ctx;
6429
7338
  this.logger = ctx.logger;
6430
- this.workspaceDir = ctx.workspaceDir;
7339
+ this.workspaceDir = workspaceDir2 ?? ctx.workspaceDir;
6431
7340
  this.toolChoice = toolChoice;
6432
- this.state = new WorkerState(ctx.sessionDir, this.id, ctx.logger);
7341
+ this.workspaceRef = resolveWorkspaceRef(wayangDirForSessionDir(ctx.sessionDir), this.workspaceDir);
7342
+ this.state = new WorkerState(
7343
+ ctx.sessionDir,
7344
+ this.id,
7345
+ ctx.logger,
7346
+ this.workspaceRef
7347
+ );
6433
7348
  this.contextManager = new ContextManager(
6434
7349
  this.state,
6435
- buildWorkerSystemPrompt(this.ctx, skills)
7350
+ buildWorkerSystemPrompt(this.ctx, skills, this.workspaceDir)
6436
7351
  );
6437
7352
  }
6438
7353
  /**
@@ -6447,11 +7362,13 @@ var WorkerAgent = class extends BaseAgent {
6447
7362
  this.logger.debug({ task }, "WorkerAgent run called");
6448
7363
  this.state.set("runtimeState.task", { id: task.id, description: task.description });
6449
7364
  this._terminalResult = null;
7365
+ const workspaceSlug = task.workspace?.slug ?? this.workspaceRef?.slug;
6450
7366
  const turnEnv = {
6451
7367
  turnId: task.executionTurnId ?? generateId("turn"),
6452
7368
  ...(task.rootTurnId ?? task.turnId) !== void 0 && { rootTurnId: task.rootTurnId ?? task.turnId },
6453
7369
  causationId: task.id,
6454
- actor: { kind: "worker", id: task.workerId, taskId: task.id }
7370
+ actor: { kind: "worker", id: task.workerId, taskId: task.id },
7371
+ ...workspaceSlug !== void 0 && { workspaceSlug }
6455
7372
  };
6456
7373
  this.recorder.beginTurn();
6457
7374
  const promptTs = nowISO();
@@ -6560,11 +7477,11 @@ var WorkerAgent = class extends BaseAgent {
6560
7477
  return this.state.get("conversation") ?? [];
6561
7478
  }
6562
7479
  // --- Subscribable ---
6563
- subscribe(path7, callback) {
6564
- return this.state.on(path7, callback);
7480
+ subscribe(path9, callback) {
7481
+ return this.state.on(path9, callback);
6565
7482
  }
6566
- getSnapshot(path7) {
6567
- return this.state.get(path7);
7483
+ getSnapshot(path9) {
7484
+ return this.state.get(path9);
6568
7485
  }
6569
7486
  /**
6570
7487
  * Accept a message from the controller — writes to internal inbox. The
@@ -6667,6 +7584,257 @@ function toolResultBlockToRecord(block, toolName, endedAt) {
6667
7584
  };
6668
7585
  }
6669
7586
 
7587
+ // src/services/tools/permission-rules.ts
7588
+ import { resolve as resolve5 } from "path";
7589
+
7590
+ // 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 = [
7594
+ ".ssh",
7595
+ ".gnupg",
7596
+ ".aws",
7597
+ ".azure",
7598
+ ".gcloud",
7599
+ ".kube",
7600
+ ".docker/config.json",
7601
+ ".netrc",
7602
+ ".git-credentials",
7603
+ ".npmrc",
7604
+ ".pypirc",
7605
+ ".config/gh",
7606
+ ".wayang.config.json",
7607
+ ".wayang"
7608
+ ];
7609
+ var SENSITIVE_SEGMENTS = /* @__PURE__ */ new Set([
7610
+ ".ssh",
7611
+ ".gnupg",
7612
+ ".aws",
7613
+ ".azure",
7614
+ ".gcloud",
7615
+ ".kube",
7616
+ ".netrc",
7617
+ ".git-credentials",
7618
+ ".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"
7626
+ ]);
7627
+ var MAX_ANCESTOR_DEPTH = 32;
7628
+ function normalizePath(p, opts = {}) {
7629
+ const base = opts.base ?? process.cwd();
7630
+ const lexical = isAbsolute(p) ? resolve4(p) : resolve4(base, p);
7631
+ try {
7632
+ return realpathSync2(lexical);
7633
+ } catch {
7634
+ return resolveDeepestExistingAncestor(lexical);
7635
+ }
7636
+ }
7637
+ function resolveDeepestExistingAncestor(lexical) {
7638
+ const peeled = [];
7639
+ let candidate = lexical;
7640
+ for (let depth = 0; depth <= MAX_ANCESTOR_DEPTH; depth += 1) {
7641
+ try {
7642
+ const resolved = realpathSync2(candidate);
7643
+ return peeled.length === 0 ? resolved : resolve4(join15(resolved, ...peeled));
7644
+ } catch {
7645
+ }
7646
+ const parent = dirname7(candidate);
7647
+ if (parent === candidate) break;
7648
+ peeled.unshift(basename6(candidate));
7649
+ candidate = parent;
7650
+ }
7651
+ return lexical;
7652
+ }
7653
+ function defaultDeniedPaths(homeDir2) {
7654
+ return DEFAULT_DENIED_RELATIVE_PATHS.map((rel) => resolve4(homeDir2, rel));
7655
+ }
7656
+ function isPathWithin(parent, child) {
7657
+ if (parent === sep) return child.startsWith(sep);
7658
+ if (child === parent) return true;
7659
+ const prefix = parent.endsWith(sep) ? parent : parent + sep;
7660
+ return child.startsWith(prefix);
7661
+ }
7662
+ function isDenied(absPath, deniedPaths) {
7663
+ return deniedPaths.some((denied) => isPathWithin(denied, absPath));
7664
+ }
7665
+ function isSensitiveSegmentPath(absPath) {
7666
+ const segments = absPath.split(sep).filter((s) => s.length > 0);
7667
+ for (let i = 0; i < segments.length; i += 1) {
7668
+ 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;
7674
+ }
7675
+ }
7676
+ return false;
7677
+ }
7678
+ function normalizeDirs(dirs, base) {
7679
+ if (!dirs) return [];
7680
+ return dirs.filter((d) => typeof d === "string" && d.length > 0).map((d) => normalizePath(d, { base }));
7681
+ }
7682
+ function findContaining(dirs, target) {
7683
+ return dirs.find((dir) => isPathWithin(dir, target));
7684
+ }
7685
+ function decideAccess(input2) {
7686
+ const resolvedPath = normalizePath(input2.path, { base: input2.cwd });
7687
+ const homeDir2 = normalizePath(input2.homeDir);
7688
+ const taskWorkspace = input2.taskWorkspace ? normalizePath(input2.taskWorkspace, { base: homeDir2 }) : void 0;
7689
+ const taskAllowedDirs = normalizeDirs(input2.taskAllowedDirs, taskWorkspace ?? homeDir2);
7690
+ const configAllowedDirs = normalizeDirs(input2.configAllowedDirs, homeDir2);
7691
+ 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 };
7701
+ }
7702
+ if (findContaining(configAllowedDirs, resolvedPath) !== void 0) {
7703
+ return { decision: "allow", matchedRule: "config-allowed", resolvedPath };
7704
+ }
7705
+ if (findContaining(autoAllowRoots, resolvedPath) !== void 0) {
7706
+ return { decision: "allow", matchedRule: "auto-allow-root", resolvedPath };
7707
+ }
7708
+ if (findContaining(taskAllowedDirs, resolvedPath) !== void 0) {
7709
+ return { decision: "allow", matchedRule: "task-allowed", resolvedPath };
7710
+ }
7711
+ if (taskWorkspace !== void 0 && isPathWithin(taskWorkspace, resolvedPath)) {
7712
+ return { decision: "allow", matchedRule: "task-workspace", resolvedPath };
7713
+ }
7714
+ if (input2.access === "read" && isPathWithin(homeDir2, resolvedPath)) {
7715
+ return { decision: "allow", matchedRule: "read-within-home", resolvedPath, withinReadScope: true };
7716
+ }
7717
+ return { decision: "ask", matchedRule: "outside-all", resolvedPath };
7718
+ }
7719
+
7720
+ // src/services/tools/permission-rules.ts
7721
+ var FILE_TOOL_NAMES = ["read_file", "write_file", "edit_file", "search_files", "search_content"];
7722
+ var READ_ONLY_FILE_TOOLS = /* @__PURE__ */ new Set(["read_file", "search_files", "search_content"]);
7723
+ 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
+ function expandHome(p, homeDir2) {
7726
+ if (p === "~") return { path: homeDir2, unresolvable: false };
7727
+ if (p.startsWith("~/")) return { path: resolve5(homeDir2, p.slice(2)), unresolvable: false };
7728
+ if (p.startsWith("~")) return { path: p, unresolvable: true };
7729
+ return { path: p, unresolvable: false };
7730
+ }
7731
+ function expandHomeVars(command, homeDir2) {
7732
+ return command.replace(/\$\{HOME\}/g, homeDir2).replace(/\$HOME(?![A-Za-z0-9_])/g, homeDir2);
7733
+ }
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;
7739
+ }
7740
+ function hasOutputRedirection(command) {
7741
+ return /(?:^|[^<>])>>?\s*(?!&)\S/.test(command);
7742
+ }
7743
+ function extractPathCandidates(command) {
7744
+ const withoutUrls = command.replace(URL_RE, " ");
7745
+ 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);
7749
+ }
7750
+ return candidates;
7751
+ }
7752
+ function decidePath(scope, path9, access) {
7753
+ return decideAccess({
7754
+ path: path9,
7755
+ access,
7756
+ cwd: scope.workspace,
7757
+ homeDir: scope.homeDir,
7758
+ taskWorkspace: scope.workspace,
7759
+ taskAllowedDirs: scope.taskAllowedDirs,
7760
+ configAllowedDirs: scope.permission?.allowedDirs,
7761
+ autoAllowRoots: scope.permission?.autoAllowRoots,
7762
+ extraDeniedDirs: scope.permission?.deniedDirs
7763
+ });
7764
+ }
7765
+ function evaluateFileAccess(scope, toolName, args) {
7766
+ const rawPath = args.path;
7767
+ if (typeof rawPath !== "string" || !rawPath) return { decision: "allow", matchedRule: "no-path" };
7768
+ const access = READ_ONLY_FILE_TOOLS.has(toolName) ? "read" : "write";
7769
+ const expanded = expandHome(rawPath, scope.homeDir);
7770
+ if (expanded.unresolvable) {
7771
+ return {
7772
+ decision: "ask",
7773
+ matchedRule: "home-other-user",
7774
+ reason: "`~user` names another user's home directory, which cannot be resolved safely"
7775
+ };
7776
+ }
7777
+ const verdict = decidePath(scope, expanded.path, access);
7778
+ if (verdict.decision === "allow" && verdict.withinReadScope) {
7779
+ return { decision: "ask", matchedRule: "read-within-home" };
7780
+ }
7781
+ return { decision: verdict.decision, matchedRule: verdict.matchedRule };
7782
+ }
7783
+ function evaluateBashAccess(scope, command) {
7784
+ const expandedCommand = expandHomeVars(command, scope.homeDir);
7785
+ const candidates = [];
7786
+ let hasUnresolvableHome = false;
7787
+ for (const raw of extractPathCandidates(expandedCommand)) {
7788
+ const expanded = expandHome(raw, scope.homeDir);
7789
+ if (expanded.unresolvable) {
7790
+ hasUnresolvableHome = true;
7791
+ continue;
7792
+ }
7793
+ candidates.push(expanded.path);
7794
+ }
7795
+ let firstAsk;
7796
+ let firstAllow;
7797
+ 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;
7802
+ }
7803
+ if (DANGEROUS_BASH_PATTERN.test(expandedCommand)) {
7804
+ return { decision: "ask", matchedRule: "dangerous-bash" };
7805
+ }
7806
+ const dynamic = detectUnsafeDynamicTarget(expandedCommand);
7807
+ if (dynamic) return { decision: "ask", matchedRule: "dynamic-shell", reason: dynamic };
7808
+ if (hasUnresolvableHome) {
7809
+ return {
7810
+ decision: "ask",
7811
+ matchedRule: "home-other-user",
7812
+ reason: "`~user` names another user's home directory, which cannot be resolved safely"
7813
+ };
7814
+ }
7815
+ if (firstAsk) return firstAsk;
7816
+ return firstAllow ?? { decision: "allow", matchedRule: "no-path" };
7817
+ }
7818
+ function denyMessage(toolName, requestedPath) {
7819
+ if (toolName === "bash") {
7820
+ return "[ERROR] bash: refusing to run this command \u2014 it touches a path on the deny list (a sensitive location).";
7821
+ }
7822
+ const target = requestedPath ? ` "${requestedPath}"` : "";
7823
+ return `[ERROR] ${toolName}: refusing access to${target} \u2014 the path is on the deny list (a sensitive location).`;
7824
+ }
7825
+ function decideToolAccess(scope, toolName, args) {
7826
+ let verdict;
7827
+ if (toolName === "bash") {
7828
+ verdict = evaluateBashAccess(scope, typeof args.command === "string" ? args.command : "");
7829
+ } else if (FILE_TOOL_NAMES.includes(toolName)) {
7830
+ verdict = evaluateFileAccess(scope, toolName, args);
7831
+ } else {
7832
+ return { decision: "allow", matchedRule: "no-path" };
7833
+ }
7834
+ if (verdict.decision !== "deny") return verdict;
7835
+ return { ...verdict, message: denyMessage(toolName, typeof args.path === "string" ? args.path : void 0) };
7836
+ }
7837
+
6670
7838
  // src/infra/blocking-requests.ts
6671
7839
  var BlockingRequestMap = class {
6672
7840
  pending = /* @__PURE__ */ new Map();
@@ -6678,14 +7846,14 @@ var BlockingRequestMap = class {
6678
7846
  if (this.pending.has(id)) {
6679
7847
  throw new Error(`BlockingRequestMap: duplicate wait id "${id}"`);
6680
7848
  }
6681
- return new Promise((resolve12) => {
7849
+ return new Promise((resolve13) => {
6682
7850
  const settle = (value) => {
6683
7851
  this.pending.delete(id);
6684
- resolve12(value);
7852
+ resolve13(value);
6685
7853
  };
6686
7854
  const timer = setTimeout(() => {
6687
7855
  this.pending.delete(id);
6688
- resolve12(makeTimeoutValue());
7856
+ resolve13(makeTimeoutValue());
6689
7857
  }, timeoutMs);
6690
7858
  if (typeof timer === "object" && timer && "unref" in timer) {
6691
7859
  timer.unref();
@@ -6713,11 +7881,14 @@ var BlockingRequestMap = class {
6713
7881
  };
6714
7882
 
6715
7883
  // src/services/agents/claude-code-worker.ts
7884
+ import { basename as basename7, dirname as dirname8 } from "path";
7885
+ import { homedir } from "os";
6716
7886
  var INQUIRY_STREAM_CLOSE_TIMEOUT_MS = 36e4;
6717
7887
  var ClaudeCodeWorker = class _ClaudeCodeWorker {
6718
7888
  id;
6719
7889
  config;
6720
7890
  state;
7891
+ /** Workspace this worker executes in — fixed at construction (IWorkerInstance). */
6721
7892
  workspaceDir;
6722
7893
  logger;
6723
7894
  abortController = new AbortController();
@@ -6773,6 +7944,16 @@ var ClaudeCodeWorker = class _ClaudeCodeWorker {
6773
7944
  // Signal context set during run()
6774
7945
  _signalQueue = null;
6775
7946
  _runContext = null;
7947
+ /**
7948
+ * Shared permission policy scope for this run (workspace, task allowedDirs,
7949
+ * homeDir, config). Provided by the engine via setSignalContext so the
7950
+ * canUseTool callback judges access exactly like a puppet worker's
7951
+ * permission middleware does. Null until wired; a fallback scope derived
7952
+ * from the worker's own directory is used when absent.
7953
+ */
7954
+ _accessScope = null;
7955
+ /** Session directory this worker belongs to (fallback homeDir derivation). */
7956
+ sessionDir;
6776
7957
  /** Turn lineage for permission-request signals emitted by this worker. */
6777
7958
  _turnLineage = {};
6778
7959
  /** Turn lineage stamped on entries written by the run in flight. */
@@ -6783,11 +7964,13 @@ var ClaudeCodeWorker = class _ClaudeCodeWorker {
6783
7964
  * the direct cause of this activation.
6784
7965
  */
6785
7966
  buildTurnEnv(task) {
7967
+ const workspaceSlug = task.workspace?.slug ?? this.workspaceRef?.slug;
6786
7968
  return {
6787
7969
  turnId: task.executionTurnId ?? generateId("turn"),
6788
7970
  ...(task.rootTurnId ?? task.turnId) !== void 0 && { rootTurnId: task.rootTurnId ?? task.turnId },
6789
7971
  causationId: task.id,
6790
- actor: { kind: "worker", id: task.workerId, taskId: task.id }
7972
+ actor: { kind: "worker", id: task.workerId, taskId: task.id },
7973
+ ...workspaceSlug !== void 0 && { workspaceSlug }
6791
7974
  };
6792
7975
  }
6793
7976
  /**
@@ -6805,14 +7988,27 @@ var ClaudeCodeWorker = class _ClaudeCodeWorker {
6805
7988
  _pendingProgress = null;
6806
7989
  ctx;
6807
7990
  skills;
7991
+ /**
7992
+ * Registry identity of the workspace this worker executes in, resolved once
7993
+ * at construction. Feeds the `workspaceSlug` on turn envelopes (and the
7994
+ * runtime-state stamp) so entries stay attributable to a directory.
7995
+ */
7996
+ workspaceRef;
6808
7997
  constructor(config, sessionDir, workspaceDir2, ctx, skills) {
6809
7998
  this.id = generateId("cw");
6810
7999
  this.ctx = ctx;
6811
8000
  this.config = config;
6812
8001
  this.workspaceDir = workspaceDir2;
8002
+ this.sessionDir = sessionDir;
6813
8003
  this.logger = ctx.logger;
6814
8004
  this.skills = skills;
6815
- this.state = new WorkerState(sessionDir, this.id, ctx.logger);
8005
+ this.workspaceRef = resolveWorkspaceRef(wayangDirForSessionDir(sessionDir), workspaceDir2);
8006
+ this.state = new WorkerState(
8007
+ sessionDir,
8008
+ this.id,
8009
+ ctx.logger,
8010
+ this.workspaceRef
8011
+ );
6816
8012
  }
6817
8013
  async run(task, _tools, onProgress) {
6818
8014
  this._terminalResult = null;
@@ -6925,6 +8121,7 @@ var ClaudeCodeWorker = class _ClaudeCodeWorker {
6925
8121
  this._signalQueue = deps.signalQueue;
6926
8122
  this._runContext = deps;
6927
8123
  this._turnLineage = deps.turnLineage ?? {};
8124
+ this._accessScope = deps.accessScope ?? null;
6928
8125
  }
6929
8126
  abort() {
6930
8127
  this.abortController.abort();
@@ -6949,11 +8146,11 @@ var ClaudeCodeWorker = class _ClaudeCodeWorker {
6949
8146
  return this.state.get("conversation") ?? [];
6950
8147
  }
6951
8148
  // --- Subscribable ---
6952
- subscribe(path7, callback) {
6953
- return this.state.on(path7, callback);
8149
+ subscribe(path9, callback) {
8150
+ return this.state.on(path9, callback);
6954
8151
  }
6955
- getSnapshot(path7) {
6956
- return this.state.get(path7);
8152
+ getSnapshot(path9) {
8153
+ return this.state.get(path9);
6957
8154
  }
6958
8155
  /**
6959
8156
  * Accept a message from the controller. The message is queued and handed
@@ -6993,18 +8190,107 @@ var ClaudeCodeWorker = class _ClaudeCodeWorker {
6993
8190
  this._askController = ask;
6994
8191
  }
6995
8192
  // --- Permission control via canUseTool ---
6996
- /** Dangerous command patterns that require controller approval. */
6997
- static DANGEROUS_COMMAND_PATTERN = /\b(rm\s|sudo\b|chmod\b|chown\b|mkfs\b|dd\s|shutdown\b|reboot\b|kill\s+-9)/;
8193
+ /**
8194
+ * SDK tool name → the canonical tool the shared permission policy judges.
8195
+ * Claude Code's file tools carry their target in a tool-specific field; this
8196
+ * maps each to one of the policy's file tool names plus the path key to read.
8197
+ * `Bash` is handled separately (its command string goes through the bash rule).
8198
+ */
8199
+ static SDK_FILE_TOOL_MAP = {
8200
+ Read: { policyTool: "read_file", pathKey: "file_path", pathRequired: true },
8201
+ Write: { policyTool: "write_file", pathKey: "file_path", pathRequired: true },
8202
+ Edit: { policyTool: "edit_file", pathKey: "file_path", pathRequired: true },
8203
+ NotebookEdit: { policyTool: "edit_file", pathKey: "notebook_path", pathRequired: true },
8204
+ // Search tools default to the workspace when no path is given.
8205
+ Glob: { policyTool: "search_files", pathKey: "path", pathRequired: false },
8206
+ Grep: { policyTool: "search_content", pathKey: "path", pathRequired: false }
8207
+ };
8208
+ /**
8209
+ * SDK tools that touch no filesystem path the policy could judge (todo list,
8210
+ * web access, sub-agents, MCP resources, worktrees). Their puppet counterparts
8211
+ * are not gated either, so they are allowed without an approval.
8212
+ */
8213
+ static SDK_NON_PATH_TOOLS = /* @__PURE__ */ new Set([
8214
+ "Agent",
8215
+ "Task",
8216
+ "TaskOutput",
8217
+ "TaskStop",
8218
+ "KillShell",
8219
+ "KillTask",
8220
+ "ExitPlanMode",
8221
+ "TodoWrite",
8222
+ "WebFetch",
8223
+ "WebSearch",
8224
+ "AskUserQuestion",
8225
+ "ListMcpResources",
8226
+ "ReadMcpResource",
8227
+ "Mcp",
8228
+ "Config",
8229
+ "EnterWorktree",
8230
+ "ExitWorktree"
8231
+ ]);
8232
+ /**
8233
+ * Shared policy scope for this run. The engine injects the exact scope it
8234
+ * applies to puppet workers; when a worker is driven directly (tests), derive
8235
+ * a minimal scope from its own directory (no task allowedDirs / config — the
8236
+ * conservative reading).
8237
+ */
8238
+ effectiveAccessScope() {
8239
+ if (this._accessScope) return this._accessScope;
8240
+ const wayangDir = wayangDirForSessionDir(this.sessionDir);
8241
+ const homeDir2 = wayangDir && basename7(wayangDir) === ".wayang" ? dirname8(wayangDir) : homedir();
8242
+ return { homeDir: homeDir2, workspace: this.workspaceDir };
8243
+ }
8244
+ /**
8245
+ * 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.
8250
+ */
6998
8251
  async handleCanUseTool(toolName, input2) {
6999
- if (toolName !== "Bash" || !this._signalQueue || !this._runContext) {
8252
+ if (!this._signalQueue || !this._runContext) {
8253
+ return { behavior: "allow" };
8254
+ }
8255
+ const scope = this.effectiveAccessScope();
8256
+ if (toolName === "Bash") {
8257
+ 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);
8262
+ return { behavior: "allow" };
8263
+ }
8264
+ const fileTool = _ClaudeCodeWorker.SDK_FILE_TOOL_MAP[toolName];
8265
+ if (fileTool) {
8266
+ const rawPath = input2[fileTool.pathKey];
8267
+ if (typeof rawPath !== "string" || rawPath.length === 0) {
8268
+ if (fileTool.pathRequired) {
8269
+ return this.requestPermission(toolName, input2, "the target path could not be determined");
8270
+ }
8271
+ return { behavior: "allow" };
8272
+ }
8273
+ 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);
7000
8278
  return { behavior: "allow" };
7001
8279
  }
7002
- const command = String(input2.command ?? "");
7003
- if (!_ClaudeCodeWorker.DANGEROUS_COMMAND_PATTERN.test(command)) {
8280
+ if (_ClaudeCodeWorker.SDK_NON_PATH_TOOLS.has(toolName)) {
7004
8281
  return { behavior: "allow" };
7005
8282
  }
8283
+ return this.requestPermission(toolName, input2, "the tool is not recognized by the permission policy");
8284
+ }
8285
+ /**
8286
+ * Emit a permission_request and block until the controller answers (or it
8287
+ * times out → deny). Shared by every `ask` verdict so the request flow stays
8288
+ * identical to the puppet worker's.
8289
+ */
8290
+ async requestPermission(toolName, input2, reason) {
8291
+ if (!this._signalQueue || !this._runContext) return { behavior: "allow" };
7006
8292
  const requestId = generateId("perm");
7007
- const description = `Execute shell command: ${command}`;
8293
+ const description = this.describePermissionRequest(toolName, input2, reason);
7008
8294
  this._signalQueue.enqueue({
7009
8295
  source: "worker",
7010
8296
  type: "permission_request",
@@ -7025,6 +8311,22 @@ var ClaudeCodeWorker = class _ClaudeCodeWorker {
7025
8311
  return { behavior: "deny", message: "Permission request timed out" };
7026
8312
  });
7027
8313
  }
8314
+ /** Human-readable summary of a gated call for the approval prompt. */
8315
+ describePermissionRequest(toolName, input2, reason) {
8316
+ let detail;
8317
+ if (toolName === "Bash") {
8318
+ detail = `Execute shell command: ${String(input2.command ?? "")}`;
8319
+ } else if (typeof input2.file_path === "string") {
8320
+ detail = `${toolName}: ${input2.file_path}`;
8321
+ } else if (typeof input2.notebook_path === "string") {
8322
+ detail = `${toolName}: ${input2.notebook_path}`;
8323
+ } else if (typeof input2.path === "string") {
8324
+ detail = `${toolName}: ${input2.path}`;
8325
+ } else {
8326
+ detail = `${toolName}: ${JSON.stringify(input2)}`;
8327
+ }
8328
+ return reason ? `${detail} (${reason})` : detail;
8329
+ }
7028
8330
  // --- Prompt as AsyncIterable (supports follow-up messages) ---
7029
8331
  buildPromptIterable(initialPrompt) {
7030
8332
  return {
@@ -7059,8 +8361,8 @@ ${leftoverText}` : `${buildThirdPartyPrompt(this.skills)}${initialPrompt}`;
7059
8361
  return { value: void 0, done: true };
7060
8362
  }
7061
8363
  } else if (this._messageQueue.length === 0) {
7062
- await new Promise((resolve12) => {
7063
- this._messageResolve = resolve12;
8364
+ await new Promise((resolve13) => {
8365
+ this._messageResolve = resolve13;
7064
8366
  });
7065
8367
  continue;
7066
8368
  }
@@ -7300,9 +8602,9 @@ controller: ${answer}`,
7300
8602
  };
7301
8603
  }
7302
8604
  if (this._messageResolve) {
7303
- const resolve12 = this._messageResolve;
8605
+ const resolve13 = this._messageResolve;
7304
8606
  this._messageResolve = null;
7305
- resolve12();
8607
+ resolve13();
7306
8608
  }
7307
8609
  const prev = this.state.get("runtimeState");
7308
8610
  this.state.set("runtimeState", {
@@ -7329,6 +8631,154 @@ controller: ${answer}`,
7329
8631
 
7330
8632
  // src/services/tools/add-task.ts
7331
8633
  import { z as z3 } from "zod";
8634
+
8635
+ // src/services/tools/workspace-rules.ts
8636
+ import { readFileSync as readFileSync9, statSync as statSync3 } from "fs";
8637
+ import { join as join16 } from "path";
8638
+ var RULE_FILE_NAMES2 = ["AGENTS.md", "CLAUDE.md"];
8639
+ var WORKSPACE_RULES_MAX_LINES = 40;
8640
+ var WORKSPACE_RULES_MAX_CHARS = 2e3;
8641
+ function buildWorkspaceRulesSummary(input2) {
8642
+ const workspacePath = input2.workspacePath;
8643
+ if (!workspacePath) return "";
8644
+ for (const name of RULE_FILE_NAMES2) {
8645
+ const rulesPath = join16(workspacePath, name);
8646
+ if (!isRegularFile(rulesPath)) continue;
8647
+ const header = `Workspace rules (from ${rulesPath}):`;
8648
+ const refusal = describeRefusal(rulesPath, workspacePath, input2);
8649
+ if (refusal !== null) return `${header}
8650
+ ${refusal}`;
8651
+ const content = readTextFile(rulesPath);
8652
+ if (content === null) {
8653
+ return `${header}
8654
+ unavailable \u2014 the file exists but could not be read.`;
8655
+ }
8656
+ if (content.trim() === "") continue;
8657
+ return `${header}
8658
+ ${summarizeRules(content)}`;
8659
+ }
8660
+ return "";
8661
+ }
8662
+ function describeRefusal(rulesPath, workspacePath, input2) {
8663
+ const homeDir2 = input2.homeDir;
8664
+ if (!homeDir2) {
8665
+ return "unavailable \u2014 the permission policy cannot be evaluated here (no home directory in this context), so the file was not read.";
8666
+ }
8667
+ const { decision } = decideAccess({
8668
+ access: "read",
8669
+ path: rulesPath,
8670
+ cwd: workspacePath,
8671
+ homeDir: homeDir2,
8672
+ taskWorkspace: workspacePath,
8673
+ extraDeniedDirs: input2.permissionConfig?.deniedDirs
8674
+ });
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.";
8677
+ }
8678
+ if (decision === "ask") {
8679
+ 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.";
8680
+ }
8681
+ return null;
8682
+ }
8683
+ function summarizeRules(content) {
8684
+ const lines = content.replace(/\r\n/g, "\n").trimEnd().split("\n");
8685
+ let body = lines.slice(0, WORKSPACE_RULES_MAX_LINES).join("\n");
8686
+ const cutByChars = body.length > WORKSPACE_RULES_MAX_CHARS;
8687
+ if (cutByChars) body = body.slice(0, WORKSPACE_RULES_MAX_CHARS).trimEnd();
8688
+ const shownLines = body === "" ? 0 : body.split("\n").length;
8689
+ const remainingLines = lines.length - shownLines;
8690
+ if (remainingLines > 0) {
8691
+ const unit = remainingLines === 1 ? "line" : "lines";
8692
+ return `${body}
8693
+ ... truncated (${remainingLines} more ${unit})`;
8694
+ }
8695
+ if (cutByChars) {
8696
+ return `${body}
8697
+ ... truncated (content exceeded ${WORKSPACE_RULES_MAX_CHARS} characters)`;
8698
+ }
8699
+ return body;
8700
+ }
8701
+ function isRegularFile(p) {
8702
+ try {
8703
+ return statSync3(p).isFile();
8704
+ } catch {
8705
+ return false;
8706
+ }
8707
+ }
8708
+ function readTextFile(p) {
8709
+ try {
8710
+ return readFileSync9(p, "utf8");
8711
+ } catch {
8712
+ return null;
8713
+ }
8714
+ }
8715
+
8716
+ // src/services/tools/add-task.ts
8717
+ function resolveTaskWorkspace(requested, deps) {
8718
+ const current = deps.workspaceDir;
8719
+ const requestedPath = requested !== void 0 && requested.trim() !== "" ? normalizeWorkspacePath(requested) : void 0;
8720
+ if (!current) {
8721
+ if (requestedPath !== void 0) {
8722
+ return {
8723
+ error: `Cannot resolve workspace ${requestedPath}: the current workspace is unknown in this context, so the task was NOT created.`
8724
+ };
8725
+ }
8726
+ return {};
8727
+ }
8728
+ const targetPath = requestedPath ?? normalizeWorkspacePath(current);
8729
+ const ref = resolveWorkspaceRef(deps.wayangDir ?? null, targetPath);
8730
+ return { workspace: { path: ref.path, slug: ref.slug } };
8731
+ }
8732
+ function resolveTaskAllowedDirs(requested, workspacePath, deps) {
8733
+ if (requested === void 0 || requested.length === 0) return {};
8734
+ const homeDir2 = deps.homeDir;
8735
+ if (!homeDir2) {
8736
+ return {
8737
+ error: "Cannot validate allowedDirs: the home directory is unknown in this context, so the task was NOT created."
8738
+ };
8739
+ }
8740
+ const home = normalizePath(homeDir2);
8741
+ 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 }))
8745
+ ].map((d) => normalizePath(d));
8746
+ const seen = /* @__PURE__ */ new Set();
8747
+ const allowedDirs = [];
8748
+ for (const raw of requested) {
8749
+ if (typeof raw !== "string" || raw.trim() === "") {
8750
+ return {
8751
+ error: `Invalid allowedDirs entry ${JSON.stringify(raw)}: expected a non-empty directory path. The task was NOT created.`
8752
+ };
8753
+ }
8754
+ const resolved = normalizePath(raw, { base });
8755
+ if (!isPathWithin(home, resolved)) {
8756
+ return {
8757
+ 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
+ };
8759
+ }
8760
+ if (isDenied(resolved, deniedPaths)) {
8761
+ 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.`
8763
+ };
8764
+ }
8765
+ if (!seen.has(resolved)) {
8766
+ seen.add(resolved);
8767
+ allowedDirs.push(resolved);
8768
+ }
8769
+ }
8770
+ return allowedDirs.length > 0 ? { allowedDirs } : {};
8771
+ }
8772
+ function withWorkspaceRules(resultText, workspacePath, deps) {
8773
+ const summary = buildWorkspaceRulesSummary({
8774
+ workspacePath,
8775
+ homeDir: deps.homeDir,
8776
+ permissionConfig: deps.permissionConfig
8777
+ });
8778
+ return summary === "" ? resultText : `${resultText}
8779
+
8780
+ ${summary}`;
8781
+ }
7332
8782
  function addTaskTool(deps) {
7333
8783
  return defineTool({
7334
8784
  description: "Add a new task to the queue. A Worker will execute it automatically.",
@@ -7342,10 +8792,25 @@ function addTaskTool(deps) {
7342
8792
  ),
7343
8793
  assignToWorker: z3.string().optional().describe(
7344
8794
  "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
+ ),
8796
+ 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."
8798
+ ),
8799
+ allowedDirs: z3.array(z3.string()).optional().describe(
8800
+ "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."
7345
8801
  )
7346
8802
  }),
7347
- execute: safeExecute("add_task", async ({ title, description, priority, workerType, multiStage, assignToWorker }, options) => {
8803
+ execute: safeExecute("add_task", async ({ title, description, priority, workerType, multiStage, assignToWorker, workspace, allowedDirs }, options) => {
7348
8804
  const resolvedType = workerType || "puppet";
8805
+ const workspaceResolution = resolveTaskWorkspace(workspace, deps);
8806
+ if ("error" in workspaceResolution) return workspaceResolution.error;
8807
+ const allowedDirsResolution = resolveTaskAllowedDirs(
8808
+ allowedDirs,
8809
+ workspaceResolution.workspace?.path ?? deps.workspaceDir,
8810
+ deps
8811
+ );
8812
+ if ("error" in allowedDirsResolution) return allowedDirsResolution.error;
8813
+ const targetWorkspacePath = workspaceResolution.workspace?.path ?? deps.workspaceDir;
7349
8814
  if (deps.validateWorkerType) {
7350
8815
  const err = deps.validateWorkerType(resolvedType);
7351
8816
  if (err) return err;
@@ -7356,6 +8821,10 @@ function addTaskTool(deps) {
7356
8821
  }
7357
8822
  const err = deps.validateIdleWorker(assignToWorker);
7358
8823
  if (err) return err;
8824
+ if (targetWorkspacePath !== void 0 && deps.validateWorkerWorkspace) {
8825
+ const wsErr = deps.validateWorkerWorkspace(assignToWorker, targetWorkspacePath);
8826
+ if (wsErr) return wsErr;
8827
+ }
7359
8828
  }
7360
8829
  const toolCallId = options?.toolCallId;
7361
8830
  const turnCtx = deps.getTurnContext?.();
@@ -7369,6 +8838,8 @@ function addTaskTool(deps) {
7369
8838
  createdAt: Date.now(),
7370
8839
  multiStage,
7371
8840
  assignToWorker,
8841
+ ...workspaceResolution.workspace !== void 0 && { workspace: workspaceResolution.workspace },
8842
+ ...allowedDirsResolution.allowedDirs !== void 0 && { taskAllowedDirs: allowedDirsResolution.allowedDirs },
7372
8843
  ...turnCtx?.turnId !== void 0 && { turnId: turnCtx.turnId },
7373
8844
  ...turnCtx?.rootTurnId !== void 0 && { rootTurnId: turnCtx.rootTurnId },
7374
8845
  ...(toolCallId ?? turnCtx?.causationId) !== void 0 && { causationId: toolCallId ?? turnCtx?.causationId }
@@ -7377,12 +8848,27 @@ function addTaskTool(deps) {
7377
8848
  const ok = deps.assignToWorker(assignToWorker, task);
7378
8849
  if (ok) deps.onTaskDispatched?.({ toolCallId, taskId: task.id });
7379
8850
  else return `Worker ${assignToWorker} could not accept the task (no longer idle?).`;
7380
- return `Task ${task.id} assigned to worker ${assignToWorker} (reusing its context, priority: ${priority}, worker: ${resolvedType}). You will be notified when the task completes.`;
8851
+ return withWorkspaceRules(
8852
+ `Task ${task.id} assigned to worker ${assignToWorker} (reusing its context, priority: ${priority}, worker: ${resolvedType}). You will be notified when the task completes.`,
8853
+ targetWorkspacePath,
8854
+ deps
8855
+ );
7381
8856
  }
7382
- deps.addTask(task);
8857
+ const workerId = deps.addTask(task);
7383
8858
  deps.onTaskDispatched?.({ toolCallId, taskId: task.id });
7384
8859
  const stageNote = multiStage ? " [multi-stage: worker will idle after completion]" : "";
7385
- return `Task ${task.id} added (priority: ${priority}, worker: ${task.workerType})${stageNote}. You will be notified when the task completes.`;
8860
+ if (workerId !== void 0) {
8861
+ return withWorkspaceRules(
8862
+ `Task ${task.id} added (priority: ${priority}, workerType: ${task.workerType}, workerId: ${workerId})${stageNote}. The worker has started; you will be notified of progress and completion.`,
8863
+ targetWorkspacePath,
8864
+ deps
8865
+ );
8866
+ }
8867
+ return withWorkspaceRules(
8868
+ `Task ${task.id} added (priority: ${priority}, workerType: ${task.workerType})${stageNote}. All concurrency slots are busy \u2014 the task is queued and will start automatically when a slot frees (a STARTED signal will name its worker). You will be notified when the task completes.`,
8869
+ targetWorkspacePath,
8870
+ deps
8871
+ );
7386
8872
  })
7387
8873
  });
7388
8874
  }
@@ -7398,9 +8884,11 @@ function listTasksTool(deps) {
7398
8884
  execute: safeExecute("list_tasks", async ({ status }) => {
7399
8885
  const tasks = deps.listTasks(status);
7400
8886
  if (tasks.length === 0) return status ? `No ${status} tasks` : "No tasks";
7401
- return tasks.map(
7402
- (t) => `[${t.status}] ${t.id}: ${t.description}${t.result ? ` \u2192 ${t.result}` : ""}${t.error ? ` \u2717 ${t.error}` : ""}`
7403
- ).join("\n");
8887
+ return tasks.map((t) => {
8888
+ const scope = t.workspace ? `, ws: ${t.workspace.slug}` : "";
8889
+ const dirs = t.taskAllowedDirs && t.taskAllowedDirs.length > 0 ? `, dirs: ${t.taskAllowedDirs.join(", ")}` : "";
8890
+ return `[${t.status}] ${t.id} (worker: ${t.workerSessionId ?? "none"}${scope}${dirs}): ${t.description}${t.result ? ` \u2192 ${t.result}` : ""}${t.error ? ` \u2717 ${t.error}` : ""}`;
8891
+ }).join("\n");
7404
8892
  })
7405
8893
  });
7406
8894
  }
@@ -7471,6 +8959,10 @@ function getTaskDetailTool(deps) {
7471
8959
  if (task.result) lines.push(`Result: ${task.result}`);
7472
8960
  if (task.error) lines.push(`Error: ${task.error}`);
7473
8961
  if (task.workerSessionId) lines.push(`Worker: ${task.workerSessionId}`);
8962
+ if (task.workspace) lines.push(`Workspace: ${task.workspace.path} (slug: ${task.workspace.slug})`);
8963
+ if (task.taskAllowedDirs && task.taskAllowedDirs.length > 0) {
8964
+ lines.push(`Allowed dirs: ${task.taskAllowedDirs.join(", ")}`);
8965
+ }
7474
8966
  const isFull = full === true;
7475
8967
  const requestedLimit = limit ?? DEFAULT_LIMIT;
7476
8968
  let entries = [];
@@ -7870,8 +9362,8 @@ ${detail}`;
7870
9362
 
7871
9363
  // src/services/tools/read-file.ts
7872
9364
  import { z as z14 } from "zod";
7873
- import { readFileSync as readFileSync7 } from "fs";
7874
- import { resolve as resolve3 } from "path";
9365
+ import { readFileSync as readFileSync10 } from "fs";
9366
+ import { resolve as resolve6 } from "path";
7875
9367
  var DEFAULT_MAX_LINES = 500;
7876
9368
  function readFileTool(deps = {}) {
7877
9369
  const workspace = deps.cwd ?? process.cwd();
@@ -7882,9 +9374,9 @@ function readFileTool(deps = {}) {
7882
9374
  offset: z14.number().int().min(1).optional().describe("Start line number (1-based)"),
7883
9375
  limit: z14.number().int().min(1).optional().describe("Number of lines to read")
7884
9376
  }),
7885
- execute: safeExecute("read_file", async ({ path: path7, offset, limit }) => {
7886
- const resolved = resolve3(workspace, path7);
7887
- const content = readFileSync7(resolved, "utf-8");
9377
+ execute: safeExecute("read_file", async ({ path: path9, offset, limit }) => {
9378
+ const resolved = resolve6(workspace, path9);
9379
+ const content = readFileSync10(resolved, "utf-8");
7888
9380
  const lines = content.split("\n");
7889
9381
  const totalLines = lines.length;
7890
9382
  const start = offset ? offset - 1 : 0;
@@ -7904,8 +9396,8 @@ function readFileTool(deps = {}) {
7904
9396
 
7905
9397
  // src/services/tools/write-file.ts
7906
9398
  import { z as z15 } from "zod";
7907
- import { writeFileSync as writeFileSync3, mkdirSync as mkdirSync4, existsSync as existsSync7 } from "fs";
7908
- import { dirname as dirname5, resolve as resolve4 } from "path";
9399
+ import { writeFileSync as writeFileSync4, mkdirSync as mkdirSync6, existsSync as existsSync10 } from "fs";
9400
+ import { dirname as dirname9, resolve as resolve7 } from "path";
7909
9401
  function writeFileTool(deps = {}) {
7910
9402
  const workspace = deps.cwd ?? process.cwd();
7911
9403
  return defineTool({
@@ -7914,21 +9406,21 @@ function writeFileTool(deps = {}) {
7914
9406
  path: z15.string().describe("File path (relative to workspace or absolute)"),
7915
9407
  content: z15.string().describe("File content")
7916
9408
  }),
7917
- execute: safeExecute("write_file", async ({ path: path7, content }) => {
7918
- const resolved = resolve4(workspace, path7);
7919
- if (existsSync7(resolved)) {
7920
- return `[ERROR] write_file: File already exists: ${path7}. Use edit_file to modify existing files.`;
9409
+ execute: safeExecute("write_file", async ({ path: path9, content }) => {
9410
+ const resolved = resolve7(workspace, path9);
9411
+ if (existsSync10(resolved)) {
9412
+ return `[ERROR] write_file: File already exists: ${path9}. Use edit_file to modify existing files.`;
7921
9413
  }
7922
- mkdirSync4(dirname5(resolved), { recursive: true });
7923
- writeFileSync3(resolved, content, "utf-8");
9414
+ mkdirSync6(dirname9(resolved), { recursive: true });
9415
+ writeFileSync4(resolved, content, "utf-8");
7924
9416
  return `Written ${content.length} chars to ${resolved}`;
7925
9417
  })
7926
9418
  });
7927
9419
  }
7928
9420
 
7929
9421
  // src/services/tools/edit-file.ts
7930
- import { readFileSync as readFileSync8, writeFileSync as writeFileSync4 } from "fs";
7931
- import { resolve as resolve5 } from "path";
9422
+ import { readFileSync as readFileSync11, writeFileSync as writeFileSync5 } from "fs";
9423
+ import { resolve as resolve8 } from "path";
7932
9424
  import { z as z16 } from "zod";
7933
9425
  function editFileTool(deps) {
7934
9426
  const workspace = deps.cwd ?? process.cwd();
@@ -7939,40 +9431,55 @@ function editFileTool(deps) {
7939
9431
  old_string: z16.string().describe("Exact text to find (must be unique in file)"),
7940
9432
  new_string: z16.string().describe("Replacement text")
7941
9433
  }),
7942
- execute: safeExecute("edit_file", async ({ path: path7, old_string, new_string }) => {
7943
- const resolved = resolve5(workspace, path7);
9434
+ execute: safeExecute("edit_file", async ({ path: path9, old_string, new_string }) => {
9435
+ const resolved = resolve8(workspace, path9);
7944
9436
  let content;
7945
9437
  try {
7946
- content = readFileSync8(resolved, "utf-8");
9438
+ content = readFileSync11(resolved, "utf-8");
7947
9439
  } catch {
7948
- return `[ERROR] edit_file: File not found: ${path7}`;
9440
+ return `[ERROR] edit_file: File not found: ${path9}`;
7949
9441
  }
7950
9442
  const count = content.split(old_string).length - 1;
7951
9443
  if (count === 0)
7952
- return `[ERROR] edit_file: old_string not found in ${path7}, read the file content and provide a correct old_string.`;
9444
+ return `[ERROR] edit_file: old_string not found in ${path9}, read the file content and provide a correct old_string.`;
7953
9445
  if (count > 1)
7954
- return `[ERROR] edit_file: old_string found ${count} times in ${path7}. Provide more surrounding context to make it unique.`;
9446
+ return `[ERROR] edit_file: old_string found ${count} times in ${path9}. Provide more surrounding context to make it unique.`;
7955
9447
  const newContent = content.replace(old_string, new_string);
7956
- writeFileSync4(resolved, newContent, "utf-8");
7957
- return `Replaced ${old_string.length} chars with ${new_string.length} chars in ${path7}`;
9448
+ writeFileSync5(resolved, newContent, "utf-8");
9449
+ return `Replaced ${old_string.length} chars with ${new_string.length} chars in ${path9}`;
7958
9450
  })
7959
9451
  });
7960
9452
  }
7961
9453
 
7962
9454
  // src/services/tools/search-files.ts
7963
9455
  import fg from "fast-glob";
7964
- import { resolve as resolve6 } from "path";
7965
9456
  import { z as z17 } from "zod";
7966
9457
  function searchFilesTool(deps) {
7967
9458
  const workspace = deps.cwd ?? process.cwd();
9459
+ const homeDir2 = deps.homeDir;
7968
9460
  return defineTool({
7969
- description: "Search for files matching a glob pattern. Returns matching file paths relative to workspace.",
9461
+ description: "Search for files matching a glob pattern. Returns matching file paths relative to workspace. Reads are restricted to the home directory; sensitive locations (credentials, tool config) are blocked.",
7970
9462
  parameters: z17.object({
7971
9463
  pattern: z17.string().describe('Glob pattern (e.g. "**/*.ts", "src/**/*.test.ts")'),
7972
9464
  path: z17.string().optional().describe("Base directory for search (default: workspace root)")
7973
9465
  }),
7974
- execute: safeExecute("search_files", async ({ pattern, path: path7 }) => {
7975
- const basePath = path7 ? resolve6(workspace, path7) : workspace;
9466
+ execute: safeExecute("search_files", async ({ pattern, path: path9 }) => {
9467
+ const basePath = path9 ? normalizePath(path9, { base: workspace }) : normalizePath(workspace);
9468
+ if (homeDir2) {
9469
+ const { decision } = decideAccess({
9470
+ access: "read",
9471
+ homeDir: homeDir2,
9472
+ cwd: workspace,
9473
+ path: basePath
9474
+ });
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).`;
9478
+ }
9479
+ if (decision === "ask") {
9480
+ 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.`;
9481
+ }
9482
+ }
7976
9483
  const entries = await fg(pattern, {
7977
9484
  cwd: basePath,
7978
9485
  onlyFiles: true,
@@ -7986,7 +9493,7 @@ function searchFilesTool(deps) {
7986
9493
 
7987
9494
  // src/services/tools/search-content.ts
7988
9495
  import { execSync as execSync3 } from "child_process";
7989
- import { resolve as resolve7 } from "path";
9496
+ import { resolve as resolve9 } from "path";
7990
9497
  import { z as z18 } from "zod";
7991
9498
  function shellQuote(s) {
7992
9499
  return `'${s.replace(/'/g, "'\\''")}'`;
@@ -8000,8 +9507,8 @@ function searchContentTool(deps) {
8000
9507
  path: z18.string().optional().describe("Directory to search in (default: workspace root)"),
8001
9508
  include: z18.string().optional().describe('File pattern to include (e.g. "*.ts", "*.py")')
8002
9509
  }),
8003
- execute: safeExecute("search_content", async ({ query: query2, path: path7, include }) => {
8004
- const searchDir = path7 ? resolve7(workspace, path7) : workspace;
9510
+ execute: safeExecute("search_content", async ({ query: query2, path: path9, include }) => {
9511
+ const searchDir = path9 ? resolve9(workspace, path9) : workspace;
8005
9512
  const cmd = `grep -rn${include ? ` --include=${shellQuote(include)}` : ""} -- ${shellQuote(query2)} ${shellQuote(searchDir)}`;
8006
9513
  try {
8007
9514
  const output = execSync3(cmd, {
@@ -8259,8 +9766,13 @@ function createControllerTools(deps) {
8259
9766
  validateWorkerType: deps.validateWorkerType,
8260
9767
  assignToWorker: deps.assignToWorker,
8261
9768
  validateIdleWorker: deps.validateIdleWorker,
9769
+ validateWorkerWorkspace: deps.validateWorkerWorkspace,
8262
9770
  getTurnContext: deps.getTurnContext,
8263
- onTaskDispatched: deps.onTaskDispatched
9771
+ onTaskDispatched: deps.onTaskDispatched,
9772
+ workspaceDir: deps.workspaceDir,
9773
+ wayangDir: deps.wayangDir,
9774
+ homeDir: deps.homeDir,
9775
+ permissionConfig: deps.permissionConfig
8264
9776
  }),
8265
9777
  list_tasks: listTasksTool({ listTasks: deps.listTasks }),
8266
9778
  cancel_task: cancelTaskTool({
@@ -8301,7 +9813,7 @@ function createControllerTools(deps) {
8301
9813
  skip_reply: skipReplyTool(),
8302
9814
  read_notebook: readNotebookTool({ getNotebook: deps.getNotebook }),
8303
9815
  update_notebook: updateNotebookTool({ setNotebook: deps.setNotebook }),
8304
- search_files: searchFilesTool({ cwd: deps.cwd }),
9816
+ search_files: searchFilesTool({ cwd: deps.cwd, homeDir: deps.homeDir }),
8305
9817
  inquire: inquireTool({ inquire: deps.inquire }),
8306
9818
  chat_worker: chatWorkerTool({
8307
9819
  sendMessageToWorker: deps.sendMessageToWorker
@@ -8361,11 +9873,16 @@ function wrapWithPermissionMiddleware(tools, config, deps) {
8361
9873
  wrappedTools[toolName] = {
8362
9874
  ...tools[toolName],
8363
9875
  execute: async (args, options) => {
8364
- if (!config.needsPermission(toolName, args)) {
9876
+ const verdict = config.decide(toolName, args);
9877
+ if (verdict.decision === "allow") {
8365
9878
  return originalExecute(args, options);
8366
9879
  }
9880
+ if (verdict.decision === "deny") {
9881
+ return verdict.message ?? `[ERROR] ${toolName}: refused by the path permission policy`;
9882
+ }
8367
9883
  const requestId = generateId("perm");
8368
- const description = config.describeCall(toolName, args);
9884
+ const baseDescription = config.describeCall(toolName, args);
9885
+ const description = verdict.reason ? `${baseDescription} [reason: ${verdict.reason}]` : baseDescription;
8369
9886
  deps.signalQueue.enqueue({
8370
9887
  source: "worker",
8371
9888
  type: "permission_request",
@@ -8382,16 +9899,16 @@ function wrapWithPermissionMiddleware(tools, config, deps) {
8382
9899
  },
8383
9900
  ...deps.turnLineage ?? {}
8384
9901
  });
8385
- const result2 = await new Promise((resolve12) => {
9902
+ const result2 = await new Promise((resolve13) => {
8386
9903
  const timer = setTimeout(() => {
8387
9904
  pending.delete(requestId);
8388
- resolve12({ approved: false, reason: "timeout" });
9905
+ resolve13({ approved: false, reason: "timeout" });
8389
9906
  }, timeoutMs);
8390
9907
  pending.set(requestId, {
8391
9908
  resolve: (ok, reason) => {
8392
9909
  clearTimeout(timer);
8393
9910
  pending.delete(requestId);
8394
- resolve12({ approved: ok, reason });
9911
+ resolve13({ approved: ok, reason });
8395
9912
  },
8396
9913
  timer
8397
9914
  });
@@ -8427,38 +9944,6 @@ function wrapWithPermissionMiddleware(tools, config, deps) {
8427
9944
  };
8428
9945
  }
8429
9946
 
8430
- // src/services/tools/permission-rules.ts
8431
- import { resolve as resolve8 } from "path";
8432
- import { homedir } from "os";
8433
- var FILE_TOOL_NAMES = ["read_file", "write_file", "edit_file", "search_files", "search_content"];
8434
- var DANGEROUS_BASH_PATTERN = /(?:^|\||;|&&)\s*(?:rm\s|sudo\b|chmod\b|chown\b|mkfs\b|dd\s|shutdown\b|reboot\b|kill\s+-9)/;
8435
- var URL_RE = /[a-zA-Z][a-zA-Z0-9+.-]*:\/\/\S+/g;
8436
- function expandHome(p) {
8437
- if (p === "~") return homedir();
8438
- if (p.startsWith("~/")) return resolve8(homedir(), p.slice(2));
8439
- return p;
8440
- }
8441
- function extractPathCandidates(command) {
8442
- const withoutUrls = command.replace(URL_RE, " ");
8443
- const candidates = [];
8444
- for (const raw of withoutUrls.split(/[\s=;|&]+/)) {
8445
- const word = raw.replace(/^['"]+/, "").replace(/['",;|&()<>'`]+$/, "");
8446
- if (word.startsWith("/") || word.startsWith("~")) candidates.push(word);
8447
- }
8448
- return candidates;
8449
- }
8450
- function needsFilePermission(workspace, args) {
8451
- const rawPath = args.path;
8452
- if (typeof rawPath !== "string" || !rawPath) return false;
8453
- return !isInsideWorkspace(resolve8(workspace, expandHome(rawPath)), workspace);
8454
- }
8455
- function needsBashPermission(workspace, command) {
8456
- if (DANGEROUS_BASH_PATTERN.test(command)) return true;
8457
- return extractPathCandidates(command).some(
8458
- (p) => !isInsideWorkspace(expandHome(p), workspace)
8459
- );
8460
- }
8461
-
8462
9947
  // src/utils/llm-error.ts
8463
9948
  function formatLlmError(err) {
8464
9949
  if (!(err instanceof Error)) return String(err);
@@ -8499,7 +9984,8 @@ function formatApiCallError(err) {
8499
9984
  }
8500
9985
 
8501
9986
  // src/services/task-execute-engine.ts
8502
- import { resolve as resolve9 } from "path";
9987
+ import { basename as basename8, dirname as dirname10, resolve as resolve10 } from "path";
9988
+ import { homedir as homedir2 } from "os";
8503
9989
  var RuntimeState = class extends BaseWayangState {
8504
9990
  constructor(initialData) {
8505
9991
  super(initialData, []);
@@ -8508,13 +9994,20 @@ var RuntimeState = class extends BaseWayangState {
8508
9994
  }
8509
9995
  };
8510
9996
  var PUPPET_WORKER_TYPE = "puppet";
9997
+ function resolveUserHomeDir(sessionDir) {
9998
+ const wayangDir = wayangDirForSessionDir(sessionDir);
9999
+ return wayangDir && basename8(wayangDir) === ".wayang" ? dirname10(wayangDir) : homedir2();
10000
+ }
8511
10001
  var DEFAULT_IDLE_TIMEOUT_MS = 30 * 60 * 1e3;
8512
10002
  var INQUIRY_TIMEOUT_MS = 3e5;
8513
10003
  var TaskExecuteEngine = class {
8514
- constructor(ctx, signalQueue, skills) {
10004
+ constructor(ctx, signalQueue, skills, options = {}) {
8515
10005
  this.ctx = ctx;
8516
10006
  this.signalQueue = signalQueue;
8517
10007
  this.logger = ctx.logger;
10008
+ this.homeDir = options.homeDir ?? resolveUserHomeDir(ctx.sessionDir);
10009
+ this.permission = options.permission ?? ctx.config.permission;
10010
+ this.onTaskWorkspace = options.onTaskWorkspace;
8518
10011
  this.maxConcurrency = ctx.maxConcurrency;
8519
10012
  this.workerProvider = ctx.workerProvider;
8520
10013
  this.workerConfigs = ctx.config.workers;
@@ -8563,28 +10056,48 @@ var TaskExecuteEngine = class {
8563
10056
  idleTimers = /* @__PURE__ */ new Map();
8564
10057
  /** Idle TTL (ms). Idle workers are disposed after this if not reused. */
8565
10058
  idleTimeoutMs;
10059
+ /** The user's home directory the path permission policy anchors on. */
10060
+ homeDir;
10061
+ /** User-config permission section applied to this run's worker tools. */
10062
+ permission;
10063
+ /** Session-side hook: remember every workspace a task is accepted for. */
10064
+ onTaskWorkspace;
8566
10065
  // ---------------------------------------------------------------------------
8567
10066
  // Subscribable
8568
10067
  // ---------------------------------------------------------------------------
8569
- subscribe(path7, callback) {
8570
- if (path7.startsWith("tasks.")) return this.taskState.on(path7, callback);
8571
- if (path7 === "activeWorkers" || path7.startsWith("activeWorkers.")) return this.workerState.on(path7, callback);
8572
- throw new Error(`TaskExecuteEngine: unknown subscription path "${path7}"`);
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}"`);
8573
10072
  }
8574
- getSnapshot(path7) {
8575
- if (path7.startsWith("tasks.")) return this.taskState.get(path7);
8576
- if (path7 === "activeWorkers" || path7.startsWith("activeWorkers.")) return this.workerState.get(path7);
8577
- throw new Error(`TaskExecuteEngine: unknown snapshot path "${path7}"`);
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}"`);
8578
10077
  }
8579
10078
  // ---------------------------------------------------------------------------
8580
10079
  // Task management
8581
10080
  // ---------------------------------------------------------------------------
8582
- /** Add a task and immediately attempt to schedule it. */
10081
+ /**
10082
+ * Add a task and immediately attempt to schedule it.
10083
+ *
10084
+ * Returns the engine worker id when the task was scheduled right away (a
10085
+ * worker_started signal follows), or undefined when every concurrency slot
10086
+ * is busy — in that case a task_queued signal is emitted exactly once so the
10087
+ * controller can tell "waiting for a slot" from "running" (issue #30). The
10088
+ * task starts automatically when a slot frees.
10089
+ */
8583
10090
  add(task) {
8584
10091
  this.taskState.append("tasks.pending", task);
10092
+ this.recordTaskWorkspace(task);
8585
10093
  this.logger.info({ taskId: task.id }, "Task added");
8586
10094
  this.ctx.hooks.emit("task:added", task);
8587
10095
  this.scheduleNext();
10096
+ const running = this.taskState.get("tasks.running");
10097
+ const started = running.find((t) => t.id === task.id);
10098
+ if (started?.workerSessionId) return started.workerSessionId;
10099
+ this.emitTaskQueued(task);
10100
+ return void 0;
8588
10101
  }
8589
10102
  get(id) {
8590
10103
  const all = [
@@ -8666,6 +10179,55 @@ var TaskExecuteEngine = class {
8666
10179
  causationId: task.id
8667
10180
  });
8668
10181
  }
10182
+ /**
10183
+ * Announce that a task is stuck in the pending queue — every concurrency
10184
+ * slot is busy (issue #30). Emitted at most once per task (from add(), the
10185
+ * only entry point into the pending queue). No worker exists yet, so the
10186
+ * payload carries task identity only; a later worker_started signal names
10187
+ * the worker that eventually picks the task up.
10188
+ */
10189
+ emitTaskQueued(task) {
10190
+ const workerType = task.workerType ?? PUPPET_WORKER_TYPE;
10191
+ const meta = getWorkerMeta(workerType, this.workerConfigs);
10192
+ const payload = {
10193
+ taskId: task.id,
10194
+ taskTitle: task.title,
10195
+ workerType,
10196
+ emoji: meta.emoji,
10197
+ message: `All concurrency slots are busy \u2014 the task is queued and will start automatically when a slot frees.`
10198
+ };
10199
+ this.signalQueue.enqueue({
10200
+ source: "system",
10201
+ type: "task_queued",
10202
+ payload,
10203
+ ...(task.rootTurnId ?? task.turnId) !== void 0 && { rootTurnId: task.rootTurnId ?? task.turnId },
10204
+ causationId: task.id
10205
+ });
10206
+ }
10207
+ /**
10208
+ * Announce that a worker actually began a task (issue #30): fresh spawn or
10209
+ * idle-worker reuse (`reused`). Called after the worker is registered and
10210
+ * its execution turn recorded, so the signal carries the worker's lineage.
10211
+ * If creating the worker instance throws, no started signal is emitted —
10212
+ * the existing failed signal (handleFail) is the only outcome.
10213
+ */
10214
+ emitWorkerStarted(task, workerId, workerType, reused) {
10215
+ const meta = getWorkerMeta(workerType, this.workerConfigs);
10216
+ const payload = {
10217
+ taskId: task.id,
10218
+ workerId,
10219
+ taskTitle: task.title,
10220
+ workerType,
10221
+ emoji: meta.emoji,
10222
+ message: reused ? "Idle multi-stage worker reused for this stage (it kept its context)." : "Worker spawned and started the task.",
10223
+ reused
10224
+ };
10225
+ this.enqueueWorkerSignal(workerId, {
10226
+ source: "worker",
10227
+ type: "worker_started",
10228
+ payload
10229
+ });
10230
+ }
8669
10231
  updatePending(id, updates) {
8670
10232
  const pending = [...this.taskState.get("tasks.pending")];
8671
10233
  const idx = pending.findIndex((t) => t.id === id);
@@ -8703,6 +10265,39 @@ var TaskExecuteEngine = class {
8703
10265
  if (info.status !== "idle") return `Worker "${workerId}" is not idle (status: ${info.status}).`;
8704
10266
  return null;
8705
10267
  }
10268
+ /**
10269
+ * Validate that an idle worker may take a task executing in `workspacePath`.
10270
+ * Returns an error message or null.
10271
+ *
10272
+ * A worker's workspace is fixed at construction: its state identity, the
10273
+ * relative-path assumptions in its accumulated conversation, and the identity
10274
+ * stamped on its records all belong to the workspace it was born in. Reuse
10275
+ * across workspaces would silently re-point only the RUN (cwd, tools,
10276
+ * permission gate) at the new workspace while the worker keeps the old
10277
+ * identity — so both sides are canonicalized (realpath, resolve fallback)
10278
+ * and compared before any reuse. A mismatch is never resolved by re-stamping
10279
+ * the worker; the caller must create a new one.
10280
+ *
10281
+ * Two consumers: `assignToExistingWorker` (the single reuse choke point, so
10282
+ * no caller can bypass it) and the add_task tool's pre-check (which turns the
10283
+ * rejection into an explicit instruction instead of a generic failure).
10284
+ * `workspacePath` falls back to the process workspace, mirroring
10285
+ * `taskWorkspaceDir` for tasks without a workspace stamp.
10286
+ */
10287
+ validateWorkerWorkspace(workerId, workspacePath) {
10288
+ const worker = this.workers.get(workerId);
10289
+ if (!worker) return `Worker "${workerId}" not found.`;
10290
+ const workerWorkspace = worker.workspaceDir;
10291
+ if (!workerWorkspace) {
10292
+ return `Worker "${workerId}" does not report the workspace it was created in, so reuse cannot be verified across workspaces. Create a new worker instead (drop assignToWorker).`;
10293
+ }
10294
+ const createdIn = normalizeWorkspacePath(workerWorkspace);
10295
+ const target = normalizeWorkspacePath(workspacePath ?? this.ctx.workspaceDir);
10296
+ if (createdIn !== target) {
10297
+ return `Worker "${workerId}" was created in workspace ${createdIn} and cannot take a task in ${target}. Create a new worker for that workspace instead (drop assignToWorker).`;
10298
+ }
10299
+ return null;
10300
+ }
8706
10301
  getWorkerState(workerId) {
8707
10302
  const worker = this.workers.get(workerId);
8708
10303
  return worker ?? null;
@@ -8945,8 +10540,20 @@ var TaskExecuteEngine = class {
8945
10540
  // ---------------------------------------------------------------------------
8946
10541
  // Persistence
8947
10542
  // ---------------------------------------------------------------------------
8948
- async restore() {
10543
+ /**
10544
+ * Restore persisted tasks and recover from a crashed run.
10545
+ *
10546
+ * @param fallbackWorkspace The resumed session's workspace identity. Tasks
10547
+ * written before workspace attribution existed have no `workspace`, so it
10548
+ * is filled in from here (memory only) before dispatch — otherwise
10549
+ * `taskWorkspaceDir` would fall back to the process workspace, which for a
10550
+ * resumed session may be an entirely different directory.
10551
+ */
10552
+ async restore(fallbackWorkspace) {
8949
10553
  await this.taskState.restore();
10554
+ if (fallbackWorkspace) {
10555
+ this.taskState.fillWorkspaceInMemory(fallbackWorkspace);
10556
+ }
8950
10557
  this.recoverCrashedTasks();
8951
10558
  }
8952
10559
  // ---------------------------------------------------------------------------
@@ -9106,10 +10713,40 @@ var TaskExecuteEngine = class {
9106
10713
  // ---------------------------------------------------------------------------
9107
10714
  // Private: worker spawn + lifecycle
9108
10715
  // ---------------------------------------------------------------------------
9109
- createWorker(workerType) {
10716
+ /**
10717
+ * Workspace a task executes in.
10718
+ *
10719
+ * `task.workspace.path` is canonical (add_task canonicalizes through
10720
+ * realpath), so it is used verbatim — worker construction, tool cwd and the
10721
+ * permission-gate boundary all read this single value, never a path
10722
+ * re-assembled from the process workspace. Tasks without a workspace stamp
10723
+ * (written before workspaces existed, restored from an older session, or
10724
+ * dispatched internally) fall back to the process workspace.
10725
+ */
10726
+ taskWorkspaceDir(task) {
10727
+ return task.workspace?.path ?? this.ctx.workspaceDir;
10728
+ }
10729
+ /**
10730
+ * Tell the session a task was accepted for `task.workspace`, so a session
10731
+ * accumulates every workspace it dispatches work into. Best-effort: recording
10732
+ * the identity is bookkeeping, never a precondition for running the task, so
10733
+ * a failure (e.g. an unreadable registry) is logged and swallowed rather than
10734
+ * failing an accepted task. Tasks without a workspace stamp run in the
10735
+ * process workspace, which the session already records — nothing to add.
10736
+ */
10737
+ recordTaskWorkspace(task) {
10738
+ if (!this.onTaskWorkspace || !task.workspace) return;
10739
+ try {
10740
+ this.onTaskWorkspace(task.workspace);
10741
+ } catch (err) {
10742
+ this.logger.warn({ taskId: task.id, err }, "Failed to record task workspace on the session");
10743
+ }
10744
+ }
10745
+ createWorker(workerType, task) {
9110
10746
  const type = workerType ?? PUPPET_WORKER_TYPE;
10747
+ const workspaceDir2 = this.taskWorkspaceDir(task);
9111
10748
  if (type === PUPPET_WORKER_TYPE) {
9112
- return new WorkerAgent(this.workerProvider, this.ctx, this.skills, this.ctx.config.worker.toolChoice);
10749
+ return new WorkerAgent(this.workerProvider, this.ctx, this.skills, this.ctx.config.worker.toolChoice, workspaceDir2);
9113
10750
  }
9114
10751
  const config = this.workerConfigs?.[type];
9115
10752
  if (!config) {
@@ -9120,18 +10757,19 @@ var TaskExecuteEngine = class {
9120
10757
  }
9121
10758
  switch (config.type) {
9122
10759
  case "claude-code":
9123
- return new ClaudeCodeWorker(config, this.ctx.sessionDir, this.ctx.workspaceDir, this.ctx, this.skills);
10760
+ return new ClaudeCodeWorker(config, this.ctx.sessionDir, workspaceDir2, this.ctx, this.skills);
9124
10761
  default:
9125
10762
  throw new Error(`Unsupported worker type: "${config.type}"`);
9126
10763
  }
9127
10764
  }
9128
10765
  async spawnWorker(task, workerId) {
9129
10766
  const workerType = task.workerType ?? PUPPET_WORKER_TYPE;
9130
- const worker = this.createWorker(workerType);
10767
+ const worker = this.createWorker(workerType, task);
9131
10768
  this.workers.set(workerId, worker);
9132
10769
  this.workerTaskMap.set(workerId, task.id);
9133
10770
  this.stampWorkerTranscriptId(task.id, worker.id);
9134
10771
  const executionTurnId = this.registerWorkerTurn(workerId, task);
10772
+ this.emitWorkerStarted(task, workerId, workerType, false);
9135
10773
  const onProgress = this.buildProgressEmitter(workerId, task, workerType);
9136
10774
  const tools = this.wireWorker(worker, task, workerId, workerType);
9137
10775
  this.logger.info({ workerId, taskId: task.id, workerType, executionTurnId }, "Worker starting");
@@ -9140,7 +10778,8 @@ var TaskExecuteEngine = class {
9140
10778
  /**
9141
10779
  * Dispatch a follow-up task to an existing idle (multi-stage) worker.
9142
10780
  * Reuses the worker instance + its accumulated conversation context.
9143
- * Returns false if the worker is missing or not idle.
10781
+ * Returns false if the worker is missing, not idle, or was created in a
10782
+ * different workspace than the task executes in.
9144
10783
  */
9145
10784
  assignToExistingWorker(workerId, task) {
9146
10785
  const err = this.validateIdleWorker(workerId);
@@ -9153,6 +10792,15 @@ var TaskExecuteEngine = class {
9153
10792
  this.logger.warn({ workerId }, "Idle worker instance missing");
9154
10793
  return false;
9155
10794
  }
10795
+ const taskWorkspace = this.taskWorkspaceDir(task);
10796
+ const workspaceErr = this.validateWorkerWorkspace(workerId, taskWorkspace);
10797
+ if (workspaceErr) {
10798
+ this.logger.warn(
10799
+ { workerId, taskId: task.id, taskWorkspace, err: workspaceErr },
10800
+ "Refusing to reuse a worker across workspaces"
10801
+ );
10802
+ return false;
10803
+ }
9156
10804
  const workerType = task.workerType ?? PUPPET_WORKER_TYPE;
9157
10805
  const meta = getWorkerMeta(workerType, this.workerConfigs);
9158
10806
  this.clearIdleTimer(workerId);
@@ -9166,8 +10814,10 @@ var TaskExecuteEngine = class {
9166
10814
  workerTranscriptId: worker.id
9167
10815
  };
9168
10816
  this.taskState.append("tasks.running", taskWithOrigin);
10817
+ this.recordTaskWorkspace(taskWithOrigin);
9169
10818
  this.workerTaskMap.set(workerId, task.id);
9170
10819
  const executionTurnId = this.registerWorkerTurn(workerId, taskWithOrigin);
10820
+ this.emitWorkerStarted(task, workerId, workerType, true);
9171
10821
  this.updateActiveWorker(workerId, {
9172
10822
  status: "running",
9173
10823
  taskId: task.id,
@@ -9217,6 +10867,13 @@ var TaskExecuteEngine = class {
9217
10867
  */
9218
10868
  wireWorker(worker, task, workerId, workerType) {
9219
10869
  const workerMeta = getWorkerMeta(workerType, this.workerConfigs);
10870
+ const workspace = this.taskWorkspaceDir(task);
10871
+ const accessScope = {
10872
+ homeDir: this.homeDir,
10873
+ workspace,
10874
+ taskAllowedDirs: task.taskAllowedDirs,
10875
+ permission: this.permission
10876
+ };
9220
10877
  const inquiryRegistry = new BlockingRequestMap();
9221
10878
  this.inquiryRegistries.set(workerId, inquiryRegistry);
9222
10879
  const askAndWait = async (question, context) => {
@@ -9252,7 +10909,7 @@ var TaskExecuteEngine = class {
9252
10909
  let tools = workerType === PUPPET_WORKER_TYPE ? {
9253
10910
  ...createWorkerTools({
9254
10911
  listTasks: (status) => this.list(status),
9255
- cwd: this.ctx.workspaceDir,
10912
+ cwd: workspace,
9256
10913
  tavilyApiKey: this.ctx.config.tavilyApiKey,
9257
10914
  registry: this.skills,
9258
10915
  reportProgress: (msg, _percent) => {
@@ -9285,23 +10942,17 @@ var TaskExecuteEngine = class {
9285
10942
  worker.setInquirySupport(askAndWait);
9286
10943
  }
9287
10944
  if (workerType === PUPPET_WORKER_TYPE) {
9288
- const workspace = this.ctx.workspaceDir;
9289
10945
  const middleware = wrapWithPermissionMiddleware(tools, {
9290
10946
  gatedTools: ["bash", ...FILE_TOOL_NAMES],
9291
- needsPermission: (toolName, args) => {
9292
- if (toolName === "bash") {
9293
- return needsBashPermission(workspace, args.command ?? "");
9294
- }
9295
- if (FILE_TOOL_NAMES.includes(toolName)) {
9296
- return needsFilePermission(workspace, args);
9297
- }
9298
- return false;
9299
- },
10947
+ // Single decision source: the shared access policy (denylist →
10948
+ // config roots → task allowedDirs → task workspace → read-in-home →
10949
+ // ask). `deny` refuses here without ever emitting a permission_request.
10950
+ decide: (toolName, args) => decideToolAccess(accessScope, toolName, args),
9300
10951
  describeCall: (toolName, args) => {
9301
10952
  if (toolName === "bash") return `Execute shell command: ${args.command}`;
9302
10953
  if (FILE_TOOL_NAMES.includes(toolName)) {
9303
10954
  const rawPath = typeof args.path === "string" ? args.path : "";
9304
- const resolved = rawPath ? resolve9(workspace, rawPath) : workspace;
10955
+ const resolved = rawPath ? resolve10(workspace, rawPath) : workspace;
9305
10956
  return `${toolName}: ${rawPath || "(workspace root)"} (resolved: ${resolved})`;
9306
10957
  }
9307
10958
  return `${toolName}: ${JSON.stringify(args)}`;
@@ -9326,7 +10977,8 @@ var TaskExecuteEngine = class {
9326
10977
  taskTitle: task.title,
9327
10978
  workerType,
9328
10979
  emoji: workerMeta.emoji,
9329
- turnLineage: this.workerLineage(workerId)
10980
+ turnLineage: this.workerLineage(workerId),
10981
+ accessScope
9330
10982
  });
9331
10983
  this.permissionResolvers.set(workerId, worker.permissionHandler);
9332
10984
  }
@@ -9565,121 +11217,8 @@ var ControllerLoop = class {
9565
11217
  }
9566
11218
  };
9567
11219
 
9568
- // src/services/session/session-manager.ts
9569
- import * as path5 from "path";
9570
-
9571
- // src/services/session/session-state.ts
9572
- import { join as join10 } from "path";
9573
- var SessionState = class extends BaseWayangState {
9574
- jsonFile;
9575
- constructor(sessionDir) {
9576
- const jsonFile = new JSONFileHelper(join10(sessionDir, "meta.json"));
9577
- super(
9578
- {
9579
- meta: {
9580
- sessionId: "",
9581
- workspace: "",
9582
- firstInput: null,
9583
- createdAt: 0,
9584
- lastActiveAt: 0,
9585
- title: void 0,
9586
- archived: false,
9587
- archivedAt: void 0
9588
- }
9589
- },
9590
- [{ path: "meta", helper: jsonFile }]
9591
- );
9592
- this.jsonFile = jsonFile;
9593
- }
9594
- async restore() {
9595
- const data = this.jsonFile.read();
9596
- if (data) {
9597
- this.data.meta = data;
9598
- }
9599
- }
9600
- };
9601
-
9602
- // src/services/session/session-manager.ts
9603
- var SessionManager = class _SessionManager {
9604
- sessionId;
9605
- sessionDir;
9606
- isResume;
9607
- state;
9608
- constructor(sessionId, sessionDir, isResume) {
9609
- this.sessionId = sessionId;
9610
- this.sessionDir = sessionDir;
9611
- this.isResume = isResume;
9612
- this.state = new SessionState(sessionDir);
9613
- }
9614
- /** Create a new session: generates ID, initializes meta via WayangState (auto mkdir + write). */
9615
- static create(homeDir2, workspaceDir2) {
9616
- const sessionId = `${formatTimestamp()}-${Math.random().toString(36).slice(2, 6)}`;
9617
- const sessionDir = path5.join(homeDir2, "sessions", sessionId);
9618
- const mgr = new _SessionManager(sessionId, sessionDir, false);
9619
- mgr.state.set("meta", {
9620
- sessionId,
9621
- workspace: workspaceDir2,
9622
- firstInput: null,
9623
- createdAt: Date.now(),
9624
- lastActiveAt: Date.now()
9625
- });
9626
- return mgr;
9627
- }
9628
- /** Resume an existing session. State is restored in restore(). */
9629
- static resume(sessionId, sessionDir) {
9630
- return new _SessionManager(sessionId, sessionDir, true);
9631
- }
9632
- /** Restore state from disk (resume only, no-op for new sessions). */
9633
- async restore() {
9634
- if (!this.isResume) return;
9635
- await this.state.restore();
9636
- }
9637
- /** Workspace directory of this session. */
9638
- get workspace() {
9639
- return this.state.get("meta.workspace");
9640
- }
9641
- /** Whether firstInput has already been recorded. */
9642
- get hasFirstInput() {
9643
- return !!this.state.get("meta.firstInput");
9644
- }
9645
- /**
9646
- * Notify that a user input occurred.
9647
- * Records firstInput (idempotent) and updates lastActiveAt.
9648
- */
9649
- onUserInput(text) {
9650
- if (!this.hasFirstInput) {
9651
- this.state.set("meta.firstInput", text);
9652
- }
9653
- this.state.set("meta.lastActiveAt", Date.now());
9654
- }
9655
- /** Current display label: explicit title, else first input. */
9656
- get displayTitle() {
9657
- return sessionDisplayTitle(this.state.get("meta"));
9658
- }
9659
- /** Whether this session is archived (hidden from the default list). */
9660
- get archived() {
9661
- return this.state.get("meta.archived") ?? false;
9662
- }
9663
- /**
9664
- * Rename the session. Passing null/empty clears the explicit title, so the
9665
- * session falls back to showing its first input.
9666
- */
9667
- rename(title) {
9668
- const trimmed = title?.trim();
9669
- this.state.set("meta.title", trimmed && trimmed.length > 0 ? trimmed : void 0);
9670
- }
9671
- /**
9672
- * Toggle the archived flag. Archiving hides the session from the default
9673
- * list without deleting its transcript; unarchiving restores visibility.
9674
- */
9675
- setArchived(archived) {
9676
- this.state.set("meta.archived", archived);
9677
- this.state.set("meta.archivedAt", archived ? Date.now() : void 0);
9678
- }
9679
- };
9680
-
9681
11220
  // src/services/agents/controller-state.ts
9682
- import { join as join12 } from "path";
11221
+ import { join as join17 } from "path";
9683
11222
 
9684
11223
  // src/services/conversation/conversation-index.ts
9685
11224
  function emptyUsage() {
@@ -9903,8 +11442,8 @@ var ControllerAgentState = class extends BaseWayangState {
9903
11442
  /** In-memory resolve function for the current inquiry — never persisted. */
9904
11443
  inquiryResolver = null;
9905
11444
  constructor(ctx) {
9906
- const jsonFile = new JSONFileHelper(join12(ctx.sessionDir, "runtime-state.json"));
9907
- const jsonlFile = new JSONLFileHelper(join12(ctx.sessionDir, "conversation.jsonl"));
11445
+ const jsonFile = new JSONFileHelper(join17(ctx.sessionDir, "runtime-state.json"));
11446
+ const jsonlFile = new JSONLFileHelper(join17(ctx.sessionDir, "conversation.jsonl"));
9908
11447
  const initialData = {
9909
11448
  runtimeState: {
9910
11449
  session: { id: "", startedAt: 0 },
@@ -9929,9 +11468,9 @@ var ControllerAgentState = class extends BaseWayangState {
9929
11468
  * shared conversation-envelope module — the single source of truth. This
9930
11469
  * override only feeds the in-memory turn index incrementally.
9931
11470
  */
9932
- append(path7, entry) {
9933
- super.append(path7, entry);
9934
- if (path7 === "conversation" && this.convIndex) {
11471
+ append(path9, entry) {
11472
+ super.append(path9, entry);
11473
+ if (path9 === "conversation" && this.convIndex) {
9935
11474
  this.convIndex.onAppend(entry);
9936
11475
  }
9937
11476
  }
@@ -9979,8 +11518,8 @@ var ControllerAgentState = class extends BaseWayangState {
9979
11518
  }
9980
11519
  /** Set a pending inquiry and return a Promise that resolves when the user answers. */
9981
11520
  askInquiry(question) {
9982
- return new Promise((resolve12) => {
9983
- this.inquiryResolver = resolve12;
11521
+ return new Promise((resolve13) => {
11522
+ this.inquiryResolver = resolve13;
9984
11523
  this.set("runtimeState.pendingInquiry", question);
9985
11524
  });
9986
11525
  }
@@ -10089,6 +11628,8 @@ function buildSummaryInput(entries, maxChars) {
10089
11628
  }
10090
11629
 
10091
11630
  // src/services/agents/controller-agent.ts
11631
+ import { basename as basename9, dirname as dirname11 } from "path";
11632
+ import { homedir as homedir3 } from "os";
10092
11633
  var SUMMARIZER_INPUT_RATIO = 0.5;
10093
11634
  var ControllerAgent = class _ControllerAgent extends BaseAgent {
10094
11635
  state;
@@ -10101,6 +11642,13 @@ var ControllerAgent = class _ControllerAgent extends BaseAgent {
10101
11642
  recorder;
10102
11643
  /** Turn id of the most recent run — used to stamp late error entries. */
10103
11644
  lastTurnId = null;
11645
+ /**
11646
+ * Registry slug of the workspace this controller executes in (the process
11647
+ * workspace). Stamped on every controller turn envelope so its entries are
11648
+ * attributable to a directory, matching worker entries. Resolved once at
11649
+ * construction; undefined only when the context carries no workspace dir.
11650
+ */
11651
+ workspaceSlug;
10104
11652
  /**
10105
11653
  * True once THIS turn's interrupt record has been persisted
10106
11654
  * (recordUserAbort). Step finishes landing after that point would append
@@ -10122,6 +11670,7 @@ var ControllerAgent = class _ControllerAgent extends BaseAgent {
10122
11670
  this.recorder = recorder;
10123
11671
  this.tools = recordToolCalls(tools, recorder);
10124
11672
  this.logger = ctx.logger;
11673
+ this.workspaceSlug = ctx.workspaceDir ? resolveWorkspaceRef(wayangDirForSessionDir(ctx.sessionDir), ctx.workspaceDir).slug : void 0;
10125
11674
  this.contextManager = new ContextManager(
10126
11675
  state,
10127
11676
  buildControllerSystemPrompt(this.ctx, skills),
@@ -10145,11 +11694,14 @@ var ControllerAgent = class _ControllerAgent extends BaseAgent {
10145
11694
  const { ctx, provider, engine, signalQueue, skills } = opts;
10146
11695
  const state = new ControllerAgentState(ctx);
10147
11696
  const recorder = new ToolCallRecorder(ctx.logger);
11697
+ const wayangDir = wayangDirForSessionDir(ctx.sessionDir);
11698
+ const homeDir2 = wayangDir && basename9(wayangDir) === ".wayang" ? dirname11(wayangDir) : homedir3();
10148
11699
  const tools = createControllerTools({
10149
11700
  addTask: (task) => engine.add(task),
10150
11701
  validateWorkerType: (type) => engine.validateWorkerType(type),
10151
11702
  assignToWorker: (workerId, task) => engine.assignToExistingWorker(workerId, task),
10152
11703
  validateIdleWorker: (workerId) => engine.validateIdleWorker(workerId),
11704
+ validateWorkerWorkspace: (workerId, workspacePath) => engine.validateWorkerWorkspace(workerId, workspacePath),
10153
11705
  listTasks: (status) => engine.list(status),
10154
11706
  getTask: (taskId) => engine.get(taskId),
10155
11707
  getWorkerConversation: (taskId) => engine.getWorkerConversation(taskId),
@@ -10171,6 +11723,10 @@ var ControllerAgent = class _ControllerAgent extends BaseAgent {
10171
11723
  updateTask: (taskId, updates) => engine.updatePending(taskId, updates),
10172
11724
  queryMessages: (filter) => signalQueue.query(filter),
10173
11725
  cwd: ctx.workspaceDir,
11726
+ homeDir: homeDir2,
11727
+ // User-config permission section: add_task validates a task's allowedDirs
11728
+ // against the built-in denylist plus these configured deniedDirs.
11729
+ permissionConfig: opts.config.permission,
10174
11730
  getNotebook: () => state.get("runtimeState.notebook") ?? "",
10175
11731
  setNotebook: (content, mode) => {
10176
11732
  const current = state.get("runtimeState.notebook") ?? "";
@@ -10188,7 +11744,11 @@ var ControllerAgent = class _ControllerAgent extends BaseAgent {
10188
11744
  // toolCall→task audit link, consumed when the step entry is built.
10189
11745
  onTaskDispatched: ({ toolCallId, taskId }) => {
10190
11746
  if (toolCallId) recorder.attachTask(toolCallId, taskId);
10191
- }
11747
+ },
11748
+ // Default workspace for tasks created by add_task; a declared workspace
11749
+ // is stamped instead and the engine executes the task there.
11750
+ workspaceDir: ctx.workspaceDir,
11751
+ wayangDir
10192
11752
  });
10193
11753
  return new _ControllerAgent(ctx, state, provider, tools, skills, recorder);
10194
11754
  }
@@ -10212,7 +11772,11 @@ var ControllerAgent = class _ControllerAgent extends BaseAgent {
10212
11772
  turnId,
10213
11773
  ...rootTurnId !== void 0 && { rootTurnId },
10214
11774
  ...causationId !== void 0 && { causationId },
10215
- actor: { kind: "controller", id: this.id }
11775
+ actor: { kind: "controller", id: this.id },
11776
+ // Controller entries carry the process workspace's slug so they are
11777
+ // attributable to a directory just like worker entries. Undefined only
11778
+ // when the context carries no workspace directory.
11779
+ ...this.workspaceSlug !== void 0 && { workspaceSlug: this.workspaceSlug }
10216
11780
  };
10217
11781
  this.state.set("dynamicState.currentTurn", turnEnv);
10218
11782
  this.lastTurnId = turnId;
@@ -10451,11 +12015,11 @@ var ControllerAgent = class _ControllerAgent extends BaseAgent {
10451
12015
  return this.state.getTurn(turnId);
10452
12016
  }
10453
12017
  // --- Subscribable ---
10454
- subscribe(path7, callback) {
10455
- return this.state.on(path7, callback);
12018
+ subscribe(path9, callback) {
12019
+ return this.state.on(path9, callback);
10456
12020
  }
10457
- getSnapshot(path7) {
10458
- return this.state.get(path7);
12021
+ getSnapshot(path9) {
12022
+ return this.state.get(path9);
10459
12023
  }
10460
12024
  /** Check if context needs compaction. */
10461
12025
  needsCompaction() {
@@ -10616,7 +12180,7 @@ Pending tasks: ${p.pendingTaskCount}`
10616
12180
  emoji: p.emoji,
10617
12181
  taskId: p.taskId,
10618
12182
  taskTitle: p.taskTitle,
10619
- content: `[PERMISSION REQUEST] Worker ${p.workerId} (task: ${p.taskTitle}) wants to run ${p.toolName}.
12183
+ content: `[PERMISSION REQUEST] The worker running task "${p.taskTitle}" wants to run ${p.toolName}.
10620
12184
  Description: ${p.description}
10621
12185
  Request ID: ${p.requestId}
10622
12186
  Use respond_permission to approve or deny.`,
@@ -10640,7 +12204,7 @@ Use respond_permission to approve or deny.`,
10640
12204
  emoji: p.emoji,
10641
12205
  taskId: p.taskId,
10642
12206
  taskTitle: p.taskTitle,
10643
- content: `[WORKER INQUIRY] Worker ${p.workerId} (task: ${p.taskTitle}) is blocked and asks:${"\n"}${p.question}${p.context ? `
12207
+ content: `[WORKER INQUIRY] The worker running task "${p.taskTitle}" is blocked and asks:${"\n"}${p.question}${p.context ? `
10644
12208
  Context: ${p.context}` : ""}
10645
12209
  Inquiry ID: ${p.inquiryId}
10646
12210
  Use respond_inquiry to answer; the worker is waiting and will continue on its own after a 5-minute timeout.`,
@@ -10661,7 +12225,7 @@ Use respond_inquiry to answer; the worker is waiting and will continue on its ow
10661
12225
  emoji: p.emoji,
10662
12226
  taskId: p.taskId,
10663
12227
  taskTitle: p.taskTitle,
10664
- content: `[INQUIRY TIMEOUT] Worker ${p.workerId} (task: ${p.taskTitle}) got no answer within 5 minutes and continued on its own judgment. Question was: ${p.question}`
12228
+ content: `[INQUIRY TIMEOUT] The worker running task "${p.taskTitle}" got no answer within 5 minutes and continued on its own judgment. Question was: ${p.question}`
10665
12229
  };
10666
12230
  },
10667
12231
  message_undelivered: (sig, ts, sessionId) => {
@@ -10675,7 +12239,7 @@ Use respond_inquiry to answer; the worker is waiting and will continue on its ow
10675
12239
  emoji: p.emoji,
10676
12240
  taskId: p.taskId,
10677
12241
  taskTitle: p.taskTitle ?? "message_undelivered",
10678
- content: `[MESSAGES NOT DELIVERED] Worker ${p.workerId} ${p.reason === "worker_disposed" ? "was disposed (idle)" : "finished its task"} before reading your message(s):${"\n"}${lines.join("\n")}
12242
+ content: `[MESSAGES NOT DELIVERED] The worker ${p.reason === "worker_disposed" ? "was disposed (idle)" : "finished its task"} before reading your message(s):${"\n"}${lines.join("\n")}
10679
12243
  They were never seen by the worker. If still relevant, re-dispatch as a follow-up task.`
10680
12244
  };
10681
12245
  },
@@ -10689,7 +12253,7 @@ They were never seen by the worker. If still relevant, re-dispatch as a follow-u
10689
12253
  emoji: p.emoji,
10690
12254
  taskId: p.taskId,
10691
12255
  taskTitle: "worker_disposed",
10692
- content: `[WORKER DISPOSED] Idle (multi-stage) worker ${p.workerId} was disposed (reaped by timeout or manually). No task failed \u2014 its last stage already completed. If you still need this worker's context, create a fresh multi-stage task instead.`
12256
+ content: `[WORKER DISPOSED] An idle (multi-stage) worker was disposed (reaped by timeout or manually). No task failed \u2014 its last stage already completed. If you still need this worker's context, create a fresh multi-stage task instead.`
10693
12257
  };
10694
12258
  },
10695
12259
  task_cancelled: (sig, ts, sessionId) => {
@@ -10705,6 +12269,31 @@ They were never seen by the worker. If still relevant, re-dispatch as a follow-u
10705
12269
  content: `[TASK CANCELLED] Task ${p.taskId}${p.taskTitle ? ` (${p.taskTitle})` : ""} was cancelled by the user/controller. This is not a failure \u2014 the task terminated as cancelled.`
10706
12270
  };
10707
12271
  },
12272
+ task_queued: (sig, ts, sessionId) => {
12273
+ const p = sig.payload;
12274
+ return {
12275
+ ...signalBase(ts, sessionId),
12276
+ subtype: "task_queued" /* TaskQueued */,
12277
+ workerType: p.workerType,
12278
+ emoji: p.emoji,
12279
+ taskId: p.taskId,
12280
+ taskTitle: p.taskTitle,
12281
+ content: `[TASK QUEUED] ${p.message}`
12282
+ };
12283
+ },
12284
+ worker_started: (sig, ts, sessionId) => {
12285
+ const p = sig.payload;
12286
+ return {
12287
+ ...signalBase(ts, sessionId),
12288
+ subtype: "worker_started" /* WorkerStarted */,
12289
+ workerId: p.workerId,
12290
+ workerType: p.workerType,
12291
+ emoji: p.emoji,
12292
+ taskId: p.taskId,
12293
+ taskTitle: p.taskTitle,
12294
+ content: `[WORKER STARTED${p.reused ? " (reused idle worker)" : ""}] ${p.message}`
12295
+ };
12296
+ },
10708
12297
  previous_session_tasks: (sig, ts, sessionId) => {
10709
12298
  const p = sig.payload;
10710
12299
  const lines = p.tasks.map((t) => `- [${t.status}] ${t.title} (id: ${t.taskId}): ${t.description}`);
@@ -10758,8 +12347,8 @@ function signalToEntry(sig, ts, sessionId) {
10758
12347
  }
10759
12348
 
10760
12349
  // src/services/skills/registry.ts
10761
- import { readFileSync as readFileSync9, readdirSync as readdirSync2, statSync as statSync3 } from "fs";
10762
- import { join as join13 } from "path";
12350
+ import { readFileSync as readFileSync12, readdirSync as readdirSync2, statSync as statSync4 } from "fs";
12351
+ import { join as join18 } from "path";
10763
12352
  import { parse as parseYaml } from "yaml";
10764
12353
  var SKILL_FILE = "SKILL.md";
10765
12354
  function parseFrontmatter(raw) {
@@ -10794,7 +12383,7 @@ function listResources(skillDir) {
10794
12383
  const walk = (relDir) => {
10795
12384
  let entries;
10796
12385
  try {
10797
- entries = readdirSync2(join13(skillDir, relDir), { withFileTypes: true });
12386
+ entries = readdirSync2(join18(skillDir, relDir), { withFileTypes: true });
10798
12387
  } catch {
10799
12388
  return;
10800
12389
  }
@@ -10841,10 +12430,10 @@ var SkillRegistry = class {
10841
12430
  if (existing) return existing;
10842
12431
  const desc = this.descriptors.get(name);
10843
12432
  if (!desc) return void 0;
10844
- const filePath = join13(desc.dir, SKILL_FILE);
12433
+ const filePath = join18(desc.dir, SKILL_FILE);
10845
12434
  let raw;
10846
12435
  try {
10847
- raw = readFileSync9(filePath, "utf-8");
12436
+ raw = readFileSync12(filePath, "utf-8");
10848
12437
  } catch (err) {
10849
12438
  this.logger?.warn({ skill: name, error: err instanceof Error ? err.message : String(err) }, "Failed to read skill file");
10850
12439
  return void 0;
@@ -10872,8 +12461,8 @@ var SkillRegistry = class {
10872
12461
  for (const entry of entries) {
10873
12462
  if (!entry.isDirectory()) continue;
10874
12463
  if (entry.name.startsWith(".") || entry.name === "node_modules") continue;
10875
- const skillDir = join13(dir, entry.name);
10876
- const skillFile = join13(skillDir, SKILL_FILE);
12464
+ const skillDir = join18(dir, entry.name);
12465
+ const skillFile = join18(skillDir, SKILL_FILE);
10877
12466
  if (!existsAsFile(skillFile)) continue;
10878
12467
  const { frontmatter } = safeReadFrontmatter(skillFile);
10879
12468
  const description = asString(frontmatter.description);
@@ -10886,37 +12475,37 @@ var SkillRegistry = class {
10886
12475
  }
10887
12476
  }
10888
12477
  };
10889
- function existsAsFile(path7) {
12478
+ function existsAsFile(path9) {
10890
12479
  try {
10891
- return statSync3(path7).isFile();
12480
+ return statSync4(path9).isFile();
10892
12481
  } catch {
10893
12482
  return false;
10894
12483
  }
10895
12484
  }
10896
- function safeReadFrontmatter(path7) {
12485
+ function safeReadFrontmatter(path9) {
10897
12486
  try {
10898
- return parseFrontmatter(readFileSync9(path7, "utf-8"));
12487
+ return parseFrontmatter(readFileSync12(path9, "utf-8"));
10899
12488
  } catch {
10900
12489
  return { frontmatter: {}, body: "" };
10901
12490
  }
10902
12491
  }
10903
12492
 
10904
12493
  // src/services/skills/skill-dir.ts
10905
- import { existsSync as existsSync8 } from "fs";
10906
- import { homedir as homedir2 } from "os";
10907
- import { join as join14, resolve as resolve10 } from "path";
12494
+ import { existsSync as existsSync11 } from "fs";
12495
+ import { homedir as homedir4 } from "os";
12496
+ import { join as join19, resolve as resolve11 } from "path";
10908
12497
  function resolveSkillDirs(workspaceDir2, configDirs) {
10909
12498
  const candidates = [
10910
- join14(homedir2(), ".wayang", "skills"),
10911
- join14(workspaceDir2, ".wayang", "skills"),
10912
- ...(configDirs ?? []).map((d) => resolve10(d))
12499
+ join19(homedir4(), ".wayang", "skills"),
12500
+ join19(workspaceDir2, ".wayang", "skills"),
12501
+ ...(configDirs ?? []).map((d) => resolve11(d))
10913
12502
  ];
10914
12503
  const seen = /* @__PURE__ */ new Set();
10915
12504
  const dirs = [];
10916
12505
  for (const dir of candidates) {
10917
12506
  if (seen.has(dir)) continue;
10918
12507
  seen.add(dir);
10919
- if (existsSync8(dir)) dirs.push(dir);
12508
+ if (existsSync11(dir)) dirs.push(dir);
10920
12509
  }
10921
12510
  return dirs;
10922
12511
  }
@@ -10930,6 +12519,8 @@ var Supervisor = class {
10930
12519
  sessionManager;
10931
12520
  /** Shared skill registry — discovered at startup, used by all agents. */
10932
12521
  skills;
12522
+ /** Exclusive ownership of this session's directory (see SessionLock). */
12523
+ sessionLock;
10933
12524
  controllerLoop;
10934
12525
  resumeSessionDir;
10935
12526
  injectPreviousTasks;
@@ -10938,11 +12529,17 @@ var Supervisor = class {
10938
12529
  this.resumeSessionDir = options.resume?.sessionDir;
10939
12530
  this.injectPreviousTasks = options.injectPreviousTasks ?? false;
10940
12531
  if (options.resume) {
10941
- this.sessionManager = SessionManager.resume(options.resume.sessionId, options.resume.sessionDir);
12532
+ this.sessionManager = SessionManager.resume(
12533
+ options.resume.sessionId,
12534
+ options.resume.sessionDir,
12535
+ options.homeDir
12536
+ );
10942
12537
  } else {
10943
12538
  if (!options.homeDir) throw new Error("homeDir is required for new sessions");
10944
12539
  this.sessionManager = SessionManager.create(options.homeDir, workspaceDir2);
10945
12540
  }
12541
+ this.sessionLock = new SessionLock(this.sessionManager.sessionDir);
12542
+ const lockResult = this.sessionLock.acquire();
10946
12543
  this.ctx = new SystemContext(
10947
12544
  config,
10948
12545
  this.sessionManager.sessionId,
@@ -10950,6 +12547,12 @@ var Supervisor = class {
10950
12547
  workspaceDir2,
10951
12548
  logLevel
10952
12549
  );
12550
+ if (lockResult.staleTakeover) {
12551
+ this.ctx.logger.warn(
12552
+ { sessionDir: this.sessionManager.sessionDir },
12553
+ "Took over a stale session lock left by an abnormally terminated process"
12554
+ );
12555
+ }
10953
12556
  this.ctx.logger.info(
10954
12557
  { controllerEndpoint: this.ctx.controllerProvider.endpoint, controllerModel: this.ctx.controllerProvider.modelName },
10955
12558
  "Provider config"
@@ -10959,7 +12562,11 @@ var Supervisor = class {
10959
12562
  resolveSkillDirs(workspaceDir2, config.skillsDirs),
10960
12563
  this.ctx.logger
10961
12564
  );
10962
- this.engine = new TaskExecuteEngine(this.ctx, this.signalQueue, this.skills);
12565
+ this.engine = new TaskExecuteEngine(this.ctx, this.signalQueue, this.skills, {
12566
+ onTaskWorkspace: (workspace) => {
12567
+ this.sessionManager.addWorkspace(workspace);
12568
+ }
12569
+ });
10963
12570
  this.controllerAgent = ControllerAgent.create({
10964
12571
  ctx: this.ctx,
10965
12572
  provider: this.ctx.controllerProvider,
@@ -10986,10 +12593,11 @@ var Supervisor = class {
10986
12593
  if (this.resumeSessionDir && this.injectPreviousTasks) {
10987
12594
  previousTasks = readSessionUnfinishedTasks(this.resumeSessionDir);
10988
12595
  }
12596
+ await this.sessionManager.restore();
12597
+ const sessionWorkspace = this.sessionManager.startupWorkspace;
10989
12598
  await Promise.all([
10990
- this.sessionManager.restore(),
10991
12599
  this.controllerAgent.restore(),
10992
- this.engine.restore(),
12600
+ this.engine.restore(sessionWorkspace),
10993
12601
  this.signalQueue.restore()
10994
12602
  ]);
10995
12603
  if (previousTasks && previousTasks.length > 0) {
@@ -11044,6 +12652,11 @@ var Supervisor = class {
11044
12652
  this.controllerLoop.shutdown();
11045
12653
  this.engine.abortAll();
11046
12654
  this.ctx.abortController.abort();
12655
+ try {
12656
+ this.sessionLock.release();
12657
+ } catch (err) {
12658
+ this.ctx.logger.warn({ error: err.message }, "Failed to release session lock");
12659
+ }
11047
12660
  this.ctx.logger.info("Supervisor shutdown complete");
11048
12661
  await this.ctx.closeLogger();
11049
12662
  }
@@ -11211,11 +12824,11 @@ async function runWayangServer(options, io = process) {
11211
12824
  let nextSubscriptionId = 1;
11212
12825
  rpc.register(METHOD.SUBSCRIBE, (params) => {
11213
12826
  const target = resolveTarget(params.target);
11214
- const path7 = requiredString(params, "path");
11215
- const data = target.getSnapshot(path7);
12827
+ const path9 = requiredString(params, "path");
12828
+ const data = target.getSnapshot(path9);
11216
12829
  const subscriptionId = `sub-${nextSubscriptionId++}`;
11217
- subscriptions.set(subscriptionId, target.subscribe(path7, (event) => {
11218
- rpc.notify(NOTIFY.STATE_EVENT, { subscriptionId, target: params.target, path: path7, data: event.data });
12830
+ subscriptions.set(subscriptionId, target.subscribe(path9, (event) => {
12831
+ rpc.notify(NOTIFY.STATE_EVENT, { subscriptionId, target: params.target, path: path9, data: event.data });
11219
12832
  }));
11220
12833
  return { subscriptionId, data: data === void 0 ? null : data };
11221
12834
  });
@@ -11328,10 +12941,12 @@ async function runWayangServer(options, io = process) {
11328
12941
  sessionDir: supervisor.ctx.sessionDir,
11329
12942
  workspaceDir: supervisor.ctx.workspaceDir
11330
12943
  });
11331
- process.on("SIGTERM", () => {
12944
+ const onSignal = () => {
11332
12945
  void supervisor.shutdown().catch(() => {
11333
12946
  }).finally(() => process.exit(0));
11334
- });
12947
+ };
12948
+ process.on("SIGTERM", onSignal);
12949
+ process.on("SIGINT", onSignal);
11335
12950
  }
11336
12951
  function requiredString(params, key) {
11337
12952
  const value = params[key];
@@ -11394,17 +13009,21 @@ if (result.status === "error") {
11394
13009
  process.exit(1);
11395
13010
  }
11396
13011
  var { input, flags } = result;
11397
- var homeDir = path6.resolve(flags.homeDir ?? (process.env.HOME || "~"));
11398
- var workspaceDir = path6.resolve(flags.workspaceDir ?? process.cwd());
11399
- var configPath = flags.config ? path6.resolve(flags.config) : path6.join(homeDir, ".wayang.config.json");
13012
+ var homeDir = path8.resolve(flags.homeDir ?? (process.env.HOME || "~"));
13013
+ var workspaceDir = normalizeWorkspacePath(flags.workspaceDir ?? process.cwd());
13014
+ var configPath = flags.config ? path8.resolve(flags.config) : path8.join(homeDir, ".wayang.config.json");
11400
13015
  function fatal(err) {
13016
+ if (err instanceof SessionLockedError) {
13017
+ console.error(formatSessionLockHint(err));
13018
+ process.exit(1);
13019
+ }
11401
13020
  console.error("Fatal:", err instanceof Error ? err.message : err);
11402
13021
  process.exit(1);
11403
13022
  }
11404
13023
  if (input[0] === "server") {
11405
13024
  serverMain({
11406
13025
  configPath,
11407
- homeDir: path6.join(homeDir, ".wayang"),
13026
+ homeDir: path8.join(homeDir, ".wayang"),
11408
13027
  workspaceDir,
11409
13028
  session: flags.session ?? "new",
11410
13029
  injectPreviousTasks: flags.injectPreviousTasks,
@@ -11418,7 +13037,7 @@ if (input[0] === "server") {
11418
13037
  homeDir,
11419
13038
  workspaceDir,
11420
13039
  logLevel: flags.verbose ? "debug" : "info",
11421
- fresh: flags.fresh,
13040
+ new: flags.new,
11422
13041
  resume: resumeFlag ? resumeId ?? "" : void 0,
11423
13042
  showAll: flags.all
11424
13043
  }).catch(fatal);