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.
- package/dist/cli.js +2552 -933
- 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
|
|
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 --
|
|
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
|
-
--
|
|
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
|
-
|
|
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
|
-
|
|
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([...
|
|
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 (
|
|
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-") &&
|
|
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
|
-
|
|
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
|
|
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((
|
|
412
|
+
return new Promise((resolve13, reject) => {
|
|
366
413
|
this.pending.set(id, {
|
|
367
|
-
resolve: (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,
|
|
420
|
-
const key = `${target}:${
|
|
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:
|
|
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,
|
|
434
|
-
return this.entries.get(`${target}:${
|
|
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(
|
|
473
|
-
return this.store.subscribe(this.target,
|
|
519
|
+
subscribe(path9, callback) {
|
|
520
|
+
return this.store.subscribe(this.target, path9, callback);
|
|
474
521
|
}
|
|
475
|
-
getSnapshot(
|
|
476
|
-
return this.store.getSnapshot(this.target,
|
|
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((
|
|
572
|
-
child.on("exit", (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((
|
|
638
|
+
new Promise((resolve13) => {
|
|
592
639
|
setTimeout(() => {
|
|
593
640
|
child.kill("SIGKILL");
|
|
594
|
-
|
|
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((
|
|
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
|
|
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/
|
|
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 =
|
|
920
|
+
const sessionsDir = path2.join(homeDir2, "sessions");
|
|
649
921
|
let names;
|
|
650
922
|
try {
|
|
651
|
-
names =
|
|
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 =
|
|
658
|
-
const metaPath =
|
|
659
|
-
try {
|
|
660
|
-
const raw =
|
|
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
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
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
|
-
|
|
681
|
-
|
|
682
|
-
|
|
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
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
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
|
-
|
|
705
|
-
|
|
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
|
-
|
|
1731
|
+
this.state.set("meta.workspaces", [...existing, { path: canonical, slug: workspace.slug }]);
|
|
1732
|
+
return true;
|
|
708
1733
|
}
|
|
709
|
-
|
|
710
|
-
|
|
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
|
-
|
|
713
|
-
|
|
714
|
-
return
|
|
1750
|
+
/** Whether firstInput has already been recorded. */
|
|
1751
|
+
get hasFirstInput() {
|
|
1752
|
+
return !!this.state.get("meta.firstInput");
|
|
715
1753
|
}
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
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
|
-
|
|
725
|
-
cache.byId.set(entry.sessionId, entry);
|
|
1762
|
+
this.state.set("meta.lastActiveAt", Date.now());
|
|
726
1763
|
}
|
|
727
|
-
|
|
728
|
-
|
|
1764
|
+
/** Current display label: explicit title, else first input. */
|
|
1765
|
+
get displayTitle() {
|
|
1766
|
+
return sessionDisplayTitle(this.state.get("meta"));
|
|
729
1767
|
}
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
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
|
|
1117
|
-
if (!
|
|
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(
|
|
2150
|
+
return fitWithSuffix(shortenPath(path9), note, budget);
|
|
1121
2151
|
}
|
|
1122
2152
|
case "edit_file": {
|
|
1123
|
-
const
|
|
1124
|
-
if (!
|
|
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(
|
|
2158
|
+
return fitWithSuffix(shortenPath(path9), note, budget);
|
|
1129
2159
|
}
|
|
1130
2160
|
case "read_file": {
|
|
1131
|
-
const
|
|
1132
|
-
if (!
|
|
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(
|
|
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
|
-
|
|
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,
|
|
2766
|
-
const [value, setValue] = useState4(() => source.getSnapshot(
|
|
3771
|
+
function useWayangState(source, path9) {
|
|
3772
|
+
const [value, setValue] = useState4(() => source.getSnapshot(path9));
|
|
2767
3773
|
useEffect3(() => {
|
|
2768
|
-
const unSub = source.subscribe(
|
|
2769
|
-
setValue(source.getSnapshot(
|
|
3774
|
+
const unSub = source.subscribe(path9, () => {
|
|
3775
|
+
setValue(source.getSnapshot(path9));
|
|
2770
3776
|
});
|
|
2771
|
-
setValue(source.getSnapshot(
|
|
3777
|
+
setValue(source.getSnapshot(path9));
|
|
2772
3778
|
return unSub;
|
|
2773
|
-
}, [source,
|
|
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
|
-
|
|
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
|
|
2844
|
-
const
|
|
2845
|
-
|
|
2846
|
-
|
|
2847
|
-
|
|
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
|
|
2851
|
-
|
|
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
|
|
2864
|
-
const workerBudget = Math.max(0, terminalCols - 4 - countsText
|
|
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.
|
|
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
|
|
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((
|
|
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(() =>
|
|
4389
|
+
waitUntilExit().then(() => resolve13());
|
|
3356
4390
|
});
|
|
3357
4391
|
}
|
|
3358
4392
|
|
|
3359
4393
|
// src/onboard.ts
|
|
3360
|
-
import * as
|
|
3361
|
-
import * as
|
|
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 (
|
|
4516
|
+
if (fs5.existsSync(configPath2)) {
|
|
3483
4517
|
try {
|
|
3484
|
-
config = JSON.parse(
|
|
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
|
-
|
|
3514
|
-
|
|
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
|
-
|
|
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
|
|
3535
|
-
|
|
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
|
|
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
|
|
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
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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((
|
|
4742
|
+
return new Promise((resolve13) => {
|
|
3629
4743
|
transport.on("error", () => {
|
|
3630
4744
|
});
|
|
3631
4745
|
if (transport.closed || transport.destroyed) {
|
|
3632
|
-
|
|
4746
|
+
resolve13();
|
|
3633
4747
|
return;
|
|
3634
4748
|
}
|
|
3635
|
-
transport.once("close", () =>
|
|
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
|
-
*
|
|
3952
|
-
*
|
|
3953
|
-
*
|
|
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
|
-
|
|
3956
|
-
|
|
3957
|
-
|
|
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
|
-
|
|
3966
|
-
|
|
3967
|
-
this.writeValue(null);
|
|
4844
|
+
get controllerProvider() {
|
|
4845
|
+
return this.config.providers[this.config.controller.provider];
|
|
3968
4846
|
}
|
|
3969
|
-
|
|
3970
|
-
|
|
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
|
-
|
|
3974
|
-
|
|
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
|
|
3985
|
-
import { dirname as
|
|
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
|
-
|
|
4877
|
+
mkdirSync5(dirname5(this.filePath), { recursive: true });
|
|
4003
4878
|
appendFileSync(this.filePath, JSON.stringify(entry) + "\n");
|
|
4004
4879
|
}
|
|
4005
4880
|
read() {
|
|
4006
|
-
if (!
|
|
4007
|
-
const text =
|
|
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
|
-
|
|
4031
|
-
|
|
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 =
|
|
4078
|
-
const historyPath =
|
|
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 =
|
|
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(
|
|
4266
|
-
return this.state.on(
|
|
5140
|
+
subscribe(path9, callback) {
|
|
5141
|
+
return this.state.on(path9, callback);
|
|
4267
5142
|
}
|
|
4268
|
-
getSnapshot(
|
|
4269
|
-
return this.state.get(
|
|
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((
|
|
5200
|
+
return new Promise((resolve13) => {
|
|
4326
5201
|
this.resolveWait = () => {
|
|
4327
5202
|
this.logger.debug("waitForSignal resolved");
|
|
4328
|
-
|
|
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
|
|
4390
|
-
import { join as
|
|
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
|
|
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
|
|
5699
|
+
return join11(sessionDir, "workers", workerId);
|
|
4838
5700
|
}
|
|
4839
5701
|
var WorkerState = class extends BaseWayangState {
|
|
4840
|
-
|
|
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(
|
|
4843
|
-
const jsonlFile = new JSONLFileHelper(
|
|
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
|
|
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
|
|
4903
|
-
if (!
|
|
4904
|
-
return new JSONLFileHelper(
|
|
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
|
|
5805
|
+
const path9 = workerTranscriptPath(sessionDir, workerId);
|
|
4935
5806
|
let size;
|
|
4936
5807
|
try {
|
|
4937
|
-
size = statSync(
|
|
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 =
|
|
5822
|
+
const fd = openSync3(path9, "r");
|
|
4952
5823
|
try {
|
|
4953
5824
|
readSync(fd, chunk, 0, readLen, start);
|
|
4954
5825
|
} finally {
|
|
4955
|
-
|
|
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
|
|
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(
|
|
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
|
|
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": `${
|
|
6706
|
+
"OS": `${os2.platform()} (${os2.arch()})`,
|
|
5817
6707
|
"Shell": `${shellName} (${shell})`,
|
|
5818
|
-
"Home":
|
|
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
|
|
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 [
|
|
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
|
-
|
|
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
|
-
## [
|
|
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
|
-
## [
|
|
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
|
|
6170
|
-
import { basename as
|
|
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 =
|
|
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 (
|
|
6180
|
-
const parent =
|
|
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
|
|
7088
|
+
const path9 = join14(dir, name);
|
|
6189
7089
|
try {
|
|
6190
|
-
if (!statSync2(
|
|
6191
|
-
const content =
|
|
7090
|
+
if (!statSync2(path9).isFile()) continue;
|
|
7091
|
+
const content = readFileSync8(path9, "utf8");
|
|
6192
7092
|
if (!content.trim()) continue;
|
|
6193
|
-
return { path:
|
|
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 =
|
|
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(
|
|
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
|
-
|
|
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.
|
|
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(
|
|
6564
|
-
return this.state.on(
|
|
7480
|
+
subscribe(path9, callback) {
|
|
7481
|
+
return this.state.on(path9, callback);
|
|
6565
7482
|
}
|
|
6566
|
-
getSnapshot(
|
|
6567
|
-
return this.state.get(
|
|
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((
|
|
7849
|
+
return new Promise((resolve13) => {
|
|
6682
7850
|
const settle = (value) => {
|
|
6683
7851
|
this.pending.delete(id);
|
|
6684
|
-
|
|
7852
|
+
resolve13(value);
|
|
6685
7853
|
};
|
|
6686
7854
|
const timer = setTimeout(() => {
|
|
6687
7855
|
this.pending.delete(id);
|
|
6688
|
-
|
|
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.
|
|
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(
|
|
6953
|
-
return this.state.on(
|
|
8149
|
+
subscribe(path9, callback) {
|
|
8150
|
+
return this.state.on(path9, callback);
|
|
6954
8151
|
}
|
|
6955
|
-
getSnapshot(
|
|
6956
|
-
return this.state.get(
|
|
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
|
-
/**
|
|
6997
|
-
|
|
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 (
|
|
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
|
-
|
|
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 =
|
|
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((
|
|
7063
|
-
this._messageResolve =
|
|
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
|
|
8605
|
+
const resolve13 = this._messageResolve;
|
|
7304
8606
|
this._messageResolve = null;
|
|
7305
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
7403
|
-
|
|
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
|
|
7874
|
-
import { resolve as
|
|
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:
|
|
7886
|
-
const resolved =
|
|
7887
|
-
const content =
|
|
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
|
|
7908
|
-
import { dirname as
|
|
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:
|
|
7918
|
-
const resolved =
|
|
7919
|
-
if (
|
|
7920
|
-
return `[ERROR] write_file: File already exists: ${
|
|
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
|
-
|
|
7923
|
-
|
|
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
|
|
7931
|
-
import { resolve as
|
|
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:
|
|
7943
|
-
const resolved =
|
|
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 =
|
|
9438
|
+
content = readFileSync11(resolved, "utf-8");
|
|
7947
9439
|
} catch {
|
|
7948
|
-
return `[ERROR] edit_file: File not found: ${
|
|
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 ${
|
|
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 ${
|
|
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
|
-
|
|
7957
|
-
return `Replaced ${old_string.length} chars with ${new_string.length} chars in ${
|
|
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:
|
|
7975
|
-
const basePath =
|
|
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
|
|
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:
|
|
8004
|
-
const searchDir =
|
|
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
|
-
|
|
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
|
|
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((
|
|
9902
|
+
const result2 = await new Promise((resolve13) => {
|
|
8386
9903
|
const timer = setTimeout(() => {
|
|
8387
9904
|
pending.delete(requestId);
|
|
8388
|
-
|
|
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
|
-
|
|
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
|
|
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(
|
|
8570
|
-
if (
|
|
8571
|
-
if (
|
|
8572
|
-
throw new Error(`TaskExecuteEngine: unknown subscription path "${
|
|
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(
|
|
8575
|
-
if (
|
|
8576
|
-
if (
|
|
8577
|
-
throw new Error(`TaskExecuteEngine: unknown snapshot path "${
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
|
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:
|
|
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
|
-
|
|
9292
|
-
|
|
9293
|
-
|
|
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 ?
|
|
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
|
|
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(
|
|
9907
|
-
const jsonlFile = new JSONLFileHelper(
|
|
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(
|
|
9933
|
-
super.append(
|
|
9934
|
-
if (
|
|
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((
|
|
9983
|
-
this.inquiryResolver =
|
|
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(
|
|
10455
|
-
return this.state.on(
|
|
12018
|
+
subscribe(path9, callback) {
|
|
12019
|
+
return this.state.on(path9, callback);
|
|
10456
12020
|
}
|
|
10457
|
-
getSnapshot(
|
|
10458
|
-
return this.state.get(
|
|
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]
|
|
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]
|
|
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]
|
|
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]
|
|
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]
|
|
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
|
|
10762
|
-
import { join as
|
|
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(
|
|
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 =
|
|
12433
|
+
const filePath = join18(desc.dir, SKILL_FILE);
|
|
10845
12434
|
let raw;
|
|
10846
12435
|
try {
|
|
10847
|
-
raw =
|
|
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 =
|
|
10876
|
-
const skillFile =
|
|
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(
|
|
12478
|
+
function existsAsFile(path9) {
|
|
10890
12479
|
try {
|
|
10891
|
-
return
|
|
12480
|
+
return statSync4(path9).isFile();
|
|
10892
12481
|
} catch {
|
|
10893
12482
|
return false;
|
|
10894
12483
|
}
|
|
10895
12484
|
}
|
|
10896
|
-
function safeReadFrontmatter(
|
|
12485
|
+
function safeReadFrontmatter(path9) {
|
|
10897
12486
|
try {
|
|
10898
|
-
return parseFrontmatter(
|
|
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
|
|
10906
|
-
import { homedir as
|
|
10907
|
-
import { join as
|
|
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
|
-
|
|
10911
|
-
|
|
10912
|
-
...(configDirs ?? []).map((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 (
|
|
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(
|
|
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
|
|
11215
|
-
const data = target.getSnapshot(
|
|
12827
|
+
const path9 = requiredString(params, "path");
|
|
12828
|
+
const data = target.getSnapshot(path9);
|
|
11216
12829
|
const subscriptionId = `sub-${nextSubscriptionId++}`;
|
|
11217
|
-
subscriptions.set(subscriptionId, target.subscribe(
|
|
11218
|
-
rpc.notify(NOTIFY.STATE_EVENT, { subscriptionId, target: params.target, path:
|
|
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
|
-
|
|
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 =
|
|
11398
|
-
var workspaceDir =
|
|
11399
|
-
var configPath = flags.config ?
|
|
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:
|
|
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
|
-
|
|
13040
|
+
new: flags.new,
|
|
11422
13041
|
resume: resumeFlag ? resumeId ?? "" : void 0,
|
|
11423
13042
|
showAll: flags.all
|
|
11424
13043
|
}).catch(fatal);
|