@turmind/halo 2.0.3 → 2.0.5
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/admin-out/404.html +1 -1
- package/admin-out/_next/static/chunks/app/_not-found/{page-484fd244e4bc7b70.js → page-4271518f2746cf73.js} +1 -1
- package/admin-out/_next/static/chunks/app/layout-ea161287d934ba96.js +1 -0
- package/admin-out/_next/static/chunks/app/{page-faee6fbd17dc7b80.js → page-cb05cd98d8f9042a.js} +1 -1
- package/admin-out/_next/static/chunks/{main-app-42f48cb3fd4811f4.js → main-app-340a5b58ed229c4d.js} +1 -1
- package/admin-out/_next/static/chunks/{webpack-b7907e0d16faa4ca.js → webpack-74f577c1b094f942.js} +1 -1
- package/admin-out/index.html +1 -1
- package/admin-out/index.txt +4 -4
- package/bundled-docs/design/canvas-extensions.md +2 -2
- package/bundled-docs/design/evolution.md +2 -2
- package/bundled-docs/dev/api.md +1 -1
- package/bundled-docs/dev/deploy.md +1 -1
- package/dist/evo-wrapper.js +2810 -0
- package/dist/index.js +151 -97
- package/package.json +1 -1
- package/admin-out/_next/static/chunks/app/layout-3e437a977abd0081.js +0 -1
- /package/admin-out/_next/static/{P7yMYf16Q3-XqGtzCJNnw → F7kFetjUGIXr5T6aTQAtP}/_buildManifest.js +0 -0
- /package/admin-out/_next/static/{P7yMYf16Q3-XqGtzCJNnw → F7kFetjUGIXr5T6aTQAtP}/_ssgManifest.js +0 -0
|
@@ -0,0 +1,2810 @@
|
|
|
1
|
+
// ../server/dist/evolution/evo-wrapper.js
|
|
2
|
+
import fs9 from "node:fs";
|
|
3
|
+
import path12 from "node:path";
|
|
4
|
+
import { spawn as spawn2 } from "node:child_process";
|
|
5
|
+
import { homedir as homedir6 } from "node:os";
|
|
6
|
+
import { fileURLToPath as fileURLToPath2 } from "node:url";
|
|
7
|
+
import { parseArgs } from "node:util";
|
|
8
|
+
import { eq, and } from "drizzle-orm";
|
|
9
|
+
import YAML6 from "yaml";
|
|
10
|
+
|
|
11
|
+
// ../server/dist/db/evo-db.js
|
|
12
|
+
import Database from "better-sqlite3";
|
|
13
|
+
import { drizzle } from "drizzle-orm/better-sqlite3";
|
|
14
|
+
import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core";
|
|
15
|
+
import path from "node:path";
|
|
16
|
+
import fs from "node:fs";
|
|
17
|
+
|
|
18
|
+
// ../server/dist/db/migrate.js
|
|
19
|
+
function runMigrations(sqlite, migrations) {
|
|
20
|
+
const current = sqlite.pragma("user_version", { simple: true });
|
|
21
|
+
if (current > migrations.length) {
|
|
22
|
+
console.warn(`[Db] user_version ${current} is newer than this build knows (${migrations.length}) \u2014 opened by a newer halo; continuing`);
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
for (let i = current; i < migrations.length; i++) {
|
|
26
|
+
sqlite.transaction(() => {
|
|
27
|
+
migrations[i](sqlite);
|
|
28
|
+
sqlite.pragma(`user_version = ${i + 1}`);
|
|
29
|
+
})();
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// ../server/dist/db/evo-db.js
|
|
34
|
+
var evolutionRuns = sqliteTable("evolution_runs", {
|
|
35
|
+
id: text("id").primaryKey(),
|
|
36
|
+
workspacePath: text("workspace_path").notNull(),
|
|
37
|
+
status: text("status").notNull(),
|
|
38
|
+
triggerKind: text("trigger_kind").notNull(),
|
|
39
|
+
sourceSession: text("source_session").notNull(),
|
|
40
|
+
userHint: text("user_hint"),
|
|
41
|
+
createdAt: integer("created_at").notNull(),
|
|
42
|
+
startedAt: integer("started_at"),
|
|
43
|
+
heartbeatAt: integer("heartbeat_at"),
|
|
44
|
+
completedAt: integer("completed_at"),
|
|
45
|
+
appliedAt: integer("applied_at"),
|
|
46
|
+
failureReason: text("failure_reason"),
|
|
47
|
+
/** How many times the ticker has tried to spawn a wrapper for this row.
|
|
48
|
+
* Bumped on each `pending → running` claim. When it crosses the configured
|
|
49
|
+
* retry budget, the ticker stops re-claiming after timeout/failure. */
|
|
50
|
+
attempts: integer("attempts").notNull().default(0),
|
|
51
|
+
/** When archive job zipped the artifacts and deleted the run dir. Null
|
|
52
|
+
* while the run is active. Rows with archived_at >= 30 days ago are
|
|
53
|
+
* candidates for full deletion (zip + db row). */
|
|
54
|
+
archivedAt: integer("archived_at")
|
|
55
|
+
});
|
|
56
|
+
var evolutionApplies = sqliteTable("evolution_applies", {
|
|
57
|
+
id: text("id").primaryKey(),
|
|
58
|
+
workspacePath: text("workspace_path").notNull(),
|
|
59
|
+
status: text("status").notNull(),
|
|
60
|
+
/** JSON-encoded array of evolution_runs.id values this apply consolidates. */
|
|
61
|
+
sourceRunIds: text("source_run_ids").notNull(),
|
|
62
|
+
reviewerHint: text("reviewer_hint"),
|
|
63
|
+
createdAt: integer("created_at").notNull(),
|
|
64
|
+
startedAt: integer("started_at"),
|
|
65
|
+
heartbeatAt: integer("heartbeat_at"),
|
|
66
|
+
completedAt: integer("completed_at"),
|
|
67
|
+
failureReason: text("failure_reason"),
|
|
68
|
+
attempts: integer("attempts").notNull().default(0),
|
|
69
|
+
archivedAt: integer("archived_at")
|
|
70
|
+
});
|
|
71
|
+
var CREATE_SQL = `
|
|
72
|
+
CREATE TABLE IF NOT EXISTS evolution_runs (
|
|
73
|
+
id TEXT PRIMARY KEY,
|
|
74
|
+
workspace_path TEXT NOT NULL,
|
|
75
|
+
status TEXT NOT NULL,
|
|
76
|
+
trigger_kind TEXT NOT NULL,
|
|
77
|
+
source_session TEXT NOT NULL,
|
|
78
|
+
user_hint TEXT,
|
|
79
|
+
created_at INTEGER NOT NULL,
|
|
80
|
+
started_at INTEGER,
|
|
81
|
+
heartbeat_at INTEGER,
|
|
82
|
+
completed_at INTEGER,
|
|
83
|
+
applied_at INTEGER,
|
|
84
|
+
failure_reason TEXT,
|
|
85
|
+
attempts INTEGER NOT NULL DEFAULT 0,
|
|
86
|
+
archived_at INTEGER
|
|
87
|
+
);
|
|
88
|
+
CREATE INDEX IF NOT EXISTS idx_evo_runs_status ON evolution_runs(status, created_at);
|
|
89
|
+
CREATE INDEX IF NOT EXISTS idx_evo_runs_ws ON evolution_runs(workspace_path);
|
|
90
|
+
CREATE INDEX IF NOT EXISTS idx_evo_runs_archived ON evolution_runs(archived_at);
|
|
91
|
+
|
|
92
|
+
CREATE TABLE IF NOT EXISTS evolution_applies (
|
|
93
|
+
id TEXT PRIMARY KEY,
|
|
94
|
+
workspace_path TEXT NOT NULL,
|
|
95
|
+
status TEXT NOT NULL,
|
|
96
|
+
source_run_ids TEXT NOT NULL,
|
|
97
|
+
reviewer_hint TEXT,
|
|
98
|
+
created_at INTEGER NOT NULL,
|
|
99
|
+
started_at INTEGER,
|
|
100
|
+
heartbeat_at INTEGER,
|
|
101
|
+
completed_at INTEGER,
|
|
102
|
+
failure_reason TEXT,
|
|
103
|
+
attempts INTEGER NOT NULL DEFAULT 0,
|
|
104
|
+
archived_at INTEGER
|
|
105
|
+
);
|
|
106
|
+
CREATE INDEX IF NOT EXISTS idx_evo_applies_status ON evolution_applies(status, created_at);
|
|
107
|
+
CREATE INDEX IF NOT EXISTS idx_evo_applies_ws ON evolution_applies(workspace_path);
|
|
108
|
+
CREATE INDEX IF NOT EXISTS idx_evo_applies_archived ON evolution_applies(archived_at);
|
|
109
|
+
`;
|
|
110
|
+
function createEvoDb(globalDir2) {
|
|
111
|
+
fs.mkdirSync(globalDir2, { recursive: true });
|
|
112
|
+
const dbPath = path.join(globalDir2, "evo.db");
|
|
113
|
+
const sqlite = new Database(dbPath);
|
|
114
|
+
sqlite.pragma("journal_mode = WAL");
|
|
115
|
+
sqlite.exec(CREATE_SQL);
|
|
116
|
+
runMigrations(sqlite, []);
|
|
117
|
+
return drizzle(sqlite, { schema: { evolutionRuns, evolutionApplies } });
|
|
118
|
+
}
|
|
119
|
+
var _evoDb = null;
|
|
120
|
+
function setEvoDb(db) {
|
|
121
|
+
_evoDb = db;
|
|
122
|
+
}
|
|
123
|
+
function getEvoDb() {
|
|
124
|
+
if (!_evoDb)
|
|
125
|
+
throw new Error("[evo-db] getEvoDb() called before setEvoDb()");
|
|
126
|
+
return _evoDb;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// ../server/dist/paths.js
|
|
130
|
+
import path2 from "node:path";
|
|
131
|
+
import { homedir } from "node:os";
|
|
132
|
+
function haloHome() {
|
|
133
|
+
return path2.join(homedir(), ".halo");
|
|
134
|
+
}
|
|
135
|
+
function globalDir() {
|
|
136
|
+
return path2.join(haloHome(), "global");
|
|
137
|
+
}
|
|
138
|
+
function secretsDir() {
|
|
139
|
+
return path2.join(haloHome(), "secrets");
|
|
140
|
+
}
|
|
141
|
+
function wsHaloDir(workspacePath) {
|
|
142
|
+
return path2.join(workspacePath, ".halo");
|
|
143
|
+
}
|
|
144
|
+
function wsEvoDir(workspacePath) {
|
|
145
|
+
return path2.join(wsHaloDir(workspacePath), "evo");
|
|
146
|
+
}
|
|
147
|
+
function wsEvoRunDir(workspacePath, runId) {
|
|
148
|
+
return path2.join(wsEvoDir(workspacePath), "runs", runId);
|
|
149
|
+
}
|
|
150
|
+
function wsEvoApplyDir(workspacePath, applyId) {
|
|
151
|
+
return path2.join(wsEvoDir(workspacePath), "applies", applyId);
|
|
152
|
+
}
|
|
153
|
+
function wsEvoHistoryDir(workspacePath, applyId) {
|
|
154
|
+
return path2.join(wsEvoDir(workspacePath), "history", `apply-${applyId}`);
|
|
155
|
+
}
|
|
156
|
+
function evoSandboxDir(artifactDir) {
|
|
157
|
+
return path2.join(artifactDir, "sandbox");
|
|
158
|
+
}
|
|
159
|
+
function evoSandboxHaloDir(artifactDir) {
|
|
160
|
+
return path2.join(evoSandboxDir(artifactDir), ".halo");
|
|
161
|
+
}
|
|
162
|
+
function evoRegressDir(applyDir) {
|
|
163
|
+
return path2.join(applyDir, "regress");
|
|
164
|
+
}
|
|
165
|
+
function evoRegressRunDir(applyDir, runId) {
|
|
166
|
+
return path2.join(evoRegressDir(applyDir), runId);
|
|
167
|
+
}
|
|
168
|
+
function globalModelsDir() {
|
|
169
|
+
return path2.join(globalDir(), "models");
|
|
170
|
+
}
|
|
171
|
+
function globalHubModelsDir() {
|
|
172
|
+
return path2.join(globalDir(), "models.d");
|
|
173
|
+
}
|
|
174
|
+
function globalExtensionsDir() {
|
|
175
|
+
return path2.join(globalDir(), "extensions");
|
|
176
|
+
}
|
|
177
|
+
function globalLogsDir() {
|
|
178
|
+
return path2.join(globalDir(), "logs");
|
|
179
|
+
}
|
|
180
|
+
function evoLogsDir() {
|
|
181
|
+
return path2.join(globalLogsDir(), "evo");
|
|
182
|
+
}
|
|
183
|
+
function evoWrapperLogFile(runId) {
|
|
184
|
+
return path2.join(evoLogsDir(), `run-${runId}.log`);
|
|
185
|
+
}
|
|
186
|
+
function evoApplyLogFile(applyId) {
|
|
187
|
+
return path2.join(evoLogsDir(), `apply-${applyId}.log`);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// ../server/dist/config.js
|
|
191
|
+
import fs8 from "node:fs";
|
|
192
|
+
import path11 from "node:path";
|
|
193
|
+
import { homedir as homedir5 } from "node:os";
|
|
194
|
+
import YAML5 from "yaml";
|
|
195
|
+
|
|
196
|
+
// ../server/dist/settings-schema.js
|
|
197
|
+
import fs7 from "node:fs";
|
|
198
|
+
import path10 from "node:path";
|
|
199
|
+
import { homedir as homedir4 } from "node:os";
|
|
200
|
+
import YAML4 from "yaml";
|
|
201
|
+
|
|
202
|
+
// ../server/dist/tools/sandbox.js
|
|
203
|
+
import { exec, execFile, spawn } from "node:child_process";
|
|
204
|
+
import { promisify } from "node:util";
|
|
205
|
+
import path3 from "node:path";
|
|
206
|
+
import { homedir as homedir2 } from "node:os";
|
|
207
|
+
|
|
208
|
+
// ../server/dist/child-env.js
|
|
209
|
+
var settingsOtelEnvKeys = /* @__PURE__ */ new Set();
|
|
210
|
+
function cleanChildEnv() {
|
|
211
|
+
const env = { ...process.env };
|
|
212
|
+
delete env.HALO_PASSWORD;
|
|
213
|
+
delete env.HALO_JWT_SECRET;
|
|
214
|
+
for (const key of settingsOtelEnvKeys)
|
|
215
|
+
delete env[key];
|
|
216
|
+
return env;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// ../server/dist/tools/sandbox.js
|
|
220
|
+
var execAsync = promisify(exec);
|
|
221
|
+
var execFileAsync = promisify(execFile);
|
|
222
|
+
var HOME = homedir2();
|
|
223
|
+
var HALO_HOME = path3.join(HOME, ".halo");
|
|
224
|
+
var EMPTY_MASK_FILE = path3.join(HALO_HOME, ".sandbox-empty");
|
|
225
|
+
var DEFAULT_HIDDEN_DIRS = [
|
|
226
|
+
"~/.halo/secrets",
|
|
227
|
+
"~/.aws",
|
|
228
|
+
"~/.ssh",
|
|
229
|
+
"~/.gnupg",
|
|
230
|
+
"~/.docker",
|
|
231
|
+
"~/.config/gh",
|
|
232
|
+
// Cross-workspace state under ~/.halo/global — internal-agent session
|
|
233
|
+
// transcripts and server/cron logs would leak other workspaces' activity
|
|
234
|
+
// to a workspace/readonly session (global/ is readable by design for
|
|
235
|
+
// skills/agents/prompts, so the sensitive subtrees are hidden explicitly).
|
|
236
|
+
"~/.halo/global/internal-sessions",
|
|
237
|
+
"~/.halo/global/logs"
|
|
238
|
+
];
|
|
239
|
+
var DEFAULT_HIDDEN_FILES = [
|
|
240
|
+
"~/.npmrc",
|
|
241
|
+
"~/.bash_history",
|
|
242
|
+
"~/.gitconfig",
|
|
243
|
+
// Halo's own git-credential store (git-credentials.ts writes tokens here
|
|
244
|
+
// in plaintext) — must be hidden like ~/.aws & co.
|
|
245
|
+
"~/.git-credentials",
|
|
246
|
+
"~/.netrc",
|
|
247
|
+
// Global evolution / cron databases (plus sqlite WAL/SHM sidecars) carry
|
|
248
|
+
// cross-workspace prompts, run history and channel targets — hidden from
|
|
249
|
+
// workspace/readonly sessions like the dirs above.
|
|
250
|
+
"~/.halo/global/evo.db",
|
|
251
|
+
"~/.halo/global/evo.db-wal",
|
|
252
|
+
"~/.halo/global/evo.db-shm",
|
|
253
|
+
"~/.halo/global/cron.db",
|
|
254
|
+
"~/.halo/global/cron.db-wal",
|
|
255
|
+
"~/.halo/global/cron.db-shm",
|
|
256
|
+
"~/.halo/global/runs.db",
|
|
257
|
+
"~/.halo/global/runs.db-wal",
|
|
258
|
+
"~/.halo/global/runs.db-shm",
|
|
259
|
+
// A2A tasks / push configs carry peers' webhook tokens.
|
|
260
|
+
"~/.halo/global/a2a.db",
|
|
261
|
+
"~/.halo/global/a2a.db-wal",
|
|
262
|
+
"~/.halo/global/a2a.db-shm"
|
|
263
|
+
];
|
|
264
|
+
|
|
265
|
+
// ../server/dist/models/registry.js
|
|
266
|
+
import fs2 from "node:fs";
|
|
267
|
+
import path4 from "node:path";
|
|
268
|
+
import YAML from "yaml";
|
|
269
|
+
var MODEL_RUNTIME_NAMES = [
|
|
270
|
+
"anthropic-messages",
|
|
271
|
+
"openai-chat",
|
|
272
|
+
"bedrock-invoke",
|
|
273
|
+
"bedrock-mantle",
|
|
274
|
+
"kimi",
|
|
275
|
+
"deepseek",
|
|
276
|
+
"minimax",
|
|
277
|
+
"qwen",
|
|
278
|
+
"hunyuan",
|
|
279
|
+
"doubao",
|
|
280
|
+
"zhipu",
|
|
281
|
+
"gemini"
|
|
282
|
+
];
|
|
283
|
+
function isKnownRuntime(runtime) {
|
|
284
|
+
return typeof runtime === "string" && MODEL_RUNTIME_NAMES.includes(runtime);
|
|
285
|
+
}
|
|
286
|
+
function revisionOf(data) {
|
|
287
|
+
return Number.isInteger(data.revision) ? data.revision : 0;
|
|
288
|
+
}
|
|
289
|
+
var logged = /* @__PURE__ */ new Set();
|
|
290
|
+
function logOnce(msg) {
|
|
291
|
+
if (logged.has(msg))
|
|
292
|
+
return;
|
|
293
|
+
logged.add(msg);
|
|
294
|
+
console.log(msg);
|
|
295
|
+
}
|
|
296
|
+
function readProviderDir(dir, source) {
|
|
297
|
+
let names;
|
|
298
|
+
try {
|
|
299
|
+
names = fs2.readdirSync(dir, { withFileTypes: true }).filter((e) => e.isFile() && e.name.endsWith(".yaml")).map((e) => e.name).sort();
|
|
300
|
+
} catch {
|
|
301
|
+
return [];
|
|
302
|
+
}
|
|
303
|
+
const out = [];
|
|
304
|
+
for (const name of names) {
|
|
305
|
+
const file = path4.join(dir, name);
|
|
306
|
+
try {
|
|
307
|
+
const parsed = YAML.parse(fs2.readFileSync(file, "utf-8"));
|
|
308
|
+
if (parsed && typeof parsed === "object" && typeof parsed.id === "string") {
|
|
309
|
+
out.push({
|
|
310
|
+
id: parsed.id,
|
|
311
|
+
source,
|
|
312
|
+
revision: revisionOf(parsed),
|
|
313
|
+
runtime: typeof parsed.runtime === "string" ? parsed.runtime : void 0,
|
|
314
|
+
file,
|
|
315
|
+
data: parsed
|
|
316
|
+
});
|
|
317
|
+
} else {
|
|
318
|
+
logOnce(`[Config] Skipping ${file}: missing provider id`);
|
|
319
|
+
}
|
|
320
|
+
} catch (err) {
|
|
321
|
+
logOnce(`[Config] Failed to load ${file}: ${err instanceof Error ? err.message : String(err)}`);
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
return out;
|
|
325
|
+
}
|
|
326
|
+
function mergeProviders(bundled, hub) {
|
|
327
|
+
const skipped = [];
|
|
328
|
+
const winner = /* @__PURE__ */ new Map();
|
|
329
|
+
for (const copy of bundled) {
|
|
330
|
+
const cur = winner.get(copy.id);
|
|
331
|
+
if (!cur || copy.revision > cur.revision)
|
|
332
|
+
winner.set(copy.id, copy);
|
|
333
|
+
}
|
|
334
|
+
for (const copy of hub) {
|
|
335
|
+
if (!isKnownRuntime(copy.runtime)) {
|
|
336
|
+
const reason = `unknown runtime "${copy.runtime ?? ""}" \u2014 needs a newer halo`;
|
|
337
|
+
logOnce(`[Config] Skipping ${copy.file}: ${reason}; the bundled copy stays in effect`);
|
|
338
|
+
skipped.push({ copy, reason });
|
|
339
|
+
continue;
|
|
340
|
+
}
|
|
341
|
+
const cur = winner.get(copy.id);
|
|
342
|
+
if (!cur || copy.revision >= cur.revision)
|
|
343
|
+
winner.set(copy.id, copy);
|
|
344
|
+
}
|
|
345
|
+
const order = [...new Set([...bundled, ...hub].map((c) => c.id))];
|
|
346
|
+
return { effective: order.flatMap((id) => winner.get(id) ?? []), skipped };
|
|
347
|
+
}
|
|
348
|
+
function loadProviders(bundledDir = globalModelsDir(), hubDir = globalHubModelsDir()) {
|
|
349
|
+
const bundled = readProviderDir(bundledDir, "bundled");
|
|
350
|
+
const hub = readProviderDir(hubDir, "hub");
|
|
351
|
+
return { ...mergeProviders(bundled, hub), all: [...bundled, ...hub] };
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// ../server/dist/extensions/registry.js
|
|
355
|
+
import fs4 from "node:fs";
|
|
356
|
+
import path7 from "node:path";
|
|
357
|
+
|
|
358
|
+
// ../server/dist/extensions/state.js
|
|
359
|
+
import fs3 from "node:fs";
|
|
360
|
+
import path6 from "node:path";
|
|
361
|
+
|
|
362
|
+
// ../server/dist/init.js
|
|
363
|
+
import path5 from "node:path";
|
|
364
|
+
import { fileURLToPath } from "node:url";
|
|
365
|
+
import YAML2 from "yaml";
|
|
366
|
+
var __dirname = path5.dirname(fileURLToPath(import.meta.url));
|
|
367
|
+
var TEMPLATES_DIR = path5.resolve(__dirname, "..", "templates");
|
|
368
|
+
var DOCS_SRC_CANDIDATES = [
|
|
369
|
+
path5.resolve(__dirname, "..", "bundled-docs"),
|
|
370
|
+
path5.resolve(__dirname, "..", "..", "..", ".halo", "docs")
|
|
371
|
+
];
|
|
372
|
+
|
|
373
|
+
// ../server/dist/extensions/state.js
|
|
374
|
+
function extensionStatePath() {
|
|
375
|
+
return path6.join(globalExtensionsDir(), ".state.json");
|
|
376
|
+
}
|
|
377
|
+
var cache = null;
|
|
378
|
+
function load() {
|
|
379
|
+
const file = extensionStatePath();
|
|
380
|
+
let st;
|
|
381
|
+
try {
|
|
382
|
+
st = fs3.statSync(file);
|
|
383
|
+
} catch {
|
|
384
|
+
return { states: {}, disabled: /* @__PURE__ */ new Set() };
|
|
385
|
+
}
|
|
386
|
+
const key = `${file}:${st.ino}:${st.mtimeMs}:${st.size}`;
|
|
387
|
+
if (cache?.key === key)
|
|
388
|
+
return cache;
|
|
389
|
+
let states = null;
|
|
390
|
+
try {
|
|
391
|
+
const raw = JSON.parse(fs3.readFileSync(file, "utf-8"));
|
|
392
|
+
if (typeof raw === "object" && raw !== null && !Array.isArray(raw))
|
|
393
|
+
states = raw;
|
|
394
|
+
} catch {
|
|
395
|
+
}
|
|
396
|
+
if (!states)
|
|
397
|
+
console.warn(`[Extensions] ${file} is not a JSON object \u2014 treating every extension as on; fix or delete the file`);
|
|
398
|
+
const disabled = new Set(Object.entries(states ?? {}).filter(([, s]) => isEntry(s) && s.disabled === true).map(([id]) => id));
|
|
399
|
+
cache = { key, states, disabled };
|
|
400
|
+
return cache;
|
|
401
|
+
}
|
|
402
|
+
function isEntry(v) {
|
|
403
|
+
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
404
|
+
}
|
|
405
|
+
function disabledExtensionIds() {
|
|
406
|
+
return load().disabled;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
// ../server/dist/extensions/registry.js
|
|
410
|
+
var MANIFEST_FILE = "halo-extension.json";
|
|
411
|
+
var ID_RE = /^[a-z0-9][a-z0-9_-]{0,63}$/;
|
|
412
|
+
var VERSION_RE = /^\d+\.\d+\.\d+(-[0-9A-Za-z.-]+)?$/;
|
|
413
|
+
var FILE_EXT_RE = /^\.[a-z0-9]+$/;
|
|
414
|
+
var CAPABILITIES = /* @__PURE__ */ new Set(["save", "media", "transcribe", "fs-read", "stt-local"]);
|
|
415
|
+
var PLATFORMS = /* @__PURE__ */ new Set(["web", "desktop-mac", "desktop-win", "desktop-linux"]);
|
|
416
|
+
var SETTING_KEY_RE = /^[a-z][a-z0-9_]{0,63}$/;
|
|
417
|
+
var SETTING_TYPES = /* @__PURE__ */ new Set(["string", "int", "float", "boolean", "enum"]);
|
|
418
|
+
function parseSettings(raw) {
|
|
419
|
+
if (typeof raw !== "object" || raw === null || Array.isArray(raw))
|
|
420
|
+
return { error: "settings must be an object" };
|
|
421
|
+
const s = raw;
|
|
422
|
+
const seen = /* @__PURE__ */ new Set();
|
|
423
|
+
const out = {};
|
|
424
|
+
for (const kind of ["params", "secrets"]) {
|
|
425
|
+
if (s[kind] === void 0)
|
|
426
|
+
continue;
|
|
427
|
+
if (!Array.isArray(s[kind]))
|
|
428
|
+
return { error: `settings.${kind} must be an array` };
|
|
429
|
+
const fields = [];
|
|
430
|
+
for (const item of s[kind]) {
|
|
431
|
+
if (typeof item !== "object" || item === null || Array.isArray(item))
|
|
432
|
+
return { error: `settings.${kind} entries must be objects` };
|
|
433
|
+
const f = item;
|
|
434
|
+
if (typeof f.key !== "string" || !SETTING_KEY_RE.test(f.key))
|
|
435
|
+
return { error: `settings key "${String(f.key)}" must match ^[a-z][a-z0-9_]{0,63}$` };
|
|
436
|
+
if (seen.has(f.key))
|
|
437
|
+
return { error: `duplicate settings key: ${f.key}` };
|
|
438
|
+
seen.add(f.key);
|
|
439
|
+
for (const k of ["description", "description_zh"]) {
|
|
440
|
+
if (f[k] !== void 0 && typeof f[k] !== "string")
|
|
441
|
+
return { error: `settings ${f.key}.${k} must be a string` };
|
|
442
|
+
}
|
|
443
|
+
if (f.default !== void 0 && !["string", "number", "boolean"].includes(typeof f.default))
|
|
444
|
+
return { error: `settings ${f.key}.default must be a scalar` };
|
|
445
|
+
if (f.type !== void 0 && (typeof f.type !== "string" || !SETTING_TYPES.has(f.type)))
|
|
446
|
+
return { error: `settings ${f.key}.type must be one of string|int|float|boolean|enum` };
|
|
447
|
+
if (f.options !== void 0 && (!Array.isArray(f.options) || f.options.length === 0 || f.options.some((o) => typeof o !== "string"))) {
|
|
448
|
+
return { error: `settings ${f.key}.options must be a non-empty string array` };
|
|
449
|
+
}
|
|
450
|
+
if (f.type === "enum" && f.options === void 0)
|
|
451
|
+
return { error: `settings ${f.key}: type enum requires options` };
|
|
452
|
+
fields.push({
|
|
453
|
+
key: f.key,
|
|
454
|
+
...f.description !== void 0 ? { description: f.description } : {},
|
|
455
|
+
...f.description_zh !== void 0 ? { description_zh: f.description_zh } : {},
|
|
456
|
+
...f.default !== void 0 ? { default: String(f.default) } : {},
|
|
457
|
+
...f.type !== void 0 ? { type: f.type } : {},
|
|
458
|
+
...f.options !== void 0 ? { options: f.options } : {}
|
|
459
|
+
});
|
|
460
|
+
}
|
|
461
|
+
if (fields.length > 0)
|
|
462
|
+
out[kind] = fields;
|
|
463
|
+
}
|
|
464
|
+
return out;
|
|
465
|
+
}
|
|
466
|
+
function isExtensionId(id) {
|
|
467
|
+
return ID_RE.test(id);
|
|
468
|
+
}
|
|
469
|
+
function extensionsRoot() {
|
|
470
|
+
return globalExtensionsDir();
|
|
471
|
+
}
|
|
472
|
+
function parseManifest(raw, dirName, entryExists) {
|
|
473
|
+
if (typeof raw !== "object" || raw === null || Array.isArray(raw))
|
|
474
|
+
return { error: "manifest must be a JSON object" };
|
|
475
|
+
const m = raw;
|
|
476
|
+
if (typeof m.id !== "string" || !ID_RE.test(m.id))
|
|
477
|
+
return { error: "id must match ^[a-z0-9][a-z0-9_-]{0,63}$" };
|
|
478
|
+
if (m.id !== dirName)
|
|
479
|
+
return { error: `id mismatch: manifest "${m.id}" vs directory "${dirName}"` };
|
|
480
|
+
if (typeof m.name !== "string" || m.name.trim() === "" || m.name.length > 64)
|
|
481
|
+
return { error: "name must be a non-empty string (\u2264 64 chars)" };
|
|
482
|
+
if (typeof m.version !== "string" || !VERSION_RE.test(m.version))
|
|
483
|
+
return { error: "version must be semver (x.y.z[-pre])" };
|
|
484
|
+
if (m.description !== void 0 && (typeof m.description !== "string" || m.description.length > 200))
|
|
485
|
+
return { error: "description must be a string (\u2264 200 chars)" };
|
|
486
|
+
let activity;
|
|
487
|
+
if (m.activity !== void 0) {
|
|
488
|
+
if (typeof m.activity !== "object" || m.activity === null || Array.isArray(m.activity))
|
|
489
|
+
return { error: "activity must be an object" };
|
|
490
|
+
const a = m.activity;
|
|
491
|
+
activity = { order: 100 };
|
|
492
|
+
if (a.icon !== void 0) {
|
|
493
|
+
if (typeof a.icon !== "string" || a.icon === "" || a.icon.startsWith("/") || a.icon.split(/[\\/]/).includes("..") || !/\.(svg|png)$/i.test(a.icon)) {
|
|
494
|
+
return { error: "activity.icon must be a relative .svg or .png path inside the extension directory" };
|
|
495
|
+
}
|
|
496
|
+
if (!entryExists(a.icon))
|
|
497
|
+
return { error: `activity icon not found: ${a.icon}` };
|
|
498
|
+
activity.icon = a.icon;
|
|
499
|
+
}
|
|
500
|
+
for (const k of ["title", "title_zh"]) {
|
|
501
|
+
const v = a[k];
|
|
502
|
+
if (v === void 0)
|
|
503
|
+
continue;
|
|
504
|
+
if (typeof v !== "string" || v.trim() === "" || v.length > 64)
|
|
505
|
+
return { error: `activity.${k} must be a non-empty string (\u2264 64 chars)` };
|
|
506
|
+
activity[k] = v;
|
|
507
|
+
}
|
|
508
|
+
for (const k of ["description", "description_zh"]) {
|
|
509
|
+
const v = a[k];
|
|
510
|
+
if (v === void 0)
|
|
511
|
+
continue;
|
|
512
|
+
if (typeof v !== "string" || v.length > 200)
|
|
513
|
+
return { error: `activity.${k} must be a string (\u2264 200 chars)` };
|
|
514
|
+
activity[k] = v;
|
|
515
|
+
}
|
|
516
|
+
if (a.order !== void 0) {
|
|
517
|
+
if (typeof a.order !== "number" || !Number.isInteger(a.order) || a.order < 0 || a.order > 1e3)
|
|
518
|
+
return { error: "activity.order must be an integer 0\u20131000" };
|
|
519
|
+
activity.order = a.order;
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
let chatbar;
|
|
523
|
+
if (m.chatbar !== void 0) {
|
|
524
|
+
if (typeof m.chatbar !== "object" || m.chatbar === null || Array.isArray(m.chatbar))
|
|
525
|
+
return { error: "chatbar must be an object" };
|
|
526
|
+
const c = m.chatbar;
|
|
527
|
+
chatbar = { height: 160, order: 100 };
|
|
528
|
+
if (c.icon !== void 0) {
|
|
529
|
+
if (typeof c.icon !== "string" || c.icon === "" || c.icon.startsWith("/") || c.icon.split(/[\\/]/).includes("..") || !/\.(svg|png)$/i.test(c.icon)) {
|
|
530
|
+
return { error: "chatbar.icon must be a relative .svg or .png path inside the extension directory" };
|
|
531
|
+
}
|
|
532
|
+
if (!entryExists(c.icon))
|
|
533
|
+
return { error: `chatbar icon not found: ${c.icon}` };
|
|
534
|
+
chatbar.icon = c.icon;
|
|
535
|
+
}
|
|
536
|
+
for (const k of ["title", "title_zh"]) {
|
|
537
|
+
const v = c[k];
|
|
538
|
+
if (v === void 0)
|
|
539
|
+
continue;
|
|
540
|
+
if (typeof v !== "string" || v.trim() === "" || v.length > 64)
|
|
541
|
+
return { error: `chatbar.${k} must be a non-empty string (\u2264 64 chars)` };
|
|
542
|
+
chatbar[k] = v;
|
|
543
|
+
}
|
|
544
|
+
if (c.height !== void 0) {
|
|
545
|
+
if (typeof c.height !== "number" || !Number.isInteger(c.height) || c.height < 80 || c.height > 480)
|
|
546
|
+
return { error: "chatbar.height must be an integer 80\u2013480" };
|
|
547
|
+
chatbar.height = c.height;
|
|
548
|
+
}
|
|
549
|
+
if (c.order !== void 0) {
|
|
550
|
+
if (typeof c.order !== "number" || !Number.isInteger(c.order) || c.order < 0 || c.order > 1e3)
|
|
551
|
+
return { error: "chatbar.order must be an integer 0\u20131000" };
|
|
552
|
+
chatbar.order = c.order;
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
if (activity || chatbar) {
|
|
556
|
+
if (!Array.isArray(m.extensions))
|
|
557
|
+
return { error: "extensions must be an array (empty is fine with activity or chatbar)" };
|
|
558
|
+
} else if (!Array.isArray(m.extensions) || m.extensions.length === 0)
|
|
559
|
+
return { error: "extensions must be a non-empty array" };
|
|
560
|
+
for (const e of m.extensions) {
|
|
561
|
+
if (typeof e !== "string" || !FILE_EXT_RE.test(e))
|
|
562
|
+
return { error: `extensions entry "${String(e)}" must look like ".glb" (lowercase, with dot)` };
|
|
563
|
+
}
|
|
564
|
+
if (typeof m.entry !== "string" || m.entry === "" || m.entry.startsWith("/") || m.entry.split(/[\\/]/).includes("..")) {
|
|
565
|
+
return { error: "entry must be a relative path inside the extension directory" };
|
|
566
|
+
}
|
|
567
|
+
if (!entryExists(m.entry))
|
|
568
|
+
return { error: `entry not found: ${m.entry}` };
|
|
569
|
+
let priority = "default";
|
|
570
|
+
if (m.priority !== void 0) {
|
|
571
|
+
if (m.priority !== "default" && m.priority !== "option")
|
|
572
|
+
return { error: 'priority must be "default" or "option"' };
|
|
573
|
+
priority = m.priority;
|
|
574
|
+
}
|
|
575
|
+
const capabilities = [];
|
|
576
|
+
if (m.capabilities !== void 0) {
|
|
577
|
+
if (!Array.isArray(m.capabilities))
|
|
578
|
+
return { error: "capabilities must be an array" };
|
|
579
|
+
for (const cap of m.capabilities) {
|
|
580
|
+
if (typeof cap !== "string" || !CAPABILITIES.has(cap))
|
|
581
|
+
return { error: `unknown capability: ${String(cap)}` };
|
|
582
|
+
if (!capabilities.includes(cap))
|
|
583
|
+
capabilities.push(cap);
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
if (m.bundle !== void 0 && typeof m.bundle !== "boolean")
|
|
587
|
+
return { error: "bundle must be a boolean" };
|
|
588
|
+
const bundle = m.bundle === true;
|
|
589
|
+
if (bundle && capabilities.includes("save"))
|
|
590
|
+
return { error: "bundle extensions cannot declare save" };
|
|
591
|
+
let platforms;
|
|
592
|
+
if (m.platforms !== void 0) {
|
|
593
|
+
if (!Array.isArray(m.platforms) || m.platforms.length === 0)
|
|
594
|
+
return { error: "platforms must be a non-empty array" };
|
|
595
|
+
platforms = [];
|
|
596
|
+
for (const p of m.platforms) {
|
|
597
|
+
if (typeof p !== "string" || !PLATFORMS.has(p))
|
|
598
|
+
return { error: `unknown platform: ${String(p)}` };
|
|
599
|
+
if (!platforms.includes(p))
|
|
600
|
+
platforms.push(p);
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
let settings;
|
|
604
|
+
if (m.settings !== void 0) {
|
|
605
|
+
const parsed = parseSettings(m.settings);
|
|
606
|
+
if ("error" in parsed)
|
|
607
|
+
return parsed;
|
|
608
|
+
if (parsed.params || parsed.secrets)
|
|
609
|
+
settings = parsed;
|
|
610
|
+
}
|
|
611
|
+
if (m.homepage !== void 0 && (typeof m.homepage !== "string" || !/^https?:\/\//.test(m.homepage)))
|
|
612
|
+
return { error: "homepage must be an http(s) URL" };
|
|
613
|
+
if (m.license !== void 0 && typeof m.license !== "string")
|
|
614
|
+
return { error: "license must be a string" };
|
|
615
|
+
if (m.source !== void 0 && (typeof m.source !== "string" || !/^https:\/\/\S+$/.test(m.source) || !URL.canParse(m.source)))
|
|
616
|
+
return { error: "source must be an https URL (of the update.json)" };
|
|
617
|
+
return {
|
|
618
|
+
id: m.id,
|
|
619
|
+
name: m.name,
|
|
620
|
+
version: m.version,
|
|
621
|
+
...m.description !== void 0 ? { description: m.description } : {},
|
|
622
|
+
extensions: m.extensions,
|
|
623
|
+
entry: m.entry,
|
|
624
|
+
priority,
|
|
625
|
+
capabilities,
|
|
626
|
+
bundle,
|
|
627
|
+
...platforms ? { platforms } : {},
|
|
628
|
+
...settings ? { settings } : {},
|
|
629
|
+
...activity ? { activity } : {},
|
|
630
|
+
...chatbar ? { chatbar } : {},
|
|
631
|
+
...m.homepage !== void 0 ? { homepage: m.homepage } : {},
|
|
632
|
+
...m.license !== void 0 ? { license: m.license } : {},
|
|
633
|
+
...m.source !== void 0 ? { source: m.source } : {}
|
|
634
|
+
};
|
|
635
|
+
}
|
|
636
|
+
function readExtensionDir(dir, id = path7.basename(dir)) {
|
|
637
|
+
let raw;
|
|
638
|
+
try {
|
|
639
|
+
raw = JSON.parse(fs4.readFileSync(path7.join(dir, MANIFEST_FILE), "utf-8"));
|
|
640
|
+
} catch (err) {
|
|
641
|
+
const code = err.code;
|
|
642
|
+
return { id, error: code === "ENOENT" ? `${MANIFEST_FILE} missing` : `${MANIFEST_FILE} unreadable: ${err instanceof Error ? err.message : String(err)}` };
|
|
643
|
+
}
|
|
644
|
+
const parsed = parseManifest(raw, id, (entry) => {
|
|
645
|
+
try {
|
|
646
|
+
return fs4.statSync(path7.join(dir, entry)).isFile();
|
|
647
|
+
} catch {
|
|
648
|
+
return false;
|
|
649
|
+
}
|
|
650
|
+
});
|
|
651
|
+
if ("error" in parsed)
|
|
652
|
+
return { id, error: parsed.error };
|
|
653
|
+
let installedAt = 0;
|
|
654
|
+
try {
|
|
655
|
+
installedAt = Math.round(fs4.statSync(dir).mtimeMs);
|
|
656
|
+
} catch {
|
|
657
|
+
}
|
|
658
|
+
return { ...parsed, installedAt };
|
|
659
|
+
}
|
|
660
|
+
var snapshot = { extensions: [], disabled: [], errors: [] };
|
|
661
|
+
function getSnapshot() {
|
|
662
|
+
return snapshot;
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
// ../server/dist/agents/skill-sources.js
|
|
666
|
+
import fs5 from "node:fs";
|
|
667
|
+
import path8 from "node:path";
|
|
668
|
+
import { homedir as homedir3 } from "node:os";
|
|
669
|
+
|
|
670
|
+
// ../server/dist/routes/mtime-cache.js
|
|
671
|
+
function createMtimeCache() {
|
|
672
|
+
const entries = /* @__PURE__ */ new Map();
|
|
673
|
+
return {
|
|
674
|
+
get(filePath, mtimeMs) {
|
|
675
|
+
const hit = entries.get(filePath);
|
|
676
|
+
return hit && hit.mtimeMs === mtimeMs ? hit.value : void 0;
|
|
677
|
+
},
|
|
678
|
+
set(filePath, mtimeMs, value) {
|
|
679
|
+
entries.set(filePath, { mtimeMs, value });
|
|
680
|
+
return value;
|
|
681
|
+
}
|
|
682
|
+
};
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
// ../server/dist/agents/skill-sources.js
|
|
686
|
+
var GLOBAL_SKILLS_DIR = path8.join(homedir3(), ".halo", "global", "skills");
|
|
687
|
+
var SKILL_ID_RE = /^[\w-]+$/;
|
|
688
|
+
function isFile(p) {
|
|
689
|
+
try {
|
|
690
|
+
return fs5.statSync(p).isFile();
|
|
691
|
+
} catch {
|
|
692
|
+
return false;
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
function childDirs(parent) {
|
|
696
|
+
let names;
|
|
697
|
+
try {
|
|
698
|
+
names = fs5.readdirSync(parent);
|
|
699
|
+
} catch {
|
|
700
|
+
return [];
|
|
701
|
+
}
|
|
702
|
+
return names.filter((n) => {
|
|
703
|
+
try {
|
|
704
|
+
return fs5.statSync(path8.join(parent, n)).isDirectory();
|
|
705
|
+
} catch {
|
|
706
|
+
return false;
|
|
707
|
+
}
|
|
708
|
+
});
|
|
709
|
+
}
|
|
710
|
+
function isExtensionSkillId(extId, id) {
|
|
711
|
+
return SKILL_ID_RE.test(id) && (id === extId || id.startsWith(`${extId}-`));
|
|
712
|
+
}
|
|
713
|
+
var _manifestEntry = createMtimeCache();
|
|
714
|
+
var _warned = /* @__PURE__ */ new Set();
|
|
715
|
+
function extensionSkillDirs() {
|
|
716
|
+
const root = extensionsRoot();
|
|
717
|
+
let extIds;
|
|
718
|
+
try {
|
|
719
|
+
extIds = fs5.readdirSync(root);
|
|
720
|
+
} catch {
|
|
721
|
+
return [];
|
|
722
|
+
}
|
|
723
|
+
const out = [];
|
|
724
|
+
const taken = /* @__PURE__ */ new Set();
|
|
725
|
+
let off;
|
|
726
|
+
for (const extId of extIds.filter(isExtensionId).sort()) {
|
|
727
|
+
const extDir = path8.join(root, extId);
|
|
728
|
+
const ids = childDirs(path8.join(extDir, "skills"));
|
|
729
|
+
if (ids.length === 0)
|
|
730
|
+
continue;
|
|
731
|
+
off ??= disabledExtensionIds();
|
|
732
|
+
if (off.has(extId))
|
|
733
|
+
continue;
|
|
734
|
+
const manifest = path8.join(extDir, MANIFEST_FILE);
|
|
735
|
+
let mtimeMs;
|
|
736
|
+
try {
|
|
737
|
+
mtimeMs = fs5.statSync(manifest).mtimeMs;
|
|
738
|
+
} catch {
|
|
739
|
+
continue;
|
|
740
|
+
}
|
|
741
|
+
let entry = _manifestEntry.get(manifest, mtimeMs);
|
|
742
|
+
if (entry === void 0) {
|
|
743
|
+
const ext = readExtensionDir(extDir);
|
|
744
|
+
entry = _manifestEntry.set(manifest, mtimeMs, "error" in ext ? null : path8.join(extDir, ext.entry));
|
|
745
|
+
}
|
|
746
|
+
if (!entry || !isFile(entry))
|
|
747
|
+
continue;
|
|
748
|
+
const ignored = [];
|
|
749
|
+
for (const id of ids.sort()) {
|
|
750
|
+
const dir = path8.join(extDir, "skills", id);
|
|
751
|
+
if (!fs5.existsSync(path8.join(dir, "SKILL.md")))
|
|
752
|
+
continue;
|
|
753
|
+
if (!isExtensionSkillId(extId, id) || taken.has(id)) {
|
|
754
|
+
ignored.push(id);
|
|
755
|
+
continue;
|
|
756
|
+
}
|
|
757
|
+
taken.add(id);
|
|
758
|
+
out.push({ id, dir, scope: "extension", extId });
|
|
759
|
+
}
|
|
760
|
+
const warnKey = `${manifest}@${mtimeMs}`;
|
|
761
|
+
if (ignored.length > 0 && !_warned.has(warnKey)) {
|
|
762
|
+
_warned.add(warnKey);
|
|
763
|
+
console.warn(`[Skills] extension "${extId}": ignoring skills/${ignored.join(", skills/")} \u2014 an extension skill id must be "${extId}" or start with "${extId}-", and be unique across extensions`);
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
return out;
|
|
767
|
+
}
|
|
768
|
+
function listSkillDirs(workspaceRoot) {
|
|
769
|
+
const out = [];
|
|
770
|
+
const seen = /* @__PURE__ */ new Set();
|
|
771
|
+
const add = (s) => {
|
|
772
|
+
out.push({ ...s, overridden: seen.has(s.id) });
|
|
773
|
+
seen.add(s.id);
|
|
774
|
+
};
|
|
775
|
+
if (workspaceRoot) {
|
|
776
|
+
const wsDir = path8.join(workspaceRoot, ".halo", "skills");
|
|
777
|
+
for (const id of childDirs(wsDir))
|
|
778
|
+
add({ id, dir: path8.join(wsDir, id), scope: "workspace" });
|
|
779
|
+
}
|
|
780
|
+
for (const id of childDirs(GLOBAL_SKILLS_DIR))
|
|
781
|
+
add({ id, dir: path8.join(GLOBAL_SKILLS_DIR, id), scope: "global" });
|
|
782
|
+
for (const s of extensionSkillDirs())
|
|
783
|
+
add(s);
|
|
784
|
+
return out;
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
// ../server/dist/a2a/remotes.js
|
|
788
|
+
import fs6 from "node:fs";
|
|
789
|
+
import path9 from "node:path";
|
|
790
|
+
import YAML3 from "yaml";
|
|
791
|
+
function remotesFile() {
|
|
792
|
+
return path9.join(secretsDir(), "a2a-remotes.yaml");
|
|
793
|
+
}
|
|
794
|
+
function loadRemotes() {
|
|
795
|
+
let doc;
|
|
796
|
+
try {
|
|
797
|
+
doc = YAML3.parse(fs6.readFileSync(remotesFile(), "utf8")) ?? {};
|
|
798
|
+
} catch {
|
|
799
|
+
return {};
|
|
800
|
+
}
|
|
801
|
+
const out = {};
|
|
802
|
+
for (const [name, r] of Object.entries(doc.remotes ?? {})) {
|
|
803
|
+
if (!r?.card)
|
|
804
|
+
continue;
|
|
805
|
+
out[name] = { name, card: r.card, auth: r.auth === "sigv4" ? "sigv4" : "bearer", pushBase: r.push_base?.replace(/\/+$/, "") };
|
|
806
|
+
}
|
|
807
|
+
return out;
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
// ../server/dist/settings-schema.js
|
|
811
|
+
var GLOBAL_AGENTS_DIR = path10.join(homedir4(), ".halo", "global", "agents");
|
|
812
|
+
var DEFAULT_A2A_URL_ALLOWLIST = "100.64.0.0/10,*.ts.net";
|
|
813
|
+
var DEFAULT_FILES_SUMMARY = DEFAULT_HIDDEN_FILES.filter((f) => !/-(wal|shm)$/.test(f)).join(", ");
|
|
814
|
+
function generalSection() {
|
|
815
|
+
return {
|
|
816
|
+
namespaceId: "general",
|
|
817
|
+
source: "general",
|
|
818
|
+
displayName: "General",
|
|
819
|
+
displayName_zh: "\u5E38\u89C4",
|
|
820
|
+
description: "Server behavior parameters (session limits, compaction, sandbox, logging).",
|
|
821
|
+
description_zh: "\u670D\u52A1\u7AEF\u884C\u4E3A\u53C2\u6570\uFF08\u4F1A\u8BDD\u9650\u5236\u3001\u538B\u7F29\u3001\u6C99\u7BB1\u3001\u65E5\u5FD7\uFF09\u3002",
|
|
822
|
+
// Every general.* field is globalOnly: config.ts reads them via
|
|
823
|
+
// settingsValue() from the global settings.yaml only. Per-workspace
|
|
824
|
+
// layering exists solely for namespaced params/secrets (getServerSecret).
|
|
825
|
+
fields: [
|
|
826
|
+
// system-wide language preference. Read by internal agents (evo, score)
|
|
827
|
+
// that don't load INSTRUCTIONS.md, and available to anything else that
|
|
828
|
+
// wants a language hint without inspecting per-session state.
|
|
829
|
+
{ key: "language", type: "enum", options: ["en-US", "zh-CN"], optionLabels: ["English", "\u4E2D\u6587"], globalOnly: true, description: "System language (BCP-47). Drives the admin UI language and any internal agents (evo / score) that don't load INSTRUCTIONS.md. Single source of truth.", description_zh: "\u7CFB\u7EDF\u8BED\u8A00\uFF08BCP-47 \u533A\u57DF\u7801\uFF09\u3002admin UI \u4E0E\u4E0D\u52A0\u8F7D INSTRUCTIONS.md \u7684\u5185\u90E8 agent\uFF08evo / score\uFF09\u5171\u7528\u3002\u5355\u4E00\u6765\u6E90\u3002", default: "en-US" },
|
|
830
|
+
// admin UI color theme. Consumed only by the admin frontend (shared/theme);
|
|
831
|
+
// stored server-side so the choice follows the user across browsers.
|
|
832
|
+
{ key: "theme", type: "enum", options: ["dark", "light", "midnight", "warm"], optionLabels: ["Dark", "Light", "Midnight", "Warm"], globalOnly: true, description: "Admin UI color theme.", description_zh: "Admin \u754C\u9762\u914D\u8272\u4E3B\u9898\u3002", default: "dark" },
|
|
833
|
+
{ key: "agent.max_retries", type: "int", globalOnly: true, restartRequired: true, description: "Max attempts per model call on transient errors (rate limit, 5xx, network). Backoff grows between attempts.", description_zh: "\u5355\u6B21\u6A21\u578B\u8C03\u7528\u9047\u5230\u77AC\u6001\u9519\u8BEF\uFF08\u9650\u6D41\u30015xx\u3001\u7F51\u7EDC\uFF09\u65F6\u7684\u6700\u5927\u5C1D\u8BD5\u6B21\u6570\uFF0C\u91CD\u8BD5\u95F4\u9694\u9012\u589E\u3002", default: "5" },
|
|
834
|
+
// server — network-facing knobs; global-only since they alter how the
|
|
835
|
+
// server itself authenticates clients.
|
|
836
|
+
{ key: "server.trust_proxy", type: "boolean", globalOnly: true, description: "Trust the x-forwarded-for header for client IP resolution (brute-force rate limiting). Enable ONLY when a reverse proxy you control sits in front and rewrites the header \u2014 otherwise clients can forge it to bypass lockouts.", description_zh: "\u4FE1\u4EFB x-forwarded-for \u8BF7\u6C42\u5934\u89E3\u6790\u5BA2\u6237\u7AEF IP\uFF08\u66B4\u529B\u7834\u89E3\u9650\u901F\u7528\uFF09\u3002\u4EC5\u5F53\u524D\u9762\u6709\u4F60\u63A7\u5236\u7684\u53CD\u5411\u4EE3\u7406\u5E76\u4F1A\u91CD\u5199\u8BE5\u5934\u65F6\u624D\u5F00\u542F\u2014\u2014\u5426\u5219\u5BA2\u6237\u7AEF\u53EF\u4F2A\u9020\u7ED5\u8FC7\u9501\u5B9A\u3002", default: "false" },
|
|
837
|
+
// session
|
|
838
|
+
{ key: "session.max_queue_size", type: "int", globalOnly: true, restartRequired: true, description: "Maximum queued messages per session", description_zh: "\u6BCF\u4E2A\u4F1A\u8BDD\u6700\u5927\u6392\u961F\u6D88\u606F\u6570", default: "256" },
|
|
839
|
+
{ key: "session.max_nesting_depth", type: "int", globalOnly: true, restartRequired: true, description: "Maximum session nesting depth for agent delegation", description_zh: "Agent \u59D4\u6D3E\u7684\u6700\u5927\u4F1A\u8BDD\u5D4C\u5957\u6DF1\u5EA6", default: "16" },
|
|
840
|
+
// compact
|
|
841
|
+
{ key: "compact.compress_at", type: "float", globalOnly: true, description: "Auto-compact threshold as a fraction of max context (e.g. 0.9 = compact when 90% full)", description_zh: "\u81EA\u52A8\u538B\u7F29\u9608\u503C\uFF0C\u6700\u5927\u4E0A\u4E0B\u6587\u7684\u6BD4\u4F8B\uFF08\u5982 0.9 \u8868\u793A\u7528\u6EE1 90% \u65F6\u538B\u7F29\uFF09", default: "0.9" },
|
|
842
|
+
{ key: "compact.keep_messages", type: "int", globalOnly: true, restartRequired: true, description: "Recent messages kept intact during compaction", description_zh: "\u538B\u7F29\u65F6\u4FDD\u7559\u6700\u540E\u591A\u5C11\u6761\u6D88\u606F\u4E0D\u52A8", default: "5" },
|
|
843
|
+
{ key: "compact.max_summary_input", type: "int", globalOnly: true, restartRequired: true, description: "Max chars fed into local truncation fallback", description_zh: "\u672C\u5730\u622A\u65AD\u515C\u5E95\u65F6\u7684\u603B\u8F93\u5165\u5B57\u7B26\u4E0A\u9650", default: "15000" },
|
|
844
|
+
{ key: "compact.max_message_slice", type: "int", globalOnly: true, restartRequired: true, description: "Max chars kept per old message during local truncation", description_zh: "\u672C\u5730\u622A\u65AD\u515C\u5E95\u65F6\u6BCF\u6761\u65E7\u6D88\u606F\u4FDD\u7559\u7684\u6700\u5927\u5B57\u7B26\u6570", default: "800" },
|
|
845
|
+
// sandbox (Linux bwrap / macOS sandbox-exec) — global-only: these define the security
|
|
846
|
+
// boundary agents run inside; a workspace overriding them could lift
|
|
847
|
+
// its own sandbox constraints.
|
|
848
|
+
{ key: "sandbox.hidden_dirs", globalOnly: true, description: `Extra comma-separated dirs to hide from workspace/readonly sessions (Linux bwrap / macOS sandbox-exec). Applies only to those sessions \u2014 Full sessions, the admin file explorer and the terminal are not affected. Added to the built-in list, which is always included and can't be removed here: ${DEFAULT_HIDDEN_DIRS.join(", ")}.`, description_zh: `\u989D\u5916\u8981\u5BF9 workspace/readonly \u4F1A\u8BDD\u9690\u85CF\u7684\u76EE\u5F55\uFF08\u9017\u53F7\u5206\u9694\uFF0CLinux bwrap / macOS sandbox-exec\uFF09\u3002\u53EA\u5BF9\u8FD9\u4E24\u7C7B\u4F1A\u8BDD\u751F\u6548\uFF0CFull \u4F1A\u8BDD\u3001\u6587\u4EF6\u6D4F\u89C8\u5668\u3001\u7EC8\u7AEF\u4E0D\u53D7\u5F71\u54CD\u3002\u4F1A\u8FFD\u52A0\u5230\u5185\u7F6E\u5217\u8868\u4E4B\u540E\uFF0C\u5185\u7F6E\u9879\u59CB\u7EC8\u5728\u5217\u8868\u4E2D\u3001\u4E0D\u80FD\u5728\u8FD9\u91CC\u79FB\u9664\uFF1A${DEFAULT_HIDDEN_DIRS.join("\u3001")}\u3002`, default: "" },
|
|
849
|
+
{ key: "sandbox.writable_dirs", globalOnly: true, description: "Comma-separated dirs writable inside the sandbox besides the workspace (Linux / macOS) \u2014 for external CLIs that keep local state, e.g. ~/.kiro,~/.local/share/kiro-cli. Not applied to readonly sessions.", description_zh: "\u9664 workspace \u5916\u5728\u6C99\u7BB1\u5185\u53EF\u5199\u7684\u76EE\u5F55\uFF08\u9017\u53F7\u5206\u9694\uFF0CLinux / macOS\uFF09\u2014\u2014\u7ED9\u9700\u8981\u672C\u5730\u72B6\u6001\u7684\u5916\u90E8 CLI \u7528\uFF0C\u5982 ~/.kiro,~/.local/share/kiro-cli\u3002readonly \u4F1A\u8BDD\u4E0D\u751F\u6548\u3002", default: "" },
|
|
850
|
+
{ key: "sandbox.hidden_files", globalOnly: true, description: `Extra comma-separated files to hide from workspace/readonly sessions \u2014 read as empty (Linux / macOS). Applies only to those sessions \u2014 Full sessions, the admin file explorer and the terminal are not affected. Added to the built-in list, which is always included and can't be removed here: ${DEFAULT_FILES_SUMMARY} (plus their -wal / -shm sidecars).`, description_zh: `\u989D\u5916\u8981\u5BF9 workspace/readonly \u4F1A\u8BDD\u9690\u85CF\u7684\u6587\u4EF6\uFF0C\u8BFB\u5230\u7684\u662F\u7A7A\u6587\u4EF6\uFF08\u9017\u53F7\u5206\u9694\uFF0CLinux / macOS\uFF09\u3002\u53EA\u5BF9\u8FD9\u4E24\u7C7B\u4F1A\u8BDD\u751F\u6548\uFF0CFull \u4F1A\u8BDD\u3001\u6587\u4EF6\u6D4F\u89C8\u5668\u3001\u7EC8\u7AEF\u4E0D\u53D7\u5F71\u54CD\u3002\u4F1A\u8FFD\u52A0\u5230\u5185\u7F6E\u5217\u8868\u4E4B\u540E\uFF0C\u5185\u7F6E\u9879\u59CB\u7EC8\u5728\u5217\u8868\u4E2D\u3001\u4E0D\u80FD\u5728\u8FD9\u91CC\u79FB\u9664\uFF1A${DEFAULT_FILES_SUMMARY}\uFF08\u53CA\u5176 -wal / -shm \u9644\u5C5E\u6587\u4EF6\uFF09\u3002`, default: "" },
|
|
851
|
+
// logging
|
|
852
|
+
{ key: "logging.level", type: "enum", options: ["debug", "info", "warn", "error"], globalOnly: true, restartRequired: true, description: "Log level", description_zh: "\u65E5\u5FD7\u7EA7\u522B", default: "warn" },
|
|
853
|
+
// observability (OpenTelemetry). Read once at boot by observability/otel.ts
|
|
854
|
+
// and mapped onto the standard OTEL_* env vars — hence "takes effect on
|
|
855
|
+
// restart" everywhere below. Children don't inherit those settings-derived
|
|
856
|
+
// vars (child-env.ts), so each cron / evo cli run re-reads settings.
|
|
857
|
+
{ key: "observability.endpoint", type: "string", globalOnly: true, restartRequired: true, description: "OTLP base URL of an OpenTelemetry collector (e.g. http://localhost:4318). Traces, metrics and logs are all exported there over OTLP http/protobuf \u2014 http:// or https://, on the collector's OTLP/HTTP port (4318 by default); gRPC (4317) is not supported. Empty = observability off. Takes effect on server restart; cron / evolution runs use the new value on their next run.", description_zh: "OpenTelemetry collector \u7684 OTLP \u57FA\u7840\u5730\u5740\uFF08\u5982 http://localhost:4318\uFF09\uFF0Ctrace / metrics / \u65E5\u5FD7\u4E09\u7C7B\u4FE1\u53F7\u90FD\u4EE5 OTLP http/protobuf \u53D1\u5230\u8FD9\u91CC\u3002http:// \u6216 https:// \u5747\u53EF\uFF0C\u7AEF\u53E3\u987B\u662F collector \u7684 OTLP/HTTP \u7AEF\u53E3\uFF08\u9ED8\u8BA4 4318\uFF09\uFF0C\u4E0D\u652F\u6301 gRPC\uFF084317\uFF09\u3002\u7559\u7A7A = \u5173\u95ED\u3002\u91CD\u542F\u670D\u52A1\u540E\u751F\u6548\uFF1Bcron / evolution \u4EFB\u52A1\u4E0B\u6B21\u8FD0\u884C\u5373\u7528\u65B0\u503C\u3002", default: "" },
|
|
858
|
+
{ key: "observability.service_name", type: "string", globalOnly: true, restartRequired: true, description: "OTel resource service.name reported for this server. Takes effect on server restart; cron / evolution runs use the new value on their next run.", description_zh: "\u672C\u670D\u52A1\u5728 OTel \u4E2D\u4E0A\u62A5\u7684 service.name\u3002\u91CD\u542F\u670D\u52A1\u540E\u751F\u6548\uFF1Bcron / evolution \u4EFB\u52A1\u4E0B\u6B21\u8FD0\u884C\u5373\u7528\u65B0\u503C\u3002", default: "halo" },
|
|
859
|
+
{ key: "observability.headers", type: "string", secret: true, globalOnly: true, restartRequired: true, description: "Extra headers for every OTLP request, comma-separated k=v (e.g. authorization=Bearer \u2026). Takes effect on server restart; cron / evolution runs use the new value on their next run.", description_zh: "\u9644\u52A0\u5230\u6BCF\u4E2A OTLP \u8BF7\u6C42\u7684 header\uFF0C\u9017\u53F7\u5206\u9694\u7684 k=v\uFF08\u5982 authorization=Bearer \u2026\uFF09\u3002\u91CD\u542F\u670D\u52A1\u540E\u751F\u6548\uFF1Bcron / evolution \u4EFB\u52A1\u4E0B\u6B21\u8FD0\u884C\u5373\u7528\u65B0\u503C\u3002", default: "" },
|
|
860
|
+
{ key: "observability.capture_content", type: "boolean", globalOnly: true, restartRequired: true, description: "Put prompt / completion / tool argument and result text on spans (gen_ai.*.messages etc.). Off = only metadata (model, tokens, latency, tool names). Env OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT (true / false) overrides. Takes effect on server restart; cron / evolution runs use the new value on their next run.", description_zh: "\u662F\u5426\u628A\u63D0\u793A\u8BCD / \u6A21\u578B\u8F93\u51FA / \u5DE5\u5177\u53C2\u6570\u4E0E\u7ED3\u679C\u6B63\u6587\u5199\u5230 span \u4E0A\uFF08gen_ai.*.messages \u7B49\uFF09\u3002\u5173 = \u53EA\u4E0A\u62A5\u5143\u6570\u636E\uFF08\u6A21\u578B\u3001token\u3001\u8017\u65F6\u3001\u5DE5\u5177\u540D\uFF09\u3002\u73AF\u5883\u53D8\u91CF OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT\uFF08true / false\uFF09\u4F18\u5148\u3002\u91CD\u542F\u670D\u52A1\u540E\u751F\u6548\uFF1Bcron / evolution \u4EFB\u52A1\u4E0B\u6B21\u8FD0\u884C\u5373\u7528\u65B0\u503C\u3002", default: "false" },
|
|
861
|
+
// Notebook kernels (jupyter/manager.ts). Global-only: kernels run
|
|
862
|
+
// unsandboxed as the service user, like the terminal. Read per spawn.
|
|
863
|
+
{ key: "jupyter.enabled", type: "boolean", globalOnly: true, description: "Allow notebook execution: the ipynb viewer can start kernels through a local Jupyter server (runs as the Halo service user, unsandboxed, like the terminal).", description_zh: "\u5141\u8BB8\u8FD0\u884C notebook\uFF1Aipynb \u67E5\u770B\u5668\u53EF\u901A\u8FC7\u672C\u673A Jupyter server \u542F\u52A8\u5185\u6838\uFF08\u4EE5 Halo \u670D\u52A1\u7528\u6237\u8EAB\u4EFD\u8FD0\u884C\u3001\u4E0D\u8FDB\u6C99\u7BB1\uFF0C\u4E0E\u7EC8\u7AEF\u76F8\u540C\uFF09\u3002", default: "true" },
|
|
864
|
+
{ key: "jupyter.path", type: "string", globalOnly: true, description: "Full path to the `jupyter` executable (e.g. /home/me/.venvs/jupyter/bin/jupyter). Empty = find `jupyter` on PATH. Needs jupyter_server + ipykernel (pip install jupyter_server ipykernel). Applies the next time the Jupyter server starts.", description_zh: "`jupyter` \u53EF\u6267\u884C\u6587\u4EF6\u7684\u5B8C\u6574\u8DEF\u5F84\uFF08\u5982 /home/me/.venvs/jupyter/bin/jupyter\uFF09\u3002\u7559\u7A7A = \u5728 PATH \u4E0A\u67E5\u627E `jupyter`\u3002\u9700\u8981 jupyter_server + ipykernel\uFF08pip install jupyter_server ipykernel\uFF09\u3002\u4E0B\u6B21\u542F\u52A8 Jupyter server \u65F6\u751F\u6548\u3002", default: "" },
|
|
865
|
+
// A2A (plans/a2a.md). Global-only: the URL allowlist is an egress boundary
|
|
866
|
+
// a workspace must not widen; public_url names this server to peers.
|
|
867
|
+
{ key: "a2a.public_url", type: "string", globalOnly: true, description: "Origin peers use to reach this server for A2A (e.g. http://myhost.tailnet.ts.net:9527): the agent card's interface URL and the webhook URL given to remotes. Empty = derived from the request Host. Env HALO_A2A_PUBLIC_URL overrides.", description_zh: "\u5BF9\u7AEF\u8BBF\u95EE\u672C\u670D\u52A1\u7684 A2A \u6E90\u5730\u5740\uFF08\u5982 http://myhost.tailnet.ts.net:9527\uFF09\uFF1A\u7528\u4E8E agent card \u7684\u63A5\u53E3 URL \u548C\u4EA4\u7ED9\u8FDC\u7AEF\u7684 webhook URL\u3002\u7559\u7A7A\u5219\u6309\u8BF7\u6C42 Host \u63A8\u5BFC\u3002\u73AF\u5883\u53D8\u91CF HALO_A2A_PUBLIC_URL \u4F18\u5148\u3002", default: "" },
|
|
868
|
+
{ key: "a2a.url_allowlist", type: "string", globalOnly: true, description: "Comma-separated CIDRs / host patterns A2A may reach besides public https (push webhooks, remote cards and RPC). Private / loopback ranges are refused unless listed; plain http is allowed only to listed hosts / ranges. Link-local (169.254.0.0/16, fe80::/10) is always refused.", description_zh: "A2A \u9664\u516C\u7F51 https \u4E4B\u5916\u5141\u8BB8\u8BBF\u95EE\u7684 CIDR / \u4E3B\u673A\u6A21\u5F0F\uFF08\u9017\u53F7\u5206\u9694\uFF0C\u7528\u4E8E push webhook\u3001\u8FDC\u7AEF card \u548C RPC\uFF09\u3002\u79C1\u7F51 / \u56DE\u73AF\u5730\u5740\u5FC5\u987B\u5217\u51FA\u624D\u5141\u8BB8\uFF1B\u660E\u6587 http \u53EA\u5141\u8BB8\u5217\u51FA\u7684\u4E3B\u673A / \u7F51\u6BB5\u3002\u94FE\u8DEF\u672C\u5730\u5730\u5740\uFF08169.254.0.0/16\u3001fe80::/10\uFF09\u59CB\u7EC8\u62D2\u7EDD\u3002", default: DEFAULT_A2A_URL_ALLOWLIST },
|
|
869
|
+
// self-evolution (see plans/self-evolution.md). All evo settings are
|
|
870
|
+
// global-only — the worker / ticker live in the server process and
|
|
871
|
+
// would have to reload mid-flight if a workspace could override them.
|
|
872
|
+
{ key: "evolution.level", type: "enum", options: ["L0", "L1"], globalOnly: true, description: "Self-evolution capability ladder. L0: manual only \u2014 drafts via /evo, manual approve/reject. L1: L0 plus automatic drafting on pre-compact. (Queued runs always execute regardless of level; the level only gates how they get enqueued.)", description_zh: "\u81EA\u6211\u8FDB\u5316\u7B49\u7EA7\u3002L0\uFF1A\u4EC5\u624B\u52A8\u2014\u2014\u901A\u8FC7 /evo \u8D77\u8349\uFF0C\u624B\u52A8 approve/reject\u3002L1\uFF1A\u5728 L0 \u57FA\u7840\u4E0A\uFF0C\u538B\u7F29\u524D\uFF08pre-compact\uFF09\u81EA\u52A8\u8D77\u8349\u3002\uFF08\u5DF2\u6392\u961F\u7684 run \u59CB\u7EC8\u6267\u884C\uFF0Clevel \u53EA\u51B3\u5B9A\u5982\u4F55\u5165\u961F\u3002\uFF09", default: "L0" },
|
|
873
|
+
{ key: "evolution.max_concurrent_run", type: "int", globalOnly: true, description: "Max concurrent evo run wrappers (drafting patches).", description_zh: "\u540C\u65F6\u8FD0\u884C\u7684 evo run wrapper \u4E0A\u9650\uFF08\u8D77\u8349 patch\uFF09\u3002", default: "1" },
|
|
874
|
+
{ key: "evolution.max_concurrent_apply", type: "int", globalOnly: true, description: "Max concurrent evo apply wrappers (merging patches).", description_zh: "\u540C\u65F6\u8FD0\u884C\u7684 evo apply wrapper \u4E0A\u9650\uFF08\u5408\u5E76 patch\uFF09\u3002", default: "1" },
|
|
875
|
+
{ key: "evolution.run_timeout_minutes", type: "int", globalOnly: true, description: "Heartbeat timeout for run wrappers. The wrapper writes a heartbeat every 60s for as long as it lives, so exceeding this means the wrapper process died \u2014 the row is requeued (or marked timeout past max_attempts). This is dead-wrapper detection latency, NOT a budget for slow models; the per-phase model budget is the wrapper-internal cap (draft 60min, other phases 45min).", description_zh: "run wrapper \u5FC3\u8DF3\u8D85\u65F6\uFF08\u5206\u949F\uFF09\u3002wrapper \u5B58\u6D3B\u671F\u95F4\u6BCF 60 \u79D2\u5199\u4E00\u6B21\u5FC3\u8DF3\uFF0C\u8D85\u65F6\u5373\u89C6\u4E3A wrapper \u8FDB\u7A0B\u5DF2\u6B7B\uFF0C\u4EFB\u52A1\u91CD\u6392\uFF08\u8D85\u8FC7 max_attempts \u5219\u6807 timeout\uFF09\u3002\u8FD9\u662F\u6B7B wrapper \u7684\u68C0\u6D4B\u5EF6\u8FDF\uFF0C\u4E0D\u662F\u6162\u6A21\u578B\u7684\u8FD0\u884C\u9884\u7B97\u2014\u2014\u5355\u9636\u6BB5\u6A21\u578B\u9884\u7B97\u662F wrapper \u5185\u90E8\u7684\u4E0A\u9650\uFF08\u8D77\u8349 60 \u5206\u949F\uFF0C\u5176\u4F59\u9636\u6BB5 45 \u5206\u949F\uFF09\u3002", default: "12" },
|
|
876
|
+
{ key: "evolution.apply_timeout_minutes", type: "int", globalOnly: true, description: "Heartbeat timeout for apply wrappers.", description_zh: "apply wrapper \u5FC3\u8DF3\u8D85\u65F6\uFF08\u5206\u949F\uFF09\u3002", default: "5" },
|
|
877
|
+
{ key: "evolution.max_attempts", type: "int", globalOnly: true, description: "How many times the ticker may try to spawn a wrapper for a single row before giving up. Stops infinite retry loops on permanently broken tasks.", description_zh: "ticker \u4E3A\u5355\u6761\u4EFB\u52A1\u5C1D\u8BD5\u62C9\u8D77 wrapper \u7684\u6700\u5927\u6B21\u6570\uFF0C\u8D85\u8FC7\u540E\u653E\u5F03\u3002\u907F\u514D\u574F\u4EFB\u52A1\u65E0\u9650\u91CD\u8BD5\u3002", default: "3" },
|
|
878
|
+
{ key: "evolution.triggers.pre_compact", type: "boolean", globalOnly: true, description: "Snapshot the session right before compaction so evo can analyze it. /evo is always available regardless of this setting.", description_zh: "\u5728 compact \u524D\u5FEB\u7167\u4F1A\u8BDD\u4F9B evo \u5206\u6790\u3002/evo \u603B\u662F\u53EF\u7528\uFF0C\u4E0E\u6B64\u5F00\u5173\u65E0\u5173\u3002", default: "true" },
|
|
879
|
+
// limits — runtime caps that protect against runaway tool output / UI
|
|
880
|
+
// freezes. All read live from settings.yaml so changes take effect on
|
|
881
|
+
// the next tool call without a server restart.
|
|
882
|
+
{ key: "limits.shell_output_bytes", type: "int", globalOnly: true, description: 'Max bytes captured from a single shell_exec call (stdout+stderr combined). Output past this is truncated; the agent sees a "[truncated]" marker.', description_zh: "\u5355\u6B21 shell_exec \u6700\u591A\u4FDD\u7559\u7684\u8F93\u51FA\u5B57\u8282\u6570\uFF08stdout+stderr \u5408\u8BA1\uFF09\u3002\u8D85\u51FA\u90E8\u5206\u622A\u65AD\uFF0Cagent \u4F1A\u770B\u5230 [truncated] \u6807\u8BB0\u3002", default: String(5 * 1024 * 1024) },
|
|
883
|
+
{ key: "limits.web_fetch_bytes", type: "int", globalOnly: true, description: "Max bytes downloaded by a single web_fetch call.", description_zh: "\u5355\u6B21 web_fetch \u6700\u591A\u4E0B\u8F7D\u7684\u5B57\u8282\u6570\u3002", default: String(50 * 1024) },
|
|
884
|
+
{ key: "limits.grep_default_matches", type: "int", globalOnly: true, description: "Default match cap for the grep tool when the call site does not pass an explicit `max`.", description_zh: "grep \u5DE5\u5177\u672A\u663E\u5F0F\u4F20 `max` \u53C2\u6570\u65F6\u7684\u9ED8\u8BA4\u5339\u914D\u4E0A\u9650\u3002", default: "50" },
|
|
885
|
+
{ key: "limits.tool_result_render_chars", type: "int", globalOnly: true, description: 'Per-tool-result cap on the content fed back to the LLM. Larger results are truncated (with a "[Content truncated]" marker telling the model to re-run with narrower scope) to protect the context window and prompt cache. The UI/admin chat panel shows a far larger slice \u2014 see tool_result_ui_chars.', description_zh: "\u6BCF\u6761 tool result \u56DE\u4F20\u7ED9 LLM \u7684\u5185\u5BB9\u957F\u5EA6\u4E0A\u9650\u3002\u8D85\u51FA\u90E8\u5206\u4F1A\u88AB\u622A\u65AD\uFF08\u5E76\u9644 [Content truncated] \u6807\u8BB0\u63D0\u793A\u6A21\u578B\u7F29\u5C0F\u8303\u56F4\u91CD\u67E5\uFF09\uFF0C\u4EE5\u4FDD\u62A4\u4E0A\u4E0B\u6587\u7A97\u53E3\u548C\u63D0\u793A\u8BCD\u7F13\u5B58\u3002UI/admin \u804A\u5929\u9762\u677F\u5C55\u793A\u7684\u5185\u5BB9\u4E0A\u9650\u8981\u5927\u5F97\u591A\uFF0C\u89C1 tool_result_ui_chars\u3002", default: "8000" },
|
|
886
|
+
{ key: "limits.tool_result_ui_chars", type: "int", globalOnly: true, description: "Per-tool-result cap on the content stored for UI display (admin/web chat panel). Far larger than the LLM cap so the full output of a normal command stays visible, but bounded so a multi-MB `cat` does not bloat the session file, the WS payload, or the browser render. Output past this is truncated with a marker pointing at file_read for the complete content.", description_zh: "\u6BCF\u6761 tool result \u5B58\u7ED9 UI \u5C55\u793A\uFF08admin/web \u804A\u5929\u9762\u677F\uFF09\u7684\u5185\u5BB9\u957F\u5EA6\u4E0A\u9650\u3002\u8FDC\u5927\u4E8E LLM \u4E0A\u9650\uFF0C\u4FDD\u8BC1\u666E\u901A\u547D\u4EE4\u7684\u5B8C\u6574\u8F93\u51FA\u90FD\u80FD\u770B\u5230\uFF1B\u4F46\u4ECD\u6709\u754C\uFF0C\u907F\u514D cat \u4E2A\u51E0 MB \u7684\u6587\u4EF6\u6491\u7206\u4F1A\u8BDD\u6587\u4EF6\u3001WS \u4F20\u8F93\u548C\u6D4F\u89C8\u5668\u6E32\u67D3\u3002\u8D85\u51FA\u90E8\u5206\u622A\u65AD\u5E76\u9644\u6807\u8BB0\uFF0C\u63D0\u793A\u7528 file_read \u770B\u5B8C\u6574\u5185\u5BB9\u3002", default: String(64 * 1024) },
|
|
887
|
+
{ key: "limits.auto_report_chars", type: "int", globalOnly: true, description: "Cap on the auto-report a finished sub-agent delivers to its parent. Longer reports are truncated with a pointer to get_session_output for the full text.", description_zh: "\u5B50\u4F1A\u8BDD\u5B8C\u6210\u540E\u81EA\u52A8\u4E0A\u62A5\u7ED9\u7236\u4F1A\u8BDD\u7684\u5185\u5BB9\u957F\u5EA6\u4E0A\u9650\u3002\u8D85\u51FA\u622A\u65AD\uFF0C\u5E76\u63D0\u793A\u7528 get_session_output \u53D6\u5168\u6587\u3002", default: "8192" },
|
|
888
|
+
{ key: "limits.terminal_scrollback_bytes", type: "int", globalOnly: true, description: "Off-screen scrollback bytes retained per persistent terminal while detached.", description_zh: "\u7EC8\u7AEF\u65AD\u5F00\u671F\u95F4\u4FDD\u7559\u7684\u56DE\u6EDA\u7F13\u51B2\u5B57\u8282\u6570\u3002", default: "50000" }
|
|
889
|
+
].map((f) => ({
|
|
890
|
+
...f,
|
|
891
|
+
kind: "param",
|
|
892
|
+
// Only language + theme stay in the open; everything else is folded
|
|
893
|
+
// under the UI's collapsed "Advanced" area.
|
|
894
|
+
advanced: f.key !== "language" && f.key !== "theme"
|
|
895
|
+
}))
|
|
896
|
+
};
|
|
897
|
+
}
|
|
898
|
+
var _yamlCache = createMtimeCache();
|
|
899
|
+
var _skillNameCache = createMtimeCache();
|
|
900
|
+
function readYamlFile(p) {
|
|
901
|
+
try {
|
|
902
|
+
const { mtimeMs } = fs7.statSync(p);
|
|
903
|
+
const hit = _yamlCache.get(p, mtimeMs);
|
|
904
|
+
if (hit !== void 0)
|
|
905
|
+
return hit;
|
|
906
|
+
const parsed = YAML4.parse(fs7.readFileSync(p, "utf-8"));
|
|
907
|
+
return _yamlCache.set(p, mtimeMs, parsed && typeof parsed === "object" ? parsed : null);
|
|
908
|
+
} catch {
|
|
909
|
+
return null;
|
|
910
|
+
}
|
|
911
|
+
}
|
|
912
|
+
var VALID_TYPES = /* @__PURE__ */ new Set(["string", "int", "float", "boolean", "enum"]);
|
|
913
|
+
function normalizeFieldList(raw, kind) {
|
|
914
|
+
if (!Array.isArray(raw))
|
|
915
|
+
return [];
|
|
916
|
+
const out = [];
|
|
917
|
+
for (const item of raw) {
|
|
918
|
+
if (!item || typeof item !== "object")
|
|
919
|
+
continue;
|
|
920
|
+
const r = item;
|
|
921
|
+
if (typeof r.key !== "string" || !r.key)
|
|
922
|
+
continue;
|
|
923
|
+
const type = typeof r.type === "string" && VALID_TYPES.has(r.type) ? r.type : void 0;
|
|
924
|
+
const options = Array.isArray(r.options) ? r.options.filter((v) => typeof v === "string") : void 0;
|
|
925
|
+
const optionLabels = Array.isArray(r.optionLabels) && options && r.optionLabels.length === options.length ? r.optionLabels.filter((v) => typeof v === "string") : void 0;
|
|
926
|
+
const defaultRaw = r.default;
|
|
927
|
+
const defaultStr = defaultRaw == null ? void 0 : typeof defaultRaw === "string" ? defaultRaw : String(defaultRaw);
|
|
928
|
+
out.push({
|
|
929
|
+
key: r.key,
|
|
930
|
+
kind,
|
|
931
|
+
type,
|
|
932
|
+
options,
|
|
933
|
+
optionLabels,
|
|
934
|
+
description: typeof r.description === "string" ? r.description : void 0,
|
|
935
|
+
description_zh: typeof r.description_zh === "string" ? r.description_zh : void 0,
|
|
936
|
+
default: defaultStr,
|
|
937
|
+
secret: r.secret === true
|
|
938
|
+
});
|
|
939
|
+
}
|
|
940
|
+
return out;
|
|
941
|
+
}
|
|
942
|
+
function providerSections() {
|
|
943
|
+
const sections = [];
|
|
944
|
+
for (const { id, data: parsed } of loadProviders().effective) {
|
|
945
|
+
const fields = normalizeFieldList(parsed.secrets, "secret");
|
|
946
|
+
if (fields.length === 0)
|
|
947
|
+
continue;
|
|
948
|
+
sections.push({
|
|
949
|
+
namespaceId: id,
|
|
950
|
+
source: "provider",
|
|
951
|
+
displayName: typeof parsed.displayName === "string" ? parsed.displayName : id,
|
|
952
|
+
displayName_zh: typeof parsed.displayName_zh === "string" ? parsed.displayName_zh : void 0,
|
|
953
|
+
description: typeof parsed.description === "string" ? parsed.description : void 0,
|
|
954
|
+
description_zh: typeof parsed.description_zh === "string" ? parsed.description_zh : void 0,
|
|
955
|
+
fields
|
|
956
|
+
});
|
|
957
|
+
}
|
|
958
|
+
return sections;
|
|
959
|
+
}
|
|
960
|
+
function buildSkillSection(skillsDir, skillId) {
|
|
961
|
+
const configPath = path10.join(skillsDir, skillId, "config.yaml");
|
|
962
|
+
if (!fs7.existsSync(configPath))
|
|
963
|
+
return null;
|
|
964
|
+
const parsed = readYamlFile(configPath);
|
|
965
|
+
if (!parsed)
|
|
966
|
+
return null;
|
|
967
|
+
const params = normalizeFieldList(parsed.params, "param");
|
|
968
|
+
const secrets = normalizeFieldList(parsed.secrets, "secret");
|
|
969
|
+
if (params.length === 0 && secrets.length === 0)
|
|
970
|
+
return null;
|
|
971
|
+
let displayName = skillId;
|
|
972
|
+
try {
|
|
973
|
+
const mdPath = path10.join(skillsDir, skillId, "SKILL.md");
|
|
974
|
+
const { mtimeMs } = fs7.statSync(mdPath);
|
|
975
|
+
let name = _skillNameCache.get(mdPath, mtimeMs);
|
|
976
|
+
if (name === void 0) {
|
|
977
|
+
const m = fs7.readFileSync(mdPath, "utf-8").match(/^---\s*\n([\s\S]*?)\n---/);
|
|
978
|
+
const nameMatch = m?.[1].match(/^name:\s*(.+)$/m);
|
|
979
|
+
name = _skillNameCache.set(mdPath, mtimeMs, nameMatch ? nameMatch[1].trim() : null);
|
|
980
|
+
}
|
|
981
|
+
if (name)
|
|
982
|
+
displayName = name;
|
|
983
|
+
} catch {
|
|
984
|
+
}
|
|
985
|
+
return {
|
|
986
|
+
namespaceId: skillId,
|
|
987
|
+
source: "skill",
|
|
988
|
+
displayName,
|
|
989
|
+
displayName_zh: typeof parsed.displayName_zh === "string" ? parsed.displayName_zh : void 0,
|
|
990
|
+
description: typeof parsed.description === "string" ? parsed.description : void 0,
|
|
991
|
+
description_zh: typeof parsed.description_zh === "string" ? parsed.description_zh : void 0,
|
|
992
|
+
fields: [...params, ...secrets]
|
|
993
|
+
};
|
|
994
|
+
}
|
|
995
|
+
function skillSections(workspaceRoot) {
|
|
996
|
+
const byId = /* @__PURE__ */ new Map();
|
|
997
|
+
for (const { id, dir } of listSkillDirs(workspaceRoot).reverse()) {
|
|
998
|
+
const section = buildSkillSection(path10.dirname(dir), id);
|
|
999
|
+
if (section)
|
|
1000
|
+
byId.set(section.namespaceId, section);
|
|
1001
|
+
}
|
|
1002
|
+
return [...byId.values()];
|
|
1003
|
+
}
|
|
1004
|
+
function agentSections() {
|
|
1005
|
+
const sections = [];
|
|
1006
|
+
let entries = [];
|
|
1007
|
+
try {
|
|
1008
|
+
entries = fs7.readdirSync(GLOBAL_AGENTS_DIR, { withFileTypes: true });
|
|
1009
|
+
} catch {
|
|
1010
|
+
return sections;
|
|
1011
|
+
}
|
|
1012
|
+
for (const entry of entries) {
|
|
1013
|
+
if (!entry.isDirectory())
|
|
1014
|
+
continue;
|
|
1015
|
+
const agentId = entry.name;
|
|
1016
|
+
const configPath = path10.join(GLOBAL_AGENTS_DIR, agentId, "agent-config.yaml");
|
|
1017
|
+
if (!fs7.existsSync(configPath))
|
|
1018
|
+
continue;
|
|
1019
|
+
const parsed = readYamlFile(configPath);
|
|
1020
|
+
if (!parsed)
|
|
1021
|
+
continue;
|
|
1022
|
+
const params = normalizeFieldList(parsed.params, "param");
|
|
1023
|
+
const secrets = normalizeFieldList(parsed.secrets, "secret");
|
|
1024
|
+
if (params.length === 0 && secrets.length === 0)
|
|
1025
|
+
continue;
|
|
1026
|
+
let displayName = agentId;
|
|
1027
|
+
try {
|
|
1028
|
+
const ay = readYamlFile(path10.join(GLOBAL_AGENTS_DIR, agentId, "agent.yaml"));
|
|
1029
|
+
if (ay && typeof ay.name === "string")
|
|
1030
|
+
displayName = ay.name;
|
|
1031
|
+
} catch {
|
|
1032
|
+
}
|
|
1033
|
+
sections.push({
|
|
1034
|
+
namespaceId: agentId,
|
|
1035
|
+
source: "agent",
|
|
1036
|
+
displayName,
|
|
1037
|
+
displayName_zh: typeof parsed.displayName_zh === "string" ? parsed.displayName_zh : void 0,
|
|
1038
|
+
description: typeof parsed.description === "string" ? parsed.description : void 0,
|
|
1039
|
+
description_zh: typeof parsed.description_zh === "string" ? parsed.description_zh : void 0,
|
|
1040
|
+
fields: [...params, ...secrets]
|
|
1041
|
+
});
|
|
1042
|
+
}
|
|
1043
|
+
return sections;
|
|
1044
|
+
}
|
|
1045
|
+
function loadSettingsSchema(workspaceRoot) {
|
|
1046
|
+
return [generalSection(), ...a2aSections(), ...providerSections(), ...skillSections(workspaceRoot), ...agentSections(), ...extensionSections()];
|
|
1047
|
+
}
|
|
1048
|
+
function a2aSections() {
|
|
1049
|
+
const fields = Object.values(loadRemotes()).filter((r) => r.auth === "bearer").map((r) => ({
|
|
1050
|
+
key: r.name,
|
|
1051
|
+
kind: "secret",
|
|
1052
|
+
secret: true,
|
|
1053
|
+
description: `Bearer token for the A2A remote "${r.name}" (card: ${r.card}).`,
|
|
1054
|
+
description_zh: `A2A \u8FDC\u7A0B "${r.name}" \u7684 bearer token\uFF08card\uFF1A${r.card}\uFF09\u3002`
|
|
1055
|
+
}));
|
|
1056
|
+
if (fields.length === 0)
|
|
1057
|
+
return [];
|
|
1058
|
+
return [{
|
|
1059
|
+
namespaceId: "a2a",
|
|
1060
|
+
source: "general",
|
|
1061
|
+
displayName: "A2A remotes",
|
|
1062
|
+
displayName_zh: "A2A \u8FDC\u7A0B",
|
|
1063
|
+
description: "Tokens for the bearer remotes in ~/.halo/secrets/a2a-remotes.yaml. Prefer global: non-full sessions can read the workspace settings.yaml.",
|
|
1064
|
+
description_zh: "~/.halo/secrets/a2a-remotes.yaml \u4E2D bearer \u8FDC\u7A0B\u7684 token\u3002\u5EFA\u8BAE\u8BBE\u5728\u5168\u5C40\uFF1A\u975E full \u4F1A\u8BDD\u53EF\u4EE5\u8BFB\u53D6\u5DE5\u4F5C\u533A\u7684 settings.yaml\u3002",
|
|
1065
|
+
fields
|
|
1066
|
+
}];
|
|
1067
|
+
}
|
|
1068
|
+
function extensionSections() {
|
|
1069
|
+
const sections = [];
|
|
1070
|
+
for (const ext of getSnapshot().extensions) {
|
|
1071
|
+
if (!ext.settings)
|
|
1072
|
+
continue;
|
|
1073
|
+
const fields = [];
|
|
1074
|
+
for (const kind of ["param", "secret"]) {
|
|
1075
|
+
for (const f of ext.settings[`${kind}s`] ?? []) {
|
|
1076
|
+
fields.push({ ...f, kind, globalOnly: true, ...kind === "secret" ? { secret: true } : {} });
|
|
1077
|
+
}
|
|
1078
|
+
}
|
|
1079
|
+
sections.push({
|
|
1080
|
+
namespaceId: `ext-${ext.id}`,
|
|
1081
|
+
source: "extension",
|
|
1082
|
+
displayName: ext.name,
|
|
1083
|
+
...ext.description !== void 0 ? { description: ext.description } : {},
|
|
1084
|
+
fields
|
|
1085
|
+
});
|
|
1086
|
+
}
|
|
1087
|
+
return sections;
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
// ../server/dist/config.js
|
|
1091
|
+
var HALO_HOME2 = path11.join(homedir5(), ".halo");
|
|
1092
|
+
var HALO_GLOBAL_DIR = path11.join(HALO_HOME2, "global");
|
|
1093
|
+
var HALO_SECRETS_DIR = path11.join(HALO_HOME2, "secrets");
|
|
1094
|
+
function loadYamlFile(filePath) {
|
|
1095
|
+
try {
|
|
1096
|
+
const raw = fs8.readFileSync(filePath, "utf-8");
|
|
1097
|
+
return YAML5.parse(raw) ?? {};
|
|
1098
|
+
} catch {
|
|
1099
|
+
return {};
|
|
1100
|
+
}
|
|
1101
|
+
}
|
|
1102
|
+
var SYSTEM_CONFIG_PATH = path11.join(HALO_SECRETS_DIR, "config.yaml");
|
|
1103
|
+
var _systemConfigCache = null;
|
|
1104
|
+
function getSystemConfig() {
|
|
1105
|
+
let mtimeMs = 0;
|
|
1106
|
+
try {
|
|
1107
|
+
mtimeMs = fs8.statSync(SYSTEM_CONFIG_PATH).mtimeMs;
|
|
1108
|
+
} catch {
|
|
1109
|
+
}
|
|
1110
|
+
if (!_systemConfigCache || _systemConfigCache.mtimeMs !== mtimeMs) {
|
|
1111
|
+
_systemConfigCache = { mtimeMs, data: loadYamlFile(SYSTEM_CONFIG_PATH) };
|
|
1112
|
+
}
|
|
1113
|
+
return _systemConfigCache.data;
|
|
1114
|
+
}
|
|
1115
|
+
var SETTINGS_PATH = path11.join(HALO_SECRETS_DIR, "settings.yaml");
|
|
1116
|
+
var _settingsCache = null;
|
|
1117
|
+
function getSettings() {
|
|
1118
|
+
let mtimeMs = 0;
|
|
1119
|
+
try {
|
|
1120
|
+
mtimeMs = fs8.statSync(SETTINGS_PATH).mtimeMs;
|
|
1121
|
+
} catch {
|
|
1122
|
+
}
|
|
1123
|
+
if (!_settingsCache || _settingsCache.mtimeMs !== mtimeMs) {
|
|
1124
|
+
_settingsCache = { mtimeMs, data: loadYamlFile(SETTINGS_PATH) };
|
|
1125
|
+
}
|
|
1126
|
+
return _settingsCache.data;
|
|
1127
|
+
}
|
|
1128
|
+
function readYamlValue(tree, dotPath) {
|
|
1129
|
+
const parts = dotPath.split(".");
|
|
1130
|
+
let cur = tree;
|
|
1131
|
+
for (const p of parts) {
|
|
1132
|
+
if (cur == null || typeof cur !== "object")
|
|
1133
|
+
return void 0;
|
|
1134
|
+
cur = cur[p];
|
|
1135
|
+
}
|
|
1136
|
+
if (cur != null && typeof cur === "object" && !Array.isArray(cur) && "value" in cur) {
|
|
1137
|
+
const obj = cur;
|
|
1138
|
+
const v = obj.value;
|
|
1139
|
+
const isUnset = (x) => x == null || typeof x === "string" && x === "";
|
|
1140
|
+
if (!isUnset(v))
|
|
1141
|
+
return v;
|
|
1142
|
+
const d = obj.default;
|
|
1143
|
+
return isUnset(d) ? void 0 : d;
|
|
1144
|
+
}
|
|
1145
|
+
return cur;
|
|
1146
|
+
}
|
|
1147
|
+
function settingsValue(dotPath) {
|
|
1148
|
+
const v = readYamlValue(getSettings(), dotPath);
|
|
1149
|
+
return v == null ? void 0 : typeof v === "string" ? v : String(v);
|
|
1150
|
+
}
|
|
1151
|
+
function explicitCompressAt() {
|
|
1152
|
+
const raw = settingsValue("general.compact.compress_at");
|
|
1153
|
+
if (raw === void 0)
|
|
1154
|
+
return void 0;
|
|
1155
|
+
const n = parseFloat(raw);
|
|
1156
|
+
return Number.isFinite(n) && n > 0 && n <= 1 ? n : void 0;
|
|
1157
|
+
}
|
|
1158
|
+
function configValue(dotPath) {
|
|
1159
|
+
return readYamlValue(getSystemConfig(), dotPath);
|
|
1160
|
+
}
|
|
1161
|
+
function envInt(key, fallback) {
|
|
1162
|
+
const v = process.env[key];
|
|
1163
|
+
if (v === void 0)
|
|
1164
|
+
return fallback;
|
|
1165
|
+
const n = parseInt(v, 10);
|
|
1166
|
+
return isNaN(n) ? fallback : n;
|
|
1167
|
+
}
|
|
1168
|
+
var _schemaDefaultsCache = null;
|
|
1169
|
+
function loadSchemaDefaults() {
|
|
1170
|
+
if (_schemaDefaultsCache)
|
|
1171
|
+
return _schemaDefaultsCache;
|
|
1172
|
+
const map = /* @__PURE__ */ new Map();
|
|
1173
|
+
for (const section of loadSettingsSchema()) {
|
|
1174
|
+
if (section.namespaceId !== "general")
|
|
1175
|
+
continue;
|
|
1176
|
+
for (const f of section.fields) {
|
|
1177
|
+
if (f.default !== void 0)
|
|
1178
|
+
map.set(`general.${f.key}`, f.default);
|
|
1179
|
+
}
|
|
1180
|
+
}
|
|
1181
|
+
_schemaDefaultsCache = map;
|
|
1182
|
+
return map;
|
|
1183
|
+
}
|
|
1184
|
+
function schemaDefault(settingsPath, callsiteFallback) {
|
|
1185
|
+
const fromSchema = loadSchemaDefaults().get(settingsPath);
|
|
1186
|
+
if (fromSchema === void 0)
|
|
1187
|
+
return callsiteFallback;
|
|
1188
|
+
if (fromSchema !== callsiteFallback) {
|
|
1189
|
+
console.warn(`[Config] default mismatch for "${settingsPath}": schema=${JSON.stringify(fromSchema)} callsite=${JSON.stringify(callsiteFallback)}. Using schema value.`);
|
|
1190
|
+
}
|
|
1191
|
+
return fromSchema;
|
|
1192
|
+
}
|
|
1193
|
+
function settingsInt(settingsPath, fallback) {
|
|
1194
|
+
const sv = settingsValue(settingsPath);
|
|
1195
|
+
if (sv !== void 0) {
|
|
1196
|
+
const n2 = parseInt(sv, 10);
|
|
1197
|
+
if (!isNaN(n2))
|
|
1198
|
+
return n2;
|
|
1199
|
+
}
|
|
1200
|
+
const schemaDef = schemaDefault(settingsPath, String(fallback));
|
|
1201
|
+
const n = parseInt(schemaDef, 10);
|
|
1202
|
+
return isNaN(n) ? fallback : n;
|
|
1203
|
+
}
|
|
1204
|
+
function settingsStr(settingsPath, fallback) {
|
|
1205
|
+
const sv = settingsValue(settingsPath);
|
|
1206
|
+
if (sv !== void 0)
|
|
1207
|
+
return String(sv);
|
|
1208
|
+
return schemaDefault(settingsPath, fallback);
|
|
1209
|
+
}
|
|
1210
|
+
function settingsBool(settingsPath, fallback) {
|
|
1211
|
+
const raw = settingsStr(settingsPath, fallback ? "true" : "false").trim().toLowerCase();
|
|
1212
|
+
return raw === "true" || raw === "1";
|
|
1213
|
+
}
|
|
1214
|
+
function envBool(envKey) {
|
|
1215
|
+
const raw = process.env[envKey]?.trim().toLowerCase();
|
|
1216
|
+
return raw === "true" || raw === "1" ? true : raw === "false" || raw === "0" ? false : void 0;
|
|
1217
|
+
}
|
|
1218
|
+
function systemInt(envKey, configPath, fallback) {
|
|
1219
|
+
const envVal = process.env[envKey];
|
|
1220
|
+
if (envVal !== void 0) {
|
|
1221
|
+
const n = parseInt(envVal, 10);
|
|
1222
|
+
if (!isNaN(n))
|
|
1223
|
+
return n;
|
|
1224
|
+
}
|
|
1225
|
+
const cv = configValue(configPath);
|
|
1226
|
+
if (typeof cv === "number")
|
|
1227
|
+
return cv;
|
|
1228
|
+
if (typeof cv === "string") {
|
|
1229
|
+
const n = parseInt(cv, 10);
|
|
1230
|
+
if (!isNaN(n))
|
|
1231
|
+
return n;
|
|
1232
|
+
}
|
|
1233
|
+
return fallback;
|
|
1234
|
+
}
|
|
1235
|
+
function systemStringArray(envKey, configPath, fallback) {
|
|
1236
|
+
const envVal = process.env[envKey];
|
|
1237
|
+
if (envVal !== void 0 && envVal.trim()) {
|
|
1238
|
+
return envVal.split(",").map((s) => s.trim()).filter(Boolean);
|
|
1239
|
+
}
|
|
1240
|
+
const cv = configValue(configPath);
|
|
1241
|
+
if (Array.isArray(cv))
|
|
1242
|
+
return cv.map(String);
|
|
1243
|
+
return fallback;
|
|
1244
|
+
}
|
|
1245
|
+
function systemString(envKey, configPath, fallback) {
|
|
1246
|
+
const envVal = process.env[envKey];
|
|
1247
|
+
if (envVal !== void 0)
|
|
1248
|
+
return envVal;
|
|
1249
|
+
const cv = configValue(configPath);
|
|
1250
|
+
if (typeof cv === "string")
|
|
1251
|
+
return cv;
|
|
1252
|
+
return fallback;
|
|
1253
|
+
}
|
|
1254
|
+
var config = {
|
|
1255
|
+
// Goal entry points are retired, not the runtime. Internal/global-only, no UI:
|
|
1256
|
+
// set general.goal_mode_enabled in ~/.halo/secrets/settings.yaml, then restart.
|
|
1257
|
+
goalModeEnabled: settingsBool("general.goal_mode_enabled", false),
|
|
1258
|
+
server: {
|
|
1259
|
+
port: systemInt("HALO_PORT", "server.port", 9527),
|
|
1260
|
+
/** scrypt hash stored in `secrets/config.yaml`, set by `halo setup`.
|
|
1261
|
+
* `null` = not configured. `HALO_PASSWORD` env is *not* a hash —
|
|
1262
|
+
* it's a plaintext bypass; see `passwordEnvPlaintext` below and the
|
|
1263
|
+
* branch in `middleware/auth.ts`. */
|
|
1264
|
+
get password() {
|
|
1265
|
+
const cv = configValue("server.password");
|
|
1266
|
+
return typeof cv === "string" ? cv : null;
|
|
1267
|
+
},
|
|
1268
|
+
/** Plaintext password supplied via `HALO_PASSWORD` env. The auth
|
|
1269
|
+
* middleware compares this directly (no scrypt) since the env's
|
|
1270
|
+
* whatever-secret-store already protects the value. Lets ops scripts
|
|
1271
|
+
* set a password without round-tripping through scrypt hashing. */
|
|
1272
|
+
get passwordEnvPlaintext() {
|
|
1273
|
+
const v = process.env.HALO_PASSWORD;
|
|
1274
|
+
return v && v.length > 0 ? v : null;
|
|
1275
|
+
},
|
|
1276
|
+
// Random base64 JWT signing key, set by `halo setup`. Empty = not set up.
|
|
1277
|
+
jwtSecret: systemString("HALO_JWT_SECRET", "server.jwt_secret", null),
|
|
1278
|
+
// Empty allowlist means "reflect any origin" (see CORS setup in index.ts).
|
|
1279
|
+
corsOrigins: systemStringArray("HALO_CORS_ORIGINS", "server.cors_origins", []),
|
|
1280
|
+
/** Trust `x-forwarded-for` for client-IP resolution (brute-force
|
|
1281
|
+
* buckets). Off by default — the header is client-forgeable unless a
|
|
1282
|
+
* reverse proxy the operator controls rewrites it. */
|
|
1283
|
+
get trustProxy() {
|
|
1284
|
+
return settingsBool("general.server.trust_proxy", false);
|
|
1285
|
+
},
|
|
1286
|
+
/** Environment badge (`HALO_BADGE=DEV`) surfaced to the admin via
|
|
1287
|
+
* `GET /auth/check` so parallel dev/prod tabs are tellable apart — the
|
|
1288
|
+
* admin repaints the favicon with a badge band and prefixes the tab
|
|
1289
|
+
* title. dev/prod serve the same static build, so this must be a
|
|
1290
|
+
* runtime signal. Unset/empty → null → admin keeps stock branding. */
|
|
1291
|
+
get badge() {
|
|
1292
|
+
const v = process.env.HALO_BADGE?.trim();
|
|
1293
|
+
return v ? v : null;
|
|
1294
|
+
},
|
|
1295
|
+
/** false on a `HALO_BADGE=DEV` instance: dev and prod serve the same
|
|
1296
|
+
* workspaces, so prod alone claims `.halo/runtime.lock` and runs the
|
|
1297
|
+
* boot cleanup (orphan reconcile, goal / run-ledger sweeps). */
|
|
1298
|
+
get ownsWorkspaceRuntimes() {
|
|
1299
|
+
return process.env.HALO_BADGE?.trim().toUpperCase() !== "DEV";
|
|
1300
|
+
},
|
|
1301
|
+
/** `HALO_RUNTIME_MODE=agentcore-a2a` (`halo agentcore`) runs the server
|
|
1302
|
+
* as an Amazon Bedrock AgentCore Runtime container with the A2A
|
|
1303
|
+
* protocol: AgentCore verifies the caller's signed request upstream, so
|
|
1304
|
+
* password/JWT checks, channels, cron/evolution and the instance lock are
|
|
1305
|
+
* skipped, and /ping + A2A JSON-RPC are mounted at `/` on one fixed
|
|
1306
|
+
* workspace (see a2a/agentcore.ts). Unset / any other value → normal server. */
|
|
1307
|
+
runtimeMode: process.env.HALO_RUNTIME_MODE ?? null,
|
|
1308
|
+
/** Workspace for agentcore mode. Env `HALO_WORKSPACE`, fallback cwd. */
|
|
1309
|
+
agentcoreWorkspace: process.env.HALO_WORKSPACE ?? process.cwd()
|
|
1310
|
+
},
|
|
1311
|
+
// `model.compressAt` is intentionally a getter — it reads from
|
|
1312
|
+
// settings.yaml (`general.compact.compress_at`) on every access so a
|
|
1313
|
+
// user edit takes effect on the next session without a restart. The
|
|
1314
|
+
// value is a fraction of `maxContextTokens`; auto-compact triggers when
|
|
1315
|
+
// the running context exceeds `maxContextTokens * compressAt`.
|
|
1316
|
+
model: {
|
|
1317
|
+
maxContextTokens: envInt("HALO_MAX_CONTEXT_TOKENS", 272e3),
|
|
1318
|
+
get compressAt() {
|
|
1319
|
+
return explicitCompressAt() ?? 0.9;
|
|
1320
|
+
}
|
|
1321
|
+
},
|
|
1322
|
+
/** System language preference. `general.language` in settings.yaml stores
|
|
1323
|
+
* a BCP-47 region tag (`en-US` / `zh-CN`). The rest of the codebase still
|
|
1324
|
+
* uses the simple `'en' | 'zh'` shape — we collapse the region here so
|
|
1325
|
+
* callers don't have to think about it. Anything starting with `zh-` →
|
|
1326
|
+
* `'zh'`; everything else → `'en'`. */
|
|
1327
|
+
get language() {
|
|
1328
|
+
const raw = settingsStr("general.language", "en-US").toLowerCase();
|
|
1329
|
+
return raw.startsWith("zh") ? "zh" : "en";
|
|
1330
|
+
},
|
|
1331
|
+
agent: {
|
|
1332
|
+
defaultTools: [
|
|
1333
|
+
"file_read",
|
|
1334
|
+
"file_write",
|
|
1335
|
+
"file_edit",
|
|
1336
|
+
"file_list",
|
|
1337
|
+
"shell_exec",
|
|
1338
|
+
"grep",
|
|
1339
|
+
"glob",
|
|
1340
|
+
"web_fetch"
|
|
1341
|
+
],
|
|
1342
|
+
maxRetries: settingsInt("general.agent.max_retries", 5)
|
|
1343
|
+
},
|
|
1344
|
+
timeout: {
|
|
1345
|
+
shellExec: systemInt("HALO_SHELL_TIMEOUT", "timeout.shell_exec", 12e4),
|
|
1346
|
+
webFetch: systemInt("HALO_WEB_FETCH_TIMEOUT", "timeout.web_fetch", 1e4),
|
|
1347
|
+
// Per model-call cap. A bare fetch has no default timeout, so a half-open
|
|
1348
|
+
// connection (server accepted but never sends bytes / sends no RST) hangs
|
|
1349
|
+
// the await indefinitely. Bounds each callModel; on expiry the request
|
|
1350
|
+
// aborts and the agent loop's retry takes over. Streaming providers reset
|
|
1351
|
+
// the timer on any data the stream delivers (pings, tool-argument
|
|
1352
|
+
// fragments, SSE comments included), so for them it is an idle timeout — a
|
|
1353
|
+
// long but live stream is never cut off.
|
|
1354
|
+
modelRequest: systemInt("HALO_MODEL_TIMEOUT", "timeout.model_request", 10 * 6e4),
|
|
1355
|
+
sessionGrace: systemInt("HALO_SESSION_GRACE", "timeout.session_grace", 5 * 6e4),
|
|
1356
|
+
terminalGrace: systemInt("HALO_TERMINAL_GRACE", "timeout.terminal_grace", 5 * 6e4)
|
|
1357
|
+
},
|
|
1358
|
+
// Runtime caps — read live from settings.yaml on every access. Getters
|
|
1359
|
+
// (not literals) so a settings reload takes effect on the next tool
|
|
1360
|
+
// call without restarting the server. Schema lives in settings-schema
|
|
1361
|
+
// under the `limits.*` namespace.
|
|
1362
|
+
limits: {
|
|
1363
|
+
get shellOutputBuffer() {
|
|
1364
|
+
return settingsInt("general.limits.shell_output_bytes", 5 * 1024 * 1024);
|
|
1365
|
+
},
|
|
1366
|
+
get webFetchMaxBody() {
|
|
1367
|
+
return settingsInt("general.limits.web_fetch_bytes", 50 * 1024);
|
|
1368
|
+
},
|
|
1369
|
+
get grepDefaultMax() {
|
|
1370
|
+
return settingsInt("general.limits.grep_default_matches", 50);
|
|
1371
|
+
},
|
|
1372
|
+
get toolResultMax() {
|
|
1373
|
+
return settingsInt("general.limits.tool_result_render_chars", 8e3);
|
|
1374
|
+
},
|
|
1375
|
+
get toolResultUiMax() {
|
|
1376
|
+
return settingsInt("general.limits.tool_result_ui_chars", 64 * 1024);
|
|
1377
|
+
},
|
|
1378
|
+
get autoReportMax() {
|
|
1379
|
+
return settingsInt("general.limits.auto_report_chars", 8192);
|
|
1380
|
+
},
|
|
1381
|
+
get terminalOutputBuffer() {
|
|
1382
|
+
return settingsInt("general.limits.terminal_scrollback_bytes", 5e4);
|
|
1383
|
+
}
|
|
1384
|
+
},
|
|
1385
|
+
compact: {
|
|
1386
|
+
keep_messages: settingsInt("general.compact.keep_messages", 5),
|
|
1387
|
+
max_summary_input: settingsInt("general.compact.max_summary_input", 15e3),
|
|
1388
|
+
max_message_slice: settingsInt("general.compact.max_message_slice", 800)
|
|
1389
|
+
},
|
|
1390
|
+
auth: {
|
|
1391
|
+
tokenMaxAge: 14 * 24 * 60 * 60,
|
|
1392
|
+
refreshAfter: 24 * 60 * 60
|
|
1393
|
+
},
|
|
1394
|
+
session: {
|
|
1395
|
+
maxCachedSessions: envInt("HALO_MAX_CACHED_SESSIONS", 50),
|
|
1396
|
+
maxQueueSize: settingsInt("general.session.max_queue_size", 256),
|
|
1397
|
+
maxNestingDepth: settingsInt("general.session.max_nesting_depth", 16)
|
|
1398
|
+
},
|
|
1399
|
+
logging: {
|
|
1400
|
+
// `logging.level` lives under `general.logging.level` in settings.yaml so
|
|
1401
|
+
// it shows up alongside the other general knobs in the settings UI.
|
|
1402
|
+
// `max_file_size` / `max_files` stay in config.yaml — they're operator
|
|
1403
|
+
// dials, not user-tunable preferences.
|
|
1404
|
+
level: process.env.HALO_LOG_LEVEL ?? settingsStr("general.logging.level", "warn"),
|
|
1405
|
+
maxFileSize: systemInt("HALO_LOG_MAX_SIZE", "logging.max_file_size", 10 * 1024 * 1024),
|
|
1406
|
+
// 10MB
|
|
1407
|
+
maxFiles: systemInt("HALO_LOG_MAX_FILES", "logging.max_files", 5)
|
|
1408
|
+
},
|
|
1409
|
+
// OpenTelemetry export. Read once at boot (observability/otel.ts maps these
|
|
1410
|
+
// onto OTEL_* env vars) — changes take effect on restart.
|
|
1411
|
+
observability: {
|
|
1412
|
+
endpoint: settingsStr("general.observability.endpoint", ""),
|
|
1413
|
+
serviceName: settingsStr("general.observability.service_name", "halo"),
|
|
1414
|
+
headers: settingsStr("general.observability.headers", ""),
|
|
1415
|
+
// The OTel GenAI instrumentation's own switch, when exported, beats settings
|
|
1416
|
+
// (endpoint / headers / service_name get the same from otel.ts `??=`).
|
|
1417
|
+
captureContent: envBool("OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT") ?? settingsBool("general.observability.capture_content", false)
|
|
1418
|
+
},
|
|
1419
|
+
// Notebook kernels (jupyter/manager.ts). Getters: read at each spawn.
|
|
1420
|
+
jupyter: {
|
|
1421
|
+
get enabled() {
|
|
1422
|
+
return settingsBool("general.jupyter.enabled", true);
|
|
1423
|
+
},
|
|
1424
|
+
get path() {
|
|
1425
|
+
return settingsStr("general.jupyter.path", "").trim();
|
|
1426
|
+
}
|
|
1427
|
+
},
|
|
1428
|
+
// A2A (see plans/a2a.md). Getters: read live from settings.yaml.
|
|
1429
|
+
a2a: {
|
|
1430
|
+
/** Base dir workspaces are exposed relative to: env HALO_A2A_ROOT, else the user's home. */
|
|
1431
|
+
get root() {
|
|
1432
|
+
return process.env.HALO_A2A_ROOT?.trim() || homedir5();
|
|
1433
|
+
},
|
|
1434
|
+
/** Server origin as peers see it (no trailing slash); '' = derive from the request. */
|
|
1435
|
+
get publicUrl() {
|
|
1436
|
+
return (process.env.HALO_A2A_PUBLIC_URL?.trim() || settingsStr("general.a2a.public_url", "")).replace(/\/+$/, "");
|
|
1437
|
+
},
|
|
1438
|
+
get urlAllowlist() {
|
|
1439
|
+
return settingsStr("general.a2a.url_allowlist", DEFAULT_A2A_URL_ALLOWLIST);
|
|
1440
|
+
}
|
|
1441
|
+
},
|
|
1442
|
+
// Self-evolution (see plans/self-evolution.md). Getter-based so user edits
|
|
1443
|
+
// in settings.yaml take effect on the next ticker run without restart.
|
|
1444
|
+
evolution: {
|
|
1445
|
+
get level() {
|
|
1446
|
+
const raw = settingsStr("general.evolution.level", "L0");
|
|
1447
|
+
return raw === "L1" ? "L1" : "L0";
|
|
1448
|
+
},
|
|
1449
|
+
get maxConcurrentRun() {
|
|
1450
|
+
return settingsInt("general.evolution.max_concurrent_run", 1);
|
|
1451
|
+
},
|
|
1452
|
+
get maxConcurrentApply() {
|
|
1453
|
+
return settingsInt("general.evolution.max_concurrent_apply", 1);
|
|
1454
|
+
},
|
|
1455
|
+
// Heartbeat-loss threshold the ticker uses to decide a wrapper died and
|
|
1456
|
+
// requeue its run. It is not a phase limit: a healthy wrapper heart-beats
|
|
1457
|
+
// every 60s for its whole run, including a phase that burns its full cap
|
|
1458
|
+
// (DRAFT_TIMEOUT_SEC 3600s / PHASE_TIMEOUT_SEC 2700s in evo-wrapper.ts).
|
|
1459
|
+
// 12min tolerates many missed beats before a second wrapper could be
|
|
1460
|
+
// spawned for the same run.
|
|
1461
|
+
get runTimeoutMinutes() {
|
|
1462
|
+
return settingsInt("general.evolution.run_timeout_minutes", 12);
|
|
1463
|
+
},
|
|
1464
|
+
get applyTimeoutMinutes() {
|
|
1465
|
+
return settingsInt("general.evolution.apply_timeout_minutes", 5);
|
|
1466
|
+
},
|
|
1467
|
+
get maxAttempts() {
|
|
1468
|
+
return settingsInt("general.evolution.max_attempts", 3);
|
|
1469
|
+
},
|
|
1470
|
+
triggers: {
|
|
1471
|
+
get preCompact() {
|
|
1472
|
+
return settingsBool("general.evolution.triggers.pre_compact", true);
|
|
1473
|
+
}
|
|
1474
|
+
}
|
|
1475
|
+
}
|
|
1476
|
+
};
|
|
1477
|
+
|
|
1478
|
+
// ../server/dist/evolution/evo-wrapper.js
|
|
1479
|
+
var FIX_BUDGET = 1;
|
|
1480
|
+
var DRY_RUN_TIMEOUT_SEC = 2700;
|
|
1481
|
+
var PHASE_TIMEOUT_SEC = 2700;
|
|
1482
|
+
var DRAFT_TIMEOUT_SEC = 3600;
|
|
1483
|
+
var SANDBOX_WHITELIST = ["INSTRUCTIONS.md", "INDEX.md", "USER.md", "agents", "prompts", "skills", "docs"];
|
|
1484
|
+
var PUBLISH_WHITELIST = SANDBOX_WHITELIST.filter((e) => e !== "docs");
|
|
1485
|
+
function parseCli(argv) {
|
|
1486
|
+
const { values } = parseArgs({
|
|
1487
|
+
args: argv,
|
|
1488
|
+
options: {
|
|
1489
|
+
mode: { type: "string" },
|
|
1490
|
+
id: { type: "string" }
|
|
1491
|
+
}
|
|
1492
|
+
});
|
|
1493
|
+
if (values.mode !== "run" && values.mode !== "apply") {
|
|
1494
|
+
throw new Error(`--mode must be 'run' or 'apply', got ${JSON.stringify(values.mode)}`);
|
|
1495
|
+
}
|
|
1496
|
+
if (typeof values.id !== "string" || values.id.length === 0) {
|
|
1497
|
+
throw new Error("--id is required");
|
|
1498
|
+
}
|
|
1499
|
+
return { mode: values.mode, id: values.id };
|
|
1500
|
+
}
|
|
1501
|
+
function loadRunRow(id) {
|
|
1502
|
+
const db = getEvoDb();
|
|
1503
|
+
const row = db.select({
|
|
1504
|
+
id: evolutionRuns.id,
|
|
1505
|
+
workspacePath: evolutionRuns.workspacePath,
|
|
1506
|
+
triggerKind: evolutionRuns.triggerKind,
|
|
1507
|
+
sourceSession: evolutionRuns.sourceSession,
|
|
1508
|
+
userHint: evolutionRuns.userHint
|
|
1509
|
+
}).from(evolutionRuns).where(eq(evolutionRuns.id, id)).get();
|
|
1510
|
+
if (!row)
|
|
1511
|
+
throw new Error(`evolution_runs row not found: ${id}`);
|
|
1512
|
+
return row;
|
|
1513
|
+
}
|
|
1514
|
+
function resolveHaloCli() {
|
|
1515
|
+
if (process.env.HALO_CLI)
|
|
1516
|
+
return process.env.HALO_CLI;
|
|
1517
|
+
return process.platform === "win32" ? "halo.cmd" : "halo";
|
|
1518
|
+
}
|
|
1519
|
+
function spawnProc(bin, args, logFd, teePath, stdinInput, timeoutSec) {
|
|
1520
|
+
const loggedArgs = stdinInput === void 0 ? args.slice(0, -1) : args;
|
|
1521
|
+
writeLog(logFd, `[wrapper] $ ${bin} ${loggedArgs.join(" ")}${stdinInput === void 0 ? " <last-arg-omitted>" : " <prompt-on-stdin>"}${timeoutSec ? ` (timeout ${timeoutSec}s)` : ""}
|
|
1522
|
+
`);
|
|
1523
|
+
const tee = teePath ? fs9.createWriteStream(teePath, { flags: "a" }) : null;
|
|
1524
|
+
const [spawnBin, spawnArgs] = process.platform === "win32" && bin.endsWith(".cmd") ? ["cmd.exe", ["/c", bin, ...args]] : [bin, args];
|
|
1525
|
+
return new Promise((resolve) => {
|
|
1526
|
+
const child = spawn2(spawnBin, spawnArgs, {
|
|
1527
|
+
// Pipe stdin only when we have input to write — the cli reads a prompt
|
|
1528
|
+
// from stdin when it's not a TTY, which is how long briefs are delivered
|
|
1529
|
+
// (Windows CreateProcess caps the command line at ~32KB → ENAMETOOLONG
|
|
1530
|
+
// if a multi-KB brief rides as an argv element).
|
|
1531
|
+
stdio: [stdinInput === void 0 ? "ignore" : "pipe", "pipe", "pipe"],
|
|
1532
|
+
env: cleanChildEnv(),
|
|
1533
|
+
// Own process group (POSIX) so the timeout can signal the whole tree —
|
|
1534
|
+
// the cli plus every shell_exec grandchild — not just the direct PID.
|
|
1535
|
+
// No-op contract on Windows, where we kill by PID via taskkill /T below.
|
|
1536
|
+
detached: process.platform !== "win32",
|
|
1537
|
+
// Suppress the console window the `cmd.exe /c` wrapper would otherwise
|
|
1538
|
+
// pop up on Windows (CREATE_NO_WINDOW). No-op on macOS/Linux.
|
|
1539
|
+
windowsHide: true
|
|
1540
|
+
});
|
|
1541
|
+
if (stdinInput !== void 0 && child.stdin) {
|
|
1542
|
+
child.stdin.on("error", () => {
|
|
1543
|
+
});
|
|
1544
|
+
child.stdin.write(stdinInput);
|
|
1545
|
+
child.stdin.end();
|
|
1546
|
+
}
|
|
1547
|
+
let stdout = "";
|
|
1548
|
+
let stderr = "";
|
|
1549
|
+
let timedOut = false;
|
|
1550
|
+
let sigkillTimer = null;
|
|
1551
|
+
const killTree = (signal) => {
|
|
1552
|
+
if (!child.pid)
|
|
1553
|
+
return;
|
|
1554
|
+
try {
|
|
1555
|
+
if (process.platform === "win32") {
|
|
1556
|
+
const args2 = signal === "SIGKILL" ? ["/PID", String(child.pid), "/T", "/F"] : ["/PID", String(child.pid), "/T"];
|
|
1557
|
+
spawn2("taskkill", args2);
|
|
1558
|
+
} else {
|
|
1559
|
+
process.kill(-child.pid, signal);
|
|
1560
|
+
}
|
|
1561
|
+
} catch {
|
|
1562
|
+
}
|
|
1563
|
+
};
|
|
1564
|
+
const killTimer = timeoutSec ? setTimeout(() => {
|
|
1565
|
+
timedOut = true;
|
|
1566
|
+
writeLog(logFd, `[wrapper] timeout ${timeoutSec}s \u2014 killing process group (SIGTERM)
|
|
1567
|
+
`);
|
|
1568
|
+
killTree("SIGTERM");
|
|
1569
|
+
sigkillTimer = setTimeout(() => {
|
|
1570
|
+
writeLog(logFd, `[wrapper] still alive 10s after SIGTERM \u2014 SIGKILL
|
|
1571
|
+
`);
|
|
1572
|
+
killTree("SIGKILL");
|
|
1573
|
+
}, 1e4);
|
|
1574
|
+
sigkillTimer.unref?.();
|
|
1575
|
+
}, timeoutSec * 1e3) : null;
|
|
1576
|
+
child.stdout?.on("data", (chunk) => {
|
|
1577
|
+
stdout += chunk.toString("utf-8");
|
|
1578
|
+
tee?.write(chunk);
|
|
1579
|
+
});
|
|
1580
|
+
child.stderr?.on("data", (chunk) => {
|
|
1581
|
+
stderr += chunk.toString("utf-8");
|
|
1582
|
+
tee?.write(chunk);
|
|
1583
|
+
});
|
|
1584
|
+
child.on("exit", (code, signal) => {
|
|
1585
|
+
if (killTimer)
|
|
1586
|
+
clearTimeout(killTimer);
|
|
1587
|
+
if (sigkillTimer)
|
|
1588
|
+
clearTimeout(sigkillTimer);
|
|
1589
|
+
const exitCode = timedOut ? 124 : code ?? 1;
|
|
1590
|
+
writeLog(logFd, `[wrapper] proc exit code=${code} signal=${signal}${timedOut ? " (timed out \u2192 124)" : ""} stdout=${stdout.length}B stderr=${stderr.length}B
|
|
1591
|
+
`);
|
|
1592
|
+
tee?.end();
|
|
1593
|
+
resolve({ exitCode, stdout, stderr });
|
|
1594
|
+
});
|
|
1595
|
+
child.on("error", (err) => {
|
|
1596
|
+
if (killTimer)
|
|
1597
|
+
clearTimeout(killTimer);
|
|
1598
|
+
if (sigkillTimer)
|
|
1599
|
+
clearTimeout(sigkillTimer);
|
|
1600
|
+
writeLog(logFd, `[wrapper] spawn error: ${err.message}
|
|
1601
|
+
`);
|
|
1602
|
+
tee?.write(`
|
|
1603
|
+
[spawn error] ${err.message}
|
|
1604
|
+
`);
|
|
1605
|
+
tee?.end();
|
|
1606
|
+
resolve({ exitCode: 1, stdout, stderr: stderr + `
|
|
1607
|
+
[spawn error] ${err.message}` });
|
|
1608
|
+
});
|
|
1609
|
+
});
|
|
1610
|
+
}
|
|
1611
|
+
function subCliLogPath(runDir) {
|
|
1612
|
+
return path12.join(runDir, "sub-cli.log");
|
|
1613
|
+
}
|
|
1614
|
+
function appendSubCliHeader(runDir, header) {
|
|
1615
|
+
try {
|
|
1616
|
+
fs9.appendFileSync(subCliLogPath(runDir), `
|
|
1617
|
+
=== ${(/* @__PURE__ */ new Date()).toISOString()} ${header} ===
|
|
1618
|
+
`);
|
|
1619
|
+
} catch {
|
|
1620
|
+
}
|
|
1621
|
+
}
|
|
1622
|
+
function resolveLangHint() {
|
|
1623
|
+
return config.language === "zh" ? "\u7B80\u4F53\u4E2D\u6587" : "English";
|
|
1624
|
+
}
|
|
1625
|
+
function buildLanguageClause(langHint, scope) {
|
|
1626
|
+
return [
|
|
1627
|
+
"",
|
|
1628
|
+
`Output language: write ${scope} in ${langHint}. Do NOT code-switch \u2014`,
|
|
1629
|
+
`don't sprinkle English phrases like "blast radius" or "scope" into`,
|
|
1630
|
+
`otherwise-${langHint} prose, and don't mix ${langHint} tokens into`,
|
|
1631
|
+
`otherwise-English prose. Pick the language and stay in it.`,
|
|
1632
|
+
"",
|
|
1633
|
+
`**This rule also applies to prose you draft INTO the patched prompt`,
|
|
1634
|
+
`files**, not just to your own commentary. If you copy a global file`,
|
|
1635
|
+
`(e.g. global INSTRUCTIONS.md, AGENT.md) into a workspace override`,
|
|
1636
|
+
`and the source is in a different language, translate the prose into`,
|
|
1637
|
+
`${langHint} as you go. Don't leave a half-English half-${langHint}`,
|
|
1638
|
+
`Frankenstein file in the sandbox.`,
|
|
1639
|
+
"",
|
|
1640
|
+
`Things that stay in their source form regardless of language:`,
|
|
1641
|
+
`frontmatter / JSON keys, identifier-like values (file paths, agent`,
|
|
1642
|
+
`ids, command names, enum values like 'high'/'medium'/'low',`,
|
|
1643
|
+
`encoding names like 'utf-8-sig', shell binaries like \`printf\`),`,
|
|
1644
|
+
`literal code or CLI snippets you're quoting, and section headings`,
|
|
1645
|
+
`that other tooling might grep for by exact string (when in doubt,`,
|
|
1646
|
+
`keep the heading and translate the body).`
|
|
1647
|
+
];
|
|
1648
|
+
}
|
|
1649
|
+
function readPatchFrontmatter(runDir) {
|
|
1650
|
+
const patchPath = path12.join(runDir, "patch.md");
|
|
1651
|
+
let raw;
|
|
1652
|
+
try {
|
|
1653
|
+
raw = fs9.readFileSync(patchPath, "utf-8");
|
|
1654
|
+
} catch {
|
|
1655
|
+
return null;
|
|
1656
|
+
}
|
|
1657
|
+
const m = raw.match(/^---\s*\n([\s\S]*?)\n---/);
|
|
1658
|
+
if (!m)
|
|
1659
|
+
return null;
|
|
1660
|
+
try {
|
|
1661
|
+
const parsed = YAML6.parse(m[1]);
|
|
1662
|
+
return parsed ?? null;
|
|
1663
|
+
} catch {
|
|
1664
|
+
return null;
|
|
1665
|
+
}
|
|
1666
|
+
}
|
|
1667
|
+
function extractTestScenario(fm) {
|
|
1668
|
+
if (!fm)
|
|
1669
|
+
return null;
|
|
1670
|
+
const ts = fm.testScenario;
|
|
1671
|
+
if (!ts || typeof ts !== "object")
|
|
1672
|
+
return null;
|
|
1673
|
+
const o = ts;
|
|
1674
|
+
if (typeof o.agentId !== "string" || o.agentId.length === 0)
|
|
1675
|
+
return null;
|
|
1676
|
+
const testMessage = typeof o.testMessage === "string" && o.testMessage.length > 0 ? o.testMessage : typeof o.message === "string" && o.message.length > 0 ? o.message : null;
|
|
1677
|
+
if (!testMessage)
|
|
1678
|
+
return null;
|
|
1679
|
+
const originalMessage = typeof o.originalMessage === "string" && o.originalMessage.length > 0 ? o.originalMessage : testMessage;
|
|
1680
|
+
return { agentId: o.agentId, testMessage, originalMessage };
|
|
1681
|
+
}
|
|
1682
|
+
function readEvoContext(runDir, logFd) {
|
|
1683
|
+
const p = path12.join(runDir, "evo-context.json");
|
|
1684
|
+
if (!fs9.existsSync(p)) {
|
|
1685
|
+
writeLog(logFd, `[brief] evo-context.json missing at ${p}
|
|
1686
|
+
`);
|
|
1687
|
+
return null;
|
|
1688
|
+
}
|
|
1689
|
+
try {
|
|
1690
|
+
return JSON.parse(fs9.readFileSync(p, "utf-8"));
|
|
1691
|
+
} catch (err) {
|
|
1692
|
+
writeLog(logFd, `[brief] evo-context.json parse failed: ${err instanceof Error ? err.message : String(err)}
|
|
1693
|
+
`);
|
|
1694
|
+
return null;
|
|
1695
|
+
}
|
|
1696
|
+
}
|
|
1697
|
+
function dumpSnapshotImages(runDir, logFd) {
|
|
1698
|
+
const snapshotPath = path12.join(runDir, "source-snapshot.json");
|
|
1699
|
+
if (!fs9.existsSync(snapshotPath))
|
|
1700
|
+
return [];
|
|
1701
|
+
let parsed;
|
|
1702
|
+
try {
|
|
1703
|
+
parsed = JSON.parse(fs9.readFileSync(snapshotPath, "utf-8"));
|
|
1704
|
+
} catch {
|
|
1705
|
+
writeLog(logFd, `[dumpSnapshotImages] snapshot parse failed
|
|
1706
|
+
`);
|
|
1707
|
+
return [];
|
|
1708
|
+
}
|
|
1709
|
+
const messages = Array.isArray(parsed.rawMessages) ? parsed.rawMessages : [];
|
|
1710
|
+
if (messages.length === 0)
|
|
1711
|
+
return [];
|
|
1712
|
+
const imagesDir = path12.join(runDir, "images");
|
|
1713
|
+
fs9.mkdirSync(imagesDir, { recursive: true });
|
|
1714
|
+
const out = [];
|
|
1715
|
+
const extByMime = {
|
|
1716
|
+
"image/png": "png",
|
|
1717
|
+
"image/jpeg": "jpg",
|
|
1718
|
+
"image/gif": "gif",
|
|
1719
|
+
"image/webp": "webp"
|
|
1720
|
+
};
|
|
1721
|
+
for (let m = 0; m < messages.length; m++) {
|
|
1722
|
+
const msg = messages[m];
|
|
1723
|
+
if (!msg || !Array.isArray(msg.content))
|
|
1724
|
+
continue;
|
|
1725
|
+
const adjacentText = msg.content.filter((b) => b && b.type === "text" && typeof b.text === "string").map((b) => String(b.text)).join(" ").trim().slice(0, 200);
|
|
1726
|
+
for (let b = 0; b < msg.content.length; b++) {
|
|
1727
|
+
const block = msg.content[b];
|
|
1728
|
+
if (!block || typeof block !== "object")
|
|
1729
|
+
continue;
|
|
1730
|
+
const candidates = [];
|
|
1731
|
+
if (block.type === "image") {
|
|
1732
|
+
candidates.push({ block, subBlockIdx: null });
|
|
1733
|
+
} else if (block.type === "tool_result" && Array.isArray(block.content)) {
|
|
1734
|
+
const inner = block.content;
|
|
1735
|
+
for (let i = 0; i < inner.length; i++) {
|
|
1736
|
+
const sub = inner[i];
|
|
1737
|
+
if (sub && sub.type === "image") {
|
|
1738
|
+
candidates.push({ block: sub, subBlockIdx: i });
|
|
1739
|
+
}
|
|
1740
|
+
}
|
|
1741
|
+
}
|
|
1742
|
+
for (const c of candidates) {
|
|
1743
|
+
const source = c.block.source;
|
|
1744
|
+
if (!source || source.type !== "base64")
|
|
1745
|
+
continue;
|
|
1746
|
+
const data = source.data;
|
|
1747
|
+
const mediaType = source.media_type;
|
|
1748
|
+
if (typeof data !== "string" || typeof mediaType !== "string")
|
|
1749
|
+
continue;
|
|
1750
|
+
const ext = extByMime[mediaType] ?? "bin";
|
|
1751
|
+
const fileName = c.subBlockIdx == null ? `${m}-${b}.${ext}` : `${m}-${b}-${c.subBlockIdx}.${ext}`;
|
|
1752
|
+
const fullPath = path12.join(imagesDir, fileName);
|
|
1753
|
+
try {
|
|
1754
|
+
const buf = Buffer.from(data, "base64");
|
|
1755
|
+
fs9.writeFileSync(fullPath, buf);
|
|
1756
|
+
out.push({
|
|
1757
|
+
relPath: `images/${fileName}`,
|
|
1758
|
+
msgIdx: m,
|
|
1759
|
+
blockIdx: b,
|
|
1760
|
+
role: typeof msg.role === "string" ? msg.role : "?",
|
|
1761
|
+
mediaType,
|
|
1762
|
+
sizeBytes: buf.length,
|
|
1763
|
+
contextText: adjacentText
|
|
1764
|
+
});
|
|
1765
|
+
} catch (err) {
|
|
1766
|
+
writeLog(logFd, `[dumpSnapshotImages] decode failed at msg=${m} block=${b}: ${err instanceof Error ? err.message : String(err)}
|
|
1767
|
+
`);
|
|
1768
|
+
}
|
|
1769
|
+
}
|
|
1770
|
+
}
|
|
1771
|
+
}
|
|
1772
|
+
if (out.length > 0) {
|
|
1773
|
+
writeLog(logFd, `[dumpSnapshotImages] decoded ${out.length} image(s) to ${imagesDir}
|
|
1774
|
+
`);
|
|
1775
|
+
}
|
|
1776
|
+
return out;
|
|
1777
|
+
}
|
|
1778
|
+
function renderImageManifest(images) {
|
|
1779
|
+
if (images.length === 0)
|
|
1780
|
+
return [];
|
|
1781
|
+
const lines = [];
|
|
1782
|
+
lines.push("=== Images extracted from the source conversation ===");
|
|
1783
|
+
lines.push("");
|
|
1784
|
+
lines.push("(decoded from the snapshot's inline base64 image blocks \u2014 the");
|
|
1785
|
+
lines.push("source conversation is NOT in your message history, so these");
|
|
1786
|
+
lines.push("files are the only way to see the images. `view_image` one when");
|
|
1787
|
+
lines.push("its content is load-bearing for your patch; the per-image context");
|
|
1788
|
+
lines.push("text below is usually enough. The paths also let a patch keep an");
|
|
1789
|
+
lines.push("image as a prompt resource \u2014 e.g. a screenshot inside a skill's");
|
|
1790
|
+
lines.push("reference doc.)");
|
|
1791
|
+
lines.push("");
|
|
1792
|
+
for (const img of images) {
|
|
1793
|
+
const sizeKb = (img.sizeBytes / 1024).toFixed(0);
|
|
1794
|
+
lines.push(`- ${img.relPath}`);
|
|
1795
|
+
lines.push(` msg ${img.msgIdx} / block ${img.blockIdx}, role=${img.role}, ${img.mediaType}, ${sizeKb} KB`);
|
|
1796
|
+
if (img.contextText)
|
|
1797
|
+
lines.push(` context: ${img.contextText.replace(/\n/g, " ")}`);
|
|
1798
|
+
}
|
|
1799
|
+
lines.push("");
|
|
1800
|
+
return lines;
|
|
1801
|
+
}
|
|
1802
|
+
function renderEvoContextSections(ctx) {
|
|
1803
|
+
if (!ctx)
|
|
1804
|
+
return ["(prompt surface unavailable \u2014 evo-context.json missing or invalid)"];
|
|
1805
|
+
const lines = [];
|
|
1806
|
+
lines.push(`Triggering agent id: ${ctx.agentId}`);
|
|
1807
|
+
lines.push("");
|
|
1808
|
+
if (ctx.assembledSystemPrompt) {
|
|
1809
|
+
lines.push("=== Assembled system prompt at trigger time ===");
|
|
1810
|
+
lines.push("(this is what the source agent saw as its `system` field on the");
|
|
1811
|
+
lines.push("Bedrock call \u2014 built from AGENT.md + INSTRUCTIONS.md + USER.md +");
|
|
1812
|
+
lines.push("prompts/all + prompts/root + workspace info, in runtime order)");
|
|
1813
|
+
lines.push("```");
|
|
1814
|
+
lines.push(ctx.assembledSystemPrompt);
|
|
1815
|
+
lines.push("```");
|
|
1816
|
+
lines.push("");
|
|
1817
|
+
}
|
|
1818
|
+
if (ctx.promptFiles.length > 0) {
|
|
1819
|
+
lines.push("=== Source prompt files ===");
|
|
1820
|
+
lines.push("(each entry is a file behind the assembled prompt. Workspace files take");
|
|
1821
|
+
lines.push("precedence over global ones at runtime. The triggering agent's");
|
|
1822
|
+
lines.push("agent.yaml is included here too \u2014 its model / tools / context config is");
|
|
1823
|
+
lines.push("a patch target, not just markdown. SKILL.md bodies are NOT inlined (see");
|
|
1824
|
+
lines.push("the skills listing below; file_read the one you need) \u2014 but a SKILL.md");
|
|
1825
|
+
lines.push("is just as patchable. Only template-version metadata is left out.)");
|
|
1826
|
+
lines.push("");
|
|
1827
|
+
for (const f of ctx.promptFiles) {
|
|
1828
|
+
lines.push(`--- ${f.scope}/${f.path} ---`);
|
|
1829
|
+
lines.push("```");
|
|
1830
|
+
lines.push(f.content);
|
|
1831
|
+
lines.push("```");
|
|
1832
|
+
lines.push("");
|
|
1833
|
+
}
|
|
1834
|
+
}
|
|
1835
|
+
if (ctx.agents.length > 0) {
|
|
1836
|
+
lines.push("=== Agents in this workspace ===");
|
|
1837
|
+
for (const a of ctx.agents)
|
|
1838
|
+
lines.push(` ${a.id} [${a.scope}]`);
|
|
1839
|
+
lines.push("");
|
|
1840
|
+
}
|
|
1841
|
+
if (ctx.skills.length > 0) {
|
|
1842
|
+
const globalSkillsDir = path12.join(homedir6(), ".halo", "global", "skills");
|
|
1843
|
+
lines.push("=== Skills in this workspace ===");
|
|
1844
|
+
for (const s of ctx.skills)
|
|
1845
|
+
lines.push(` ${s.id} [${s.scope}]`);
|
|
1846
|
+
lines.push("");
|
|
1847
|
+
lines.push("To read a skill's content, file_read its SKILL.md (you have full");
|
|
1848
|
+
lines.push("file access \u2014 absolute paths resolve):");
|
|
1849
|
+
lines.push(` [workspace] \u2192 ${path12.join("<workspace>", ".halo", "skills", "<id>", "SKILL.md")}`);
|
|
1850
|
+
lines.push(` [global] / [builtin] \u2192 ${path12.join(globalSkillsDir, "<id>", "SKILL.md")}`);
|
|
1851
|
+
lines.push("A [global]/[builtin] skill is NOT inside your sandbox \u2014 read it");
|
|
1852
|
+
lines.push(`from ${globalSkillsDir} directly; don't glob the sandbox for it.`);
|
|
1853
|
+
lines.push("Sibling resource files live next to SKILL.md in that same dir.");
|
|
1854
|
+
lines.push("Skills shipped by canvas extensions (~/.halo/global/extensions/*/skills/)");
|
|
1855
|
+
lines.push("are read-only and never a patch target \u2014 they are left out of this list.");
|
|
1856
|
+
lines.push("");
|
|
1857
|
+
}
|
|
1858
|
+
return lines;
|
|
1859
|
+
}
|
|
1860
|
+
function buildDraftBrief(args) {
|
|
1861
|
+
const lines = [
|
|
1862
|
+
"You are running as the Evolution drafter (mode: DRAFT).",
|
|
1863
|
+
"",
|
|
1864
|
+
`Run id: ${args.runId}`,
|
|
1865
|
+
`Workspace: ${args.workspacePath}`,
|
|
1866
|
+
`Run dir: ${args.runDir}`,
|
|
1867
|
+
"(relative tool paths resolve against the sandbox, not the run dir \u2014",
|
|
1868
|
+
"write run-dir artifacts like patch.md / .skip.md with absolute paths)",
|
|
1869
|
+
`Trigger: ${args.triggerKind}`
|
|
1870
|
+
];
|
|
1871
|
+
if (args.userHint)
|
|
1872
|
+
lines.push(`Reviewer hint: ${args.userHint}`);
|
|
1873
|
+
lines.push("", "The triggering agent's full message log lives on disk:", ` ${path12.join(args.runDir, "tool-flow.md")} \u2014 clipped, fast skim`, ` ${path12.join(args.runDir, "source-snapshot.json")} \u2014 full rawMessages`, "", "Read tool-flow.md first to understand what the user wanted, what the", "agent did, and where it diverged. Each tool_result is clipped to a", "~200-char peek (with the `is_error` flag when the tool failed) \u2014", "enough to see the *shape* of the flow without re-tokenizing large", "grep / shell outputs. Fall back to source-snapshot.json only for", "messages whose full tool result actually matters to your patch.", "", ...renderEvoContextSections(args.evoContext), ...renderImageManifest(args.images), "=== Non-text media note ===", "", "Inline images in source-snapshot.json are base64 \u2014 you can't read them", "directly. The `images/` dump", "(see manifest above) decodes them to disk so you can `view_image` if", "a specific image is load-bearing. Video, audio, and binary documents", "(PDF, etc.) are even more limited: at trigger time the source agent", "worked with them via shell tooling (ffmpeg, pdftotext, etc.), and your", "view is limited to the textual results those tools produced (visible", "inside source-snapshot.json as tool_result text).", "", "When the patch you have in mind requires understanding what's INSIDE", "the media file (a video's visual content, an audio's acoustic", "content, a PDF's layout), the dry-run test scenario can't reliably", "verify it \u2014 skipping the run is the right call. When the patch is", "about the shell-tooling pattern itself (how to call ffmpeg, how to", "grep its output, how to handle credential errors), it can be", "verified at the textual level and the run proceeds normally.", "", "=== Task ===", "", "Decide what change in the prompt surface above would help the next time", "a similar conversation comes up. Three shapes, in order of preference:", "rewrite an existing rule; tighten / reorganize what's there; or, only", "when neither applies, add a new rule.", "", `Sandbox path: ${evoSandboxDir(args.runDir)}`, "", `When you have a change worth making, write ${path12.join(args.runDir, "patch.md")}`, "(frontmatter + body) and one new file at", `${evoSandboxHaloDir(args.runDir)}/<target> with the`, "full new contents. AGENT.md describes the patch.md schema.", "", "When the conversation has no signal worth a patch (rule already covers,", "too short to learn from, agent did fine, depends on media content", `you can't see), write ${path12.join(args.runDir, ".skip.md")} with a`, "one or two sentence reason instead, and skip the rest.", "", ...buildLanguageClause(args.langHint, "all natural-language content (patch.md body, prose drafted into sandbox files, .skip.md body)"));
|
|
1874
|
+
return lines.join("\n");
|
|
1875
|
+
}
|
|
1876
|
+
function buildFixBrief(args) {
|
|
1877
|
+
let failLogText = "";
|
|
1878
|
+
try {
|
|
1879
|
+
failLogText = fs9.readFileSync(args.failLogPath, "utf-8");
|
|
1880
|
+
} catch {
|
|
1881
|
+
failLogText = `(failed to read ${args.failLogPath})`;
|
|
1882
|
+
}
|
|
1883
|
+
return [
|
|
1884
|
+
"You are running as the Evolution drafter (mode: FIX).",
|
|
1885
|
+
"",
|
|
1886
|
+
`Run id: ${args.runId}`,
|
|
1887
|
+
`Workspace: ${args.workspacePath}`,
|
|
1888
|
+
`Run dir: ${args.runDir}`,
|
|
1889
|
+
"(relative tool paths resolve against the sandbox, not the run dir \u2014",
|
|
1890
|
+
"write run-dir artifacts like patch.md with absolute paths)",
|
|
1891
|
+
`Fix attempt: ${args.attempt}/${args.maxAttempts}`,
|
|
1892
|
+
"",
|
|
1893
|
+
`An earlier draft pass produced patch.md + a sandbox at`,
|
|
1894
|
+
`${path12.join(args.runDir, "patch.md")} and`,
|
|
1895
|
+
`${evoSandboxHaloDir(args.runDir)} respectively.`,
|
|
1896
|
+
"The dry-run failed; the wrapper's failure log:",
|
|
1897
|
+
"",
|
|
1898
|
+
"```",
|
|
1899
|
+
failLogText,
|
|
1900
|
+
"```",
|
|
1901
|
+
"",
|
|
1902
|
+
"Read patch.md (and the affected sandbox file) to see what was tried.",
|
|
1903
|
+
`Read tool-flow.md / source-snapshot.json for the original conversation`,
|
|
1904
|
+
"context if you need to reconsider the patch direction. Then write the",
|
|
1905
|
+
`corrected sandbox target file (<runDir>/sandbox/.halo/<target>) and`,
|
|
1906
|
+
"update patch.md if the fix changes the test scenario. Wrapper reruns",
|
|
1907
|
+
"the dry-run after you exit.",
|
|
1908
|
+
"",
|
|
1909
|
+
...buildLanguageClause(args.langHint, "all natural-language content (patch.md body, prose drafted into sandbox files)")
|
|
1910
|
+
].join("\n");
|
|
1911
|
+
}
|
|
1912
|
+
function buildScoreBrief(args) {
|
|
1913
|
+
function readOrEmpty(p, label) {
|
|
1914
|
+
try {
|
|
1915
|
+
return fs9.readFileSync(p, "utf-8");
|
|
1916
|
+
} catch (err) {
|
|
1917
|
+
writeLog(args.logFd, `[buildScoreBrief] ${label} read failed: ${err instanceof Error ? err.message : String(err)}
|
|
1918
|
+
`);
|
|
1919
|
+
return `(${label} unavailable)`;
|
|
1920
|
+
}
|
|
1921
|
+
}
|
|
1922
|
+
const patchMd = readOrEmpty(path12.join(args.runDir, "patch.md"), "patch.md");
|
|
1923
|
+
const dryRun = readOrEmpty(path12.join(args.runDir, "dry-run-output.txt"), "dry-run-output.txt");
|
|
1924
|
+
const meta = readOrEmpty(path12.join(args.runDir, "meta.json"), "meta.json");
|
|
1925
|
+
return [
|
|
1926
|
+
"You are running as the Evolution scorer.",
|
|
1927
|
+
"",
|
|
1928
|
+
`Run id: ${args.runId}`,
|
|
1929
|
+
`Workspace: ${args.workspacePath}`,
|
|
1930
|
+
`Run dir: ${args.runDir}`,
|
|
1931
|
+
"(relative tool paths resolve against the sandbox, not the run dir \u2014",
|
|
1932
|
+
"write score.json with the absolute path given below)",
|
|
1933
|
+
"",
|
|
1934
|
+
`The original conversation lives on disk:`,
|
|
1935
|
+
` ${path12.join(args.runDir, "tool-flow.md")} \u2014 clipped, fast skim`,
|
|
1936
|
+
` ${path12.join(args.runDir, "source-snapshot.json")} \u2014 full rawMessages, when needed`,
|
|
1937
|
+
"Read tool-flow.md first to find the user turn matching",
|
|
1938
|
+
"`testScenario.originalMessage` and the assistant reply that followed",
|
|
1939
|
+
"it. Fall back to source-snapshot.json only when the full tool result",
|
|
1940
|
+
"matters. The current prompt surface and patch context follow.",
|
|
1941
|
+
"",
|
|
1942
|
+
...renderEvoContextSections(args.evoContext),
|
|
1943
|
+
"=== patch.md ===",
|
|
1944
|
+
"```",
|
|
1945
|
+
patchMd,
|
|
1946
|
+
"```",
|
|
1947
|
+
"",
|
|
1948
|
+
"=== dry-run-output.txt ===",
|
|
1949
|
+
"(stdout from the wrapper running the patched sandbox against",
|
|
1950
|
+
"`testScenario.testMessage`. Two execution constraints to keep in",
|
|
1951
|
+
"mind when scoring:",
|
|
1952
|
+
"",
|
|
1953
|
+
" 1. Sandbox runs under `--access workspace`. The agent can read/write",
|
|
1954
|
+
" inside the sandbox but `~/.aws`, `~/.ssh`, `~/.kube`, `~/.docker`,",
|
|
1955
|
+
' `~/.gnupg`, `~/.halo/secrets` are all masked. A "command not',
|
|
1956
|
+
' found" or "Unable to locate credentials" failure on cloud or',
|
|
1957
|
+
' system-credential operations is expected \u2014 interpret it as "we',
|
|
1958
|
+
' cut the wires for safety", not as the patch breaking real',
|
|
1959
|
+
" behavior. Score behavior on whether the agent gave the right",
|
|
1960
|
+
" KIND of response, not on whether external side effects fired.",
|
|
1961
|
+
"",
|
|
1962
|
+
" 2. Media in the source conversation is not directly visible to",
|
|
1963
|
+
" you \u2014 images live in source-snapshot.json as base64, and video /",
|
|
1964
|
+
" audio / binary documents (PDF, etc.) were only ever handled by",
|
|
1965
|
+
" the source agent via shell tooling (ffmpeg, pdftotext, etc.), so",
|
|
1966
|
+
" your view is limited to the textual results. The dry-run's",
|
|
1967
|
+
" `testMessage` is also text-only \u2014 patches whose value depends on",
|
|
1968
|
+
" understanding media internals naturally show low-signal dry-runs",
|
|
1969
|
+
" and that's correctly reflected in `confidence: low` rather than",
|
|
1970
|
+
" punished via behavior score.)",
|
|
1971
|
+
"```",
|
|
1972
|
+
dryRun,
|
|
1973
|
+
"```",
|
|
1974
|
+
"",
|
|
1975
|
+
"=== meta.json ===",
|
|
1976
|
+
"```",
|
|
1977
|
+
meta,
|
|
1978
|
+
"```",
|
|
1979
|
+
"",
|
|
1980
|
+
"=== Task ===",
|
|
1981
|
+
"",
|
|
1982
|
+
"From `patch.md` frontmatter, take `testScenario.originalMessage` and",
|
|
1983
|
+
"find the assistant turn that followed it in tool-flow.md (or",
|
|
1984
|
+
"source-snapshot.json) \u2014 that's your baseline. Compare baseline to",
|
|
1985
|
+
"dry-run-output.txt, rate lint / behavior / scope, write",
|
|
1986
|
+
`${path12.join(args.runDir, "score.json")} with the shape AGENT.md describes.`,
|
|
1987
|
+
"",
|
|
1988
|
+
...buildLanguageClause(args.langHint, 'the score.json "notes" field'),
|
|
1989
|
+
'The numeric fields and "confidence" enum stay in their canonical form.'
|
|
1990
|
+
].join("\n");
|
|
1991
|
+
}
|
|
1992
|
+
async function phaseDraft(args) {
|
|
1993
|
+
writeLog(args.logFd, `
|
|
1994
|
+
=== Phase A: draft ===
|
|
1995
|
+
`);
|
|
1996
|
+
const evoContext = readEvoContext(args.runDir, args.logFd);
|
|
1997
|
+
const images = dumpSnapshotImages(args.runDir, args.logFd);
|
|
1998
|
+
buildEvoSandbox(args.workspacePath, args.runDir, args.logFd);
|
|
1999
|
+
const sandboxWs = evoSandboxDir(args.runDir);
|
|
2000
|
+
const brief = buildDraftBrief({
|
|
2001
|
+
runId: args.runId,
|
|
2002
|
+
workspacePath: args.workspacePath,
|
|
2003
|
+
runDir: args.runDir,
|
|
2004
|
+
triggerKind: args.triggerKind,
|
|
2005
|
+
userHint: args.userHint,
|
|
2006
|
+
langHint: args.langHint,
|
|
2007
|
+
evoContext,
|
|
2008
|
+
images
|
|
2009
|
+
});
|
|
2010
|
+
const cliArgs = ["cli", "-a", "__evo_agent__", "-n", "-w", sandboxWs];
|
|
2011
|
+
appendSubCliHeader(args.runDir, "Phase A: __evo_agent__ draft");
|
|
2012
|
+
const result = await spawnProc(resolveHaloCli(), cliArgs, args.logFd, subCliLogPath(args.runDir), brief, DRAFT_TIMEOUT_SEC);
|
|
2013
|
+
if (result.exitCode !== 0) {
|
|
2014
|
+
writeLog(args.logFd, `[phaseDraft] evo cli exited ${result.exitCode}
|
|
2015
|
+
`);
|
|
2016
|
+
if (result.stderr)
|
|
2017
|
+
writeLog(args.logFd, `[phaseDraft] stderr: ${result.stderr.slice(-2e3)}
|
|
2018
|
+
`);
|
|
2019
|
+
if (result.stdout)
|
|
2020
|
+
writeLog(args.logFd, `[phaseDraft] stdout (last 1KB): ${result.stdout.slice(-1e3)}
|
|
2021
|
+
`);
|
|
2022
|
+
}
|
|
2023
|
+
const { outcome, why } = judgeDraft(args.runDir, args.workspacePath, result.exitCode);
|
|
2024
|
+
writeLog(args.logFd, `[phaseDraft] outcome=${outcome} \u2014 ${why}
|
|
2025
|
+
`);
|
|
2026
|
+
return outcome;
|
|
2027
|
+
}
|
|
2028
|
+
function judgeDraft(runDir, workspacePath, exitCode) {
|
|
2029
|
+
if (fs9.existsSync(path12.join(runDir, ".skip.md"))) {
|
|
2030
|
+
return { outcome: "skipped", why: ".skip.md present \u2014 evo declared skip" };
|
|
2031
|
+
}
|
|
2032
|
+
if (exitCode !== 0) {
|
|
2033
|
+
const timeout = exitCode === 124 ? " (timeout)" : "";
|
|
2034
|
+
return { outcome: "failed", why: `drafter exited ${exitCode}${timeout} \u2014 draft interrupted, not trusted` };
|
|
2035
|
+
}
|
|
2036
|
+
if (!fs9.existsSync(path12.join(runDir, "patch.md"))) {
|
|
2037
|
+
return { outcome: "failed", why: "patch.md missing" };
|
|
2038
|
+
}
|
|
2039
|
+
const target = readPatchFrontmatter(runDir)?.target;
|
|
2040
|
+
if (typeof target !== "string") {
|
|
2041
|
+
return { outcome: "drafted", why: "patch.md present, no target in frontmatter \u2014 target file not checked" };
|
|
2042
|
+
}
|
|
2043
|
+
const sandboxDir = evoSandboxDir(runDir);
|
|
2044
|
+
const rel = path12.relative(sandboxDir, path12.resolve(sandboxDir, target));
|
|
2045
|
+
if (!rel.startsWith(".halo" + path12.sep)) {
|
|
2046
|
+
return { outcome: "failed", why: `target ${JSON.stringify(target)} does not resolve under sandbox/.halo/` };
|
|
2047
|
+
}
|
|
2048
|
+
const sandboxFile = path12.join(sandboxDir, rel);
|
|
2049
|
+
if (!fs9.statSync(sandboxFile, { throwIfNoEntry: false })?.isFile()) {
|
|
2050
|
+
return { outcome: "failed", why: `target not written to sandbox: ${target}` };
|
|
2051
|
+
}
|
|
2052
|
+
const wsFile = path12.join(workspacePath, rel);
|
|
2053
|
+
if (fs9.statSync(wsFile, { throwIfNoEntry: false })?.isFile() && fs9.readFileSync(wsFile).equals(fs9.readFileSync(sandboxFile))) {
|
|
2054
|
+
return { outcome: "failed", why: `target unchanged from workspace: ${target}` };
|
|
2055
|
+
}
|
|
2056
|
+
return { outcome: "drafted", why: `patch.md + target ${target} present` };
|
|
2057
|
+
}
|
|
2058
|
+
async function phaseDryRun(args) {
|
|
2059
|
+
writeLog(args.logFd, `
|
|
2060
|
+
=== Phase B: dry-run ===
|
|
2061
|
+
`);
|
|
2062
|
+
const first = await tryDryRun(args, 0);
|
|
2063
|
+
if (first.kind === "ok")
|
|
2064
|
+
return true;
|
|
2065
|
+
const fixed = await runFix(args, first.failLogPath, 1);
|
|
2066
|
+
if (!fixed) {
|
|
2067
|
+
writeLog(args.logFd, `[phaseDryRun] fix pass exited non-zero; aborting
|
|
2068
|
+
`);
|
|
2069
|
+
return false;
|
|
2070
|
+
}
|
|
2071
|
+
const second = await tryDryRun(args, 1);
|
|
2072
|
+
if (second.kind === "ok")
|
|
2073
|
+
return true;
|
|
2074
|
+
writeLog(args.logFd, `[phaseDryRun] dry-run still failing after fix pass; aborting
|
|
2075
|
+
`);
|
|
2076
|
+
return false;
|
|
2077
|
+
}
|
|
2078
|
+
async function tryDryRun(args, attemptIndex) {
|
|
2079
|
+
const sandboxDir = evoSandboxDir(args.runDir);
|
|
2080
|
+
const outputPath = path12.join(args.runDir, "dry-run-output.txt");
|
|
2081
|
+
const failLogPath = path12.join(args.runDir, `dry-run-fail-${attemptIndex}.log`);
|
|
2082
|
+
const cli = resolveHaloCli();
|
|
2083
|
+
const fm = readPatchFrontmatter(args.runDir);
|
|
2084
|
+
const scenario = extractTestScenario(fm);
|
|
2085
|
+
if (!scenario) {
|
|
2086
|
+
writeLog(args.logFd, `[tryDryRun:${attemptIndex}] patch.md frontmatter missing testScenario.{agentId, testMessage}
|
|
2087
|
+
`);
|
|
2088
|
+
fs9.writeFileSync(failLogPath, "patch.md frontmatter missing testScenario.{agentId, testMessage} \u2014 wrapper cannot run dry-run without it.\n", "utf-8");
|
|
2089
|
+
return { kind: "no-scenario", failLogPath };
|
|
2090
|
+
}
|
|
2091
|
+
writeLog(args.logFd, `[tryDryRun:${attemptIndex}] agent=${scenario.agentId} testMessage=${JSON.stringify(scenario.testMessage).slice(0, 200)}
|
|
2092
|
+
`);
|
|
2093
|
+
appendSubCliHeader(args.runDir, `Phase B: dry-run #${attemptIndex} (${scenario.agentId})`);
|
|
2094
|
+
const result = await spawnProc(
|
|
2095
|
+
cli,
|
|
2096
|
+
["cli", "-a", scenario.agentId, "-n", "-w", sandboxDir, "--access", "workspace"],
|
|
2097
|
+
args.logFd,
|
|
2098
|
+
subCliLogPath(args.runDir),
|
|
2099
|
+
scenario.testMessage,
|
|
2100
|
+
// stdin, not argv
|
|
2101
|
+
DRY_RUN_TIMEOUT_SEC
|
|
2102
|
+
);
|
|
2103
|
+
const timedOut = result.exitCode === 124;
|
|
2104
|
+
const ok = result.exitCode === 0 && result.stdout.trim().length > 0;
|
|
2105
|
+
if (ok) {
|
|
2106
|
+
fs9.writeFileSync(outputPath, result.stdout, "utf-8");
|
|
2107
|
+
writeLog(args.logFd, `[tryDryRun:${attemptIndex}] success: wrote ${outputPath} (${result.stdout.length}B)
|
|
2108
|
+
`);
|
|
2109
|
+
return { kind: "ok" };
|
|
2110
|
+
}
|
|
2111
|
+
const stdoutTail = result.stdout.length > 4e3 ? "\u2026" + result.stdout.slice(-4e3) : result.stdout;
|
|
2112
|
+
const stderrTail = result.stderr.length > 4e3 ? "\u2026" + result.stderr.slice(-4e3) : result.stderr;
|
|
2113
|
+
fs9.writeFileSync(failLogPath, [
|
|
2114
|
+
`Wrapper command: halo cli -a ${scenario.agentId} -n -w ${sandboxDir} <testMessage>`,
|
|
2115
|
+
`Test message: ${scenario.testMessage}`,
|
|
2116
|
+
`Exit code: ${result.exitCode}${timedOut ? " (timeout)" : ""}`,
|
|
2117
|
+
`Stdout (tail):`,
|
|
2118
|
+
stdoutTail || "(empty)",
|
|
2119
|
+
``,
|
|
2120
|
+
`Stderr (tail):`,
|
|
2121
|
+
stderrTail || "(empty)"
|
|
2122
|
+
].join("\n"), "utf-8");
|
|
2123
|
+
writeLog(args.logFd, `[tryDryRun:${attemptIndex}] failed (exit=${result.exitCode}, timedOut=${timedOut}); wrote ${failLogPath}
|
|
2124
|
+
`);
|
|
2125
|
+
return { kind: "failed", failLogPath };
|
|
2126
|
+
}
|
|
2127
|
+
async function runFix(args, failLogPath, attempt) {
|
|
2128
|
+
writeLog(args.logFd, `
|
|
2129
|
+
--- fix attempt ${attempt}/${FIX_BUDGET} ---
|
|
2130
|
+
`);
|
|
2131
|
+
const brief = buildFixBrief({
|
|
2132
|
+
runId: args.runId,
|
|
2133
|
+
workspacePath: args.workspacePath,
|
|
2134
|
+
runDir: args.runDir,
|
|
2135
|
+
failLogPath,
|
|
2136
|
+
attempt,
|
|
2137
|
+
maxAttempts: FIX_BUDGET,
|
|
2138
|
+
langHint: args.langHint
|
|
2139
|
+
});
|
|
2140
|
+
appendSubCliHeader(args.runDir, `Phase B: __evo_agent__ fix attempt ${attempt}`);
|
|
2141
|
+
const sandboxWs = evoSandboxDir(args.runDir);
|
|
2142
|
+
const result = await spawnProc(
|
|
2143
|
+
resolveHaloCli(),
|
|
2144
|
+
["cli", "-a", "__evo_agent__", "-n", "-w", sandboxWs],
|
|
2145
|
+
args.logFd,
|
|
2146
|
+
subCliLogPath(args.runDir),
|
|
2147
|
+
brief,
|
|
2148
|
+
// stdin, not argv — see spawnProc / draft
|
|
2149
|
+
DRAFT_TIMEOUT_SEC
|
|
2150
|
+
);
|
|
2151
|
+
return result.exitCode === 0;
|
|
2152
|
+
}
|
|
2153
|
+
async function phaseScore(args) {
|
|
2154
|
+
writeLog(args.logFd, `
|
|
2155
|
+
=== Phase C: score ===
|
|
2156
|
+
`);
|
|
2157
|
+
const evoContext = readEvoContext(args.runDir, args.logFd);
|
|
2158
|
+
const brief = buildScoreBrief({
|
|
2159
|
+
runId: args.runId,
|
|
2160
|
+
workspacePath: args.workspacePath,
|
|
2161
|
+
runDir: args.runDir,
|
|
2162
|
+
langHint: args.langHint,
|
|
2163
|
+
evoContext,
|
|
2164
|
+
logFd: args.logFd
|
|
2165
|
+
});
|
|
2166
|
+
const sandboxWs = evoSandboxDir(args.runDir);
|
|
2167
|
+
buildEvoSandbox(args.workspacePath, args.runDir, args.logFd);
|
|
2168
|
+
const cliArgs = ["cli", "-a", "__score__", "-n", "-w", sandboxWs];
|
|
2169
|
+
appendSubCliHeader(args.runDir, "Phase C: __score__");
|
|
2170
|
+
const result = await spawnProc(
|
|
2171
|
+
resolveHaloCli(),
|
|
2172
|
+
cliArgs,
|
|
2173
|
+
args.logFd,
|
|
2174
|
+
subCliLogPath(args.runDir),
|
|
2175
|
+
brief,
|
|
2176
|
+
// stdin, not argv — see spawnProc / draft
|
|
2177
|
+
PHASE_TIMEOUT_SEC
|
|
2178
|
+
);
|
|
2179
|
+
if (result.exitCode !== 0) {
|
|
2180
|
+
writeLog(args.logFd, `[phaseScore] scorer cli exited ${result.exitCode}
|
|
2181
|
+
`);
|
|
2182
|
+
if (result.stderr)
|
|
2183
|
+
writeLog(args.logFd, `[phaseScore] stderr: ${result.stderr.slice(-2e3)}
|
|
2184
|
+
`);
|
|
2185
|
+
if (result.stdout)
|
|
2186
|
+
writeLog(args.logFd, `[phaseScore] stdout (last 1KB): ${result.stdout.slice(-1e3)}
|
|
2187
|
+
`);
|
|
2188
|
+
}
|
|
2189
|
+
const hasScore = fs9.existsSync(path12.join(args.runDir, "score.json"));
|
|
2190
|
+
writeLog(args.logFd, `[phaseScore] score.json=${hasScore}
|
|
2191
|
+
`);
|
|
2192
|
+
return hasScore;
|
|
2193
|
+
}
|
|
2194
|
+
var RUN_INPUT_FILES = /* @__PURE__ */ new Set([
|
|
2195
|
+
"source-snapshot.json",
|
|
2196
|
+
"tool-flow.md",
|
|
2197
|
+
"meta.json",
|
|
2198
|
+
"evo-context.json"
|
|
2199
|
+
]);
|
|
2200
|
+
function resetRunArtifacts(runDir, logFd) {
|
|
2201
|
+
let entries;
|
|
2202
|
+
try {
|
|
2203
|
+
entries = fs9.readdirSync(runDir);
|
|
2204
|
+
} catch {
|
|
2205
|
+
return;
|
|
2206
|
+
}
|
|
2207
|
+
for (const entry of entries) {
|
|
2208
|
+
if (RUN_INPUT_FILES.has(entry))
|
|
2209
|
+
continue;
|
|
2210
|
+
try {
|
|
2211
|
+
fs9.rmSync(path12.join(runDir, entry), { recursive: true, force: true });
|
|
2212
|
+
} catch (err) {
|
|
2213
|
+
writeLog(logFd, `[resetRunArtifacts] failed to remove ${entry}: ${err instanceof Error ? err.message : String(err)}
|
|
2214
|
+
`);
|
|
2215
|
+
}
|
|
2216
|
+
}
|
|
2217
|
+
}
|
|
2218
|
+
async function runMode(id, logFd) {
|
|
2219
|
+
const row = loadRunRow(id);
|
|
2220
|
+
const runDir = wsEvoRunDir(row.workspacePath, id);
|
|
2221
|
+
fs9.mkdirSync(runDir, { recursive: true });
|
|
2222
|
+
resetRunArtifacts(runDir, logFd);
|
|
2223
|
+
const hb = setInterval(() => {
|
|
2224
|
+
try {
|
|
2225
|
+
getEvoDb().update(evolutionRuns).set({ heartbeatAt: Date.now() }).where(eq(evolutionRuns.id, id)).run();
|
|
2226
|
+
} catch (err) {
|
|
2227
|
+
writeLog(logFd, `[wrapper] heartbeat failed: ${err instanceof Error ? err.message : String(err)}
|
|
2228
|
+
`);
|
|
2229
|
+
}
|
|
2230
|
+
}, 6e4);
|
|
2231
|
+
const langHint = resolveLangHint();
|
|
2232
|
+
writeLog(logFd, `[wrapper] resolved langHint=${langHint}
|
|
2233
|
+
`);
|
|
2234
|
+
const ctx = {
|
|
2235
|
+
runId: id,
|
|
2236
|
+
workspacePath: row.workspacePath,
|
|
2237
|
+
runDir,
|
|
2238
|
+
triggerKind: row.triggerKind,
|
|
2239
|
+
userHint: row.userHint ?? null,
|
|
2240
|
+
langHint,
|
|
2241
|
+
logFd
|
|
2242
|
+
};
|
|
2243
|
+
try {
|
|
2244
|
+
const outcome = await phaseDraft(ctx);
|
|
2245
|
+
if (outcome === "failed") {
|
|
2246
|
+
finalize(id, "failed", "phase A: draft incomplete (drafter exited non-zero, or patch.md / target file missing or unchanged) \u2014 see run log", logFd);
|
|
2247
|
+
return;
|
|
2248
|
+
}
|
|
2249
|
+
if (outcome === "skipped") {
|
|
2250
|
+
finalize(id, "skipped", null, logFd);
|
|
2251
|
+
return;
|
|
2252
|
+
}
|
|
2253
|
+
const dryRunOk = await phaseDryRun(ctx);
|
|
2254
|
+
if (!dryRunOk) {
|
|
2255
|
+
finalize(id, "failed", "phase B: dry-run failed (fix budget exhausted)", logFd);
|
|
2256
|
+
return;
|
|
2257
|
+
}
|
|
2258
|
+
const scored = await phaseScore(ctx);
|
|
2259
|
+
if (!scored) {
|
|
2260
|
+
finalize(id, "failed", "phase C: scorer did not produce score.json", logFd);
|
|
2261
|
+
return;
|
|
2262
|
+
}
|
|
2263
|
+
finalize(id, "awaiting_review", null, logFd);
|
|
2264
|
+
} finally {
|
|
2265
|
+
clearInterval(hb);
|
|
2266
|
+
}
|
|
2267
|
+
}
|
|
2268
|
+
function finalize(id, status, failureReason, logFd) {
|
|
2269
|
+
const now = Date.now();
|
|
2270
|
+
getEvoDb().update(evolutionRuns).set({ status, failureReason, completedAt: now }).where(eq(evolutionRuns.id, id)).run();
|
|
2271
|
+
writeLog(logFd, `[wrapper] finalize: status=${status}${failureReason ? ` reason=${failureReason}` : ""}
|
|
2272
|
+
`);
|
|
2273
|
+
}
|
|
2274
|
+
var APPLY_REGRESSION_FLOOR = 50;
|
|
2275
|
+
function loadApplyRow(id) {
|
|
2276
|
+
const db = getEvoDb();
|
|
2277
|
+
const row = db.select({
|
|
2278
|
+
id: evolutionApplies.id,
|
|
2279
|
+
workspacePath: evolutionApplies.workspacePath,
|
|
2280
|
+
sourceRunIds: evolutionApplies.sourceRunIds,
|
|
2281
|
+
reviewerHint: evolutionApplies.reviewerHint,
|
|
2282
|
+
status: evolutionApplies.status
|
|
2283
|
+
}).from(evolutionApplies).where(eq(evolutionApplies.id, id)).get();
|
|
2284
|
+
if (!row)
|
|
2285
|
+
throw new Error(`evolution_applies row not found: ${id}`);
|
|
2286
|
+
let sourceRunIds;
|
|
2287
|
+
try {
|
|
2288
|
+
sourceRunIds = JSON.parse(row.sourceRunIds);
|
|
2289
|
+
} catch {
|
|
2290
|
+
throw new Error(`evolution_applies.${id}.source_run_ids is not valid JSON: ${row.sourceRunIds}`);
|
|
2291
|
+
}
|
|
2292
|
+
if (!Array.isArray(sourceRunIds) || sourceRunIds.length === 0) {
|
|
2293
|
+
throw new Error(`evolution_applies.${id}.source_run_ids must be a non-empty array`);
|
|
2294
|
+
}
|
|
2295
|
+
return {
|
|
2296
|
+
id: row.id,
|
|
2297
|
+
workspacePath: row.workspacePath,
|
|
2298
|
+
sourceRunIds,
|
|
2299
|
+
reviewerHint: row.reviewerHint,
|
|
2300
|
+
status: row.status
|
|
2301
|
+
};
|
|
2302
|
+
}
|
|
2303
|
+
function buildEvoSandbox(workspacePath, sandboxParent, logFd) {
|
|
2304
|
+
const srcWs = wsHaloDir(workspacePath);
|
|
2305
|
+
const dstWs = evoSandboxHaloDir(sandboxParent);
|
|
2306
|
+
fs9.mkdirSync(dstWs, { recursive: true });
|
|
2307
|
+
const whitelist = SANDBOX_WHITELIST;
|
|
2308
|
+
for (const entry of whitelist) {
|
|
2309
|
+
const srcEntry = path12.join(srcWs, entry);
|
|
2310
|
+
if (!fs9.existsSync(srcEntry))
|
|
2311
|
+
continue;
|
|
2312
|
+
const dstEntry = path12.join(dstWs, entry);
|
|
2313
|
+
if (fs9.existsSync(dstEntry))
|
|
2314
|
+
continue;
|
|
2315
|
+
copyDereferenced(srcEntry, dstEntry);
|
|
2316
|
+
}
|
|
2317
|
+
writeLog(logFd, `[buildEvoSandbox] cp from ${srcWs} \u2192 ${dstWs} (whitelist: ${whitelist.join(", ")})
|
|
2318
|
+
`);
|
|
2319
|
+
}
|
|
2320
|
+
function copyDereferenced(src, dst) {
|
|
2321
|
+
if (fs9.statSync(src).isDirectory()) {
|
|
2322
|
+
fs9.mkdirSync(dst, { recursive: true });
|
|
2323
|
+
for (const entry of fs9.readdirSync(src))
|
|
2324
|
+
copyDereferenced(path12.join(src, entry), path12.join(dst, entry));
|
|
2325
|
+
return;
|
|
2326
|
+
}
|
|
2327
|
+
fs9.copyFileSync(src, dst);
|
|
2328
|
+
}
|
|
2329
|
+
function buildApplySandbox(workspacePath, applyDir, logFd) {
|
|
2330
|
+
buildEvoSandbox(workspacePath, applyDir, logFd);
|
|
2331
|
+
}
|
|
2332
|
+
function buildApplyMergeBrief(args) {
|
|
2333
|
+
return [
|
|
2334
|
+
"You are running as the Apply agent (phase A: MERGE).",
|
|
2335
|
+
"",
|
|
2336
|
+
`Apply id: ${args.applyId}`,
|
|
2337
|
+
`Workspace: ${args.workspacePath}`,
|
|
2338
|
+
`Apply dir: ${args.applyDir}`,
|
|
2339
|
+
"(relative tool paths resolve against the sandbox, not the apply dir \u2014",
|
|
2340
|
+
"write apply-dir artifacts like apply.log with absolute paths)",
|
|
2341
|
+
`source_run_ids: [${args.sourceRunIds.join(", ")}]`,
|
|
2342
|
+
`Reviewer hint: ${args.reviewerHint ?? "(none)"}`,
|
|
2343
|
+
"",
|
|
2344
|
+
"The wrapper has already built the sandbox at",
|
|
2345
|
+
`${evoSandboxHaloDir(args.applyDir)} by whitelist-cp from`,
|
|
2346
|
+
"the current main workspace. Read each source_run_id's patch.md",
|
|
2347
|
+
"(latest version!) and merge their changes into the sandbox.",
|
|
2348
|
+
"",
|
|
2349
|
+
"Follow the procedure in your AGENT.md. Pay close attention to the",
|
|
2350
|
+
"platform override matrix \u2014 workspace-replaces-global rules differ",
|
|
2351
|
+
"per file type, and the prompts/ directory replacement trap is real.",
|
|
2352
|
+
"",
|
|
2353
|
+
"Hard rules: only edit files under sandbox/.halo/ \u2014 main workspace",
|
|
2354
|
+
"and ~/.halo/global/ are off-limits. Do NOT spawn halo cli. Do NOT",
|
|
2355
|
+
`write score.json. Write ${path12.join(args.applyDir, "apply.log")}`,
|
|
2356
|
+
"with a summary of what you did.",
|
|
2357
|
+
"",
|
|
2358
|
+
"If the merge cannot proceed (irreconcilable patch conflicts, malformed",
|
|
2359
|
+
`input, missing sandbox), write ${path12.join(args.applyDir, "ABORT.md")}`,
|
|
2360
|
+
'with the diagnosis \u2014 the wrapper treats its presence as "abort this',
|
|
2361
|
+
'apply" and fails the apply instead of publishing. You cannot signal',
|
|
2362
|
+
"abort via exit code; ABORT.md is the only abort channel.",
|
|
2363
|
+
...buildLanguageClause(args.langHint, "apply.log and any natural-language commentary")
|
|
2364
|
+
].join("\n");
|
|
2365
|
+
}
|
|
2366
|
+
function buildApplyScoreBrief(args) {
|
|
2367
|
+
return [
|
|
2368
|
+
"You are running as the Evolution scorer in APPLY REGRESSION mode.",
|
|
2369
|
+
"",
|
|
2370
|
+
`Apply id: ${args.applyId}`,
|
|
2371
|
+
`Source run id: ${args.runId}`,
|
|
2372
|
+
`Workspace: ${args.workspacePath}`,
|
|
2373
|
+
`Regress dir: ${args.regressDir}`,
|
|
2374
|
+
"(relative tool paths resolve against the sandbox \u2014 use the absolute",
|
|
2375
|
+
"paths below)",
|
|
2376
|
+
"",
|
|
2377
|
+
"The apply agent has merged this patch (and possibly others) into",
|
|
2378
|
+
"an apply sandbox. The wrapper just ran the patch's testScenario",
|
|
2379
|
+
"against the merged sandbox; stdout is in",
|
|
2380
|
+
`${path12.join(args.regressDir, "dry-run-output.txt")}.`,
|
|
2381
|
+
"An empty dry-run-output.txt means the dry-run against the merged",
|
|
2382
|
+
"sandbox failed \u2014 score lint 0 (that is the regression signal the",
|
|
2383
|
+
`wrapper acts on); the failure detail is in`,
|
|
2384
|
+
`${path12.join(args.regressDir, "dry-run-fail.log")} when present.`,
|
|
2385
|
+
"",
|
|
2386
|
+
"Follow your AGENT.md procedure. Read:",
|
|
2387
|
+
` patch.md \u2192 ${args.patchMdPath}`,
|
|
2388
|
+
` tool-flow.md \u2192 ${path12.join(path12.dirname(args.patchMdPath), "tool-flow.md")} (baseline conversation)`,
|
|
2389
|
+
` source-snapshot.json \u2192 ${args.snapshotPath}`,
|
|
2390
|
+
` dry-run output \u2192 ${path12.join(args.regressDir, "dry-run-output.txt")}`,
|
|
2391
|
+
"",
|
|
2392
|
+
`Write ${path12.join(args.regressDir, "score.json")} \u2014 NOT the source`,
|
|
2393
|
+
"run's score.json. Same format as run mode.",
|
|
2394
|
+
"Do not modify any other file.",
|
|
2395
|
+
...buildLanguageClause(args.langHint, 'the score.json "notes" field'),
|
|
2396
|
+
'The numeric fields and "confidence" enum stay as-is.'
|
|
2397
|
+
].join("\n");
|
|
2398
|
+
}
|
|
2399
|
+
async function phaseApplyMerge(ctx) {
|
|
2400
|
+
writeLog(ctx.logFd, `
|
|
2401
|
+
=== Phase A': merge ===
|
|
2402
|
+
`);
|
|
2403
|
+
buildApplySandbox(ctx.workspacePath, ctx.applyDir, ctx.logFd);
|
|
2404
|
+
const abortPath = path12.join(ctx.applyDir, "ABORT.md");
|
|
2405
|
+
try {
|
|
2406
|
+
fs9.rmSync(abortPath, { force: true });
|
|
2407
|
+
} catch {
|
|
2408
|
+
}
|
|
2409
|
+
const brief = buildApplyMergeBrief(ctx);
|
|
2410
|
+
const sandboxWs = evoSandboxDir(ctx.applyDir);
|
|
2411
|
+
const result = await spawnProc(
|
|
2412
|
+
resolveHaloCli(),
|
|
2413
|
+
["cli", "-a", "__apply_agent__", "-n", "-w", sandboxWs],
|
|
2414
|
+
ctx.logFd,
|
|
2415
|
+
void 0,
|
|
2416
|
+
brief,
|
|
2417
|
+
// stdin, not argv — see spawnProc / draft
|
|
2418
|
+
PHASE_TIMEOUT_SEC
|
|
2419
|
+
);
|
|
2420
|
+
if (result.exitCode !== 0) {
|
|
2421
|
+
writeLog(ctx.logFd, `[phaseApplyMerge] apply cli exited ${result.exitCode}
|
|
2422
|
+
`);
|
|
2423
|
+
return { ok: false, reason: `apply cli exited ${result.exitCode}` };
|
|
2424
|
+
}
|
|
2425
|
+
if (fs9.existsSync(abortPath)) {
|
|
2426
|
+
let abortText = "";
|
|
2427
|
+
try {
|
|
2428
|
+
abortText = fs9.readFileSync(abortPath, "utf-8").trim();
|
|
2429
|
+
} catch {
|
|
2430
|
+
}
|
|
2431
|
+
writeLog(ctx.logFd, `[phaseApplyMerge] ABORT.md present \u2014 agent aborted the merge:
|
|
2432
|
+
${abortText}
|
|
2433
|
+
`);
|
|
2434
|
+
const firstLine = abortText.split("\n")[0]?.slice(0, 300) ?? "";
|
|
2435
|
+
return { ok: false, reason: `apply agent aborted: ${firstLine || "see ABORT.md in apply dir"}` };
|
|
2436
|
+
}
|
|
2437
|
+
const hasLog = fs9.existsSync(path12.join(ctx.applyDir, "apply.log"));
|
|
2438
|
+
writeLog(ctx.logFd, `[phaseApplyMerge] apply.log=${hasLog}
|
|
2439
|
+
`);
|
|
2440
|
+
if (!hasLog)
|
|
2441
|
+
return { ok: false, reason: "apply agent didn't produce apply.log" };
|
|
2442
|
+
return { ok: true };
|
|
2443
|
+
}
|
|
2444
|
+
async function phaseApplyRegress(ctx) {
|
|
2445
|
+
writeLog(ctx.logFd, `
|
|
2446
|
+
=== Phase B': regress ===
|
|
2447
|
+
`);
|
|
2448
|
+
const sandboxDir = evoSandboxDir(ctx.applyDir);
|
|
2449
|
+
fs9.mkdirSync(evoRegressDir(ctx.applyDir), { recursive: true });
|
|
2450
|
+
const outcomes = [];
|
|
2451
|
+
for (const runId of ctx.sourceRunIds) {
|
|
2452
|
+
writeLog(ctx.logFd, `
|
|
2453
|
+
--- regress for source run ${runId} ---
|
|
2454
|
+
`);
|
|
2455
|
+
const regressDir = evoRegressRunDir(ctx.applyDir, runId);
|
|
2456
|
+
fs9.mkdirSync(regressDir, { recursive: true });
|
|
2457
|
+
const runDir = wsEvoRunDir(ctx.workspacePath, runId);
|
|
2458
|
+
const patchMdPath = path12.join(runDir, "patch.md");
|
|
2459
|
+
const snapshotPath = path12.join(runDir, "source-snapshot.json");
|
|
2460
|
+
const fm = readPatchFrontmatter(runDir);
|
|
2461
|
+
const scenario = extractTestScenario(fm);
|
|
2462
|
+
if (!scenario) {
|
|
2463
|
+
writeLog(ctx.logFd, `[phaseApplyRegress:${runId}] missing testScenario in patch.md frontmatter
|
|
2464
|
+
`);
|
|
2465
|
+
outcomes.push({
|
|
2466
|
+
runId,
|
|
2467
|
+
scoreAvg: null,
|
|
2468
|
+
lint: null,
|
|
2469
|
+
behavior: null,
|
|
2470
|
+
regressed: true,
|
|
2471
|
+
reason: "patch.md missing testScenario"
|
|
2472
|
+
});
|
|
2473
|
+
continue;
|
|
2474
|
+
}
|
|
2475
|
+
writeLog(ctx.logFd, `[phaseApplyRegress:${runId}] testScenario.testMessage=${JSON.stringify(scenario.testMessage).slice(0, 200)}
|
|
2476
|
+
`);
|
|
2477
|
+
const dryRun = await spawnProc(
|
|
2478
|
+
resolveHaloCli(),
|
|
2479
|
+
["cli", "-a", scenario.agentId, "-n", "-w", sandboxDir, "--access", "workspace"],
|
|
2480
|
+
ctx.logFd,
|
|
2481
|
+
void 0,
|
|
2482
|
+
scenario.testMessage,
|
|
2483
|
+
// stdin, not argv
|
|
2484
|
+
DRY_RUN_TIMEOUT_SEC
|
|
2485
|
+
);
|
|
2486
|
+
const dryRunOut = path12.join(regressDir, "dry-run-output.txt");
|
|
2487
|
+
if (dryRun.exitCode === 0 && dryRun.stdout.trim().length > 0) {
|
|
2488
|
+
fs9.writeFileSync(dryRunOut, dryRun.stdout, "utf-8");
|
|
2489
|
+
} else {
|
|
2490
|
+
const tail = dryRun.stdout.length > 4e3 ? "\u2026" + dryRun.stdout.slice(-4e3) : dryRun.stdout;
|
|
2491
|
+
const errTail = dryRun.stderr.length > 4e3 ? "\u2026" + dryRun.stderr.slice(-4e3) : dryRun.stderr;
|
|
2492
|
+
fs9.writeFileSync(dryRunOut, "", "utf-8");
|
|
2493
|
+
fs9.writeFileSync(path12.join(regressDir, "dry-run-fail.log"), [
|
|
2494
|
+
`Wrapper command: halo cli -a ${scenario.agentId} -n -w ${sandboxDir} <testMessage>`,
|
|
2495
|
+
`Test message: ${scenario.testMessage}`,
|
|
2496
|
+
`Exit code: ${dryRun.exitCode}${dryRun.exitCode === 124 ? " (timeout)" : ""}`,
|
|
2497
|
+
`Stdout (tail):`,
|
|
2498
|
+
tail || "(empty)",
|
|
2499
|
+
"",
|
|
2500
|
+
`Stderr (tail):`,
|
|
2501
|
+
errTail || "(empty)"
|
|
2502
|
+
].join("\n"), "utf-8");
|
|
2503
|
+
}
|
|
2504
|
+
const scoreBrief = buildApplyScoreBrief({
|
|
2505
|
+
applyId: ctx.applyId,
|
|
2506
|
+
workspacePath: ctx.workspacePath,
|
|
2507
|
+
runId,
|
|
2508
|
+
regressDir,
|
|
2509
|
+
patchMdPath,
|
|
2510
|
+
snapshotPath,
|
|
2511
|
+
langHint: ctx.langHint
|
|
2512
|
+
});
|
|
2513
|
+
const scoreSandboxWs = evoSandboxDir(ctx.applyDir);
|
|
2514
|
+
const scoreRun = await spawnProc(
|
|
2515
|
+
resolveHaloCli(),
|
|
2516
|
+
["cli", "-a", "__score__", "-n", "-w", scoreSandboxWs],
|
|
2517
|
+
ctx.logFd,
|
|
2518
|
+
void 0,
|
|
2519
|
+
scoreBrief,
|
|
2520
|
+
// stdin, not argv — see spawnProc / draft
|
|
2521
|
+
PHASE_TIMEOUT_SEC
|
|
2522
|
+
);
|
|
2523
|
+
if (scoreRun.exitCode !== 0) {
|
|
2524
|
+
writeLog(ctx.logFd, `[phaseApplyRegress:${runId}] scorer cli exited ${scoreRun.exitCode}
|
|
2525
|
+
`);
|
|
2526
|
+
}
|
|
2527
|
+
const scorePath = path12.join(regressDir, "score.json");
|
|
2528
|
+
let scoreJson = null;
|
|
2529
|
+
try {
|
|
2530
|
+
scoreJson = JSON.parse(fs9.readFileSync(scorePath, "utf-8"));
|
|
2531
|
+
} catch {
|
|
2532
|
+
}
|
|
2533
|
+
if (!scoreJson) {
|
|
2534
|
+
writeLog(ctx.logFd, `[phaseApplyRegress:${runId}] missing/malformed score.json \u2014 treating as regression
|
|
2535
|
+
`);
|
|
2536
|
+
outcomes.push({
|
|
2537
|
+
runId,
|
|
2538
|
+
scoreAvg: null,
|
|
2539
|
+
lint: null,
|
|
2540
|
+
behavior: null,
|
|
2541
|
+
regressed: true,
|
|
2542
|
+
reason: "scorer did not produce score.json"
|
|
2543
|
+
});
|
|
2544
|
+
continue;
|
|
2545
|
+
}
|
|
2546
|
+
const lint = typeof scoreJson.lint === "number" ? scoreJson.lint : null;
|
|
2547
|
+
const behavior = typeof scoreJson.behavior === "number" ? scoreJson.behavior : null;
|
|
2548
|
+
const avg = typeof scoreJson.avg === "number" ? scoreJson.avg : null;
|
|
2549
|
+
let regressed = false;
|
|
2550
|
+
let reason = null;
|
|
2551
|
+
if (lint != null && lint < APPLY_REGRESSION_FLOOR) {
|
|
2552
|
+
regressed = true;
|
|
2553
|
+
reason = `lint ${lint} < ${APPLY_REGRESSION_FLOOR} (config didn't load cleanly)`;
|
|
2554
|
+
} else if (behavior != null && behavior < APPLY_REGRESSION_FLOOR) {
|
|
2555
|
+
regressed = true;
|
|
2556
|
+
reason = `behavior ${behavior} < ${APPLY_REGRESSION_FLOOR} (worse than baseline)`;
|
|
2557
|
+
}
|
|
2558
|
+
writeLog(ctx.logFd, `[phaseApplyRegress:${runId}] avg=${avg} lint=${lint} behavior=${behavior} regressed=${regressed}${reason ? ` (${reason})` : ""}
|
|
2559
|
+
`);
|
|
2560
|
+
outcomes.push({ runId, scoreAvg: avg, lint, behavior, regressed, reason });
|
|
2561
|
+
}
|
|
2562
|
+
const allOk = outcomes.every((o) => !o.regressed);
|
|
2563
|
+
writeLog(ctx.logFd, `
|
|
2564
|
+
[phaseApplyRegress] summary: ${outcomes.length} runs, allOk=${allOk}
|
|
2565
|
+
`);
|
|
2566
|
+
return { allOk, outcomes };
|
|
2567
|
+
}
|
|
2568
|
+
function finalizeApply(applyId, status, failureReason, logFd) {
|
|
2569
|
+
const now = Date.now();
|
|
2570
|
+
getEvoDb().update(evolutionApplies).set({ status, failureReason, completedAt: now }).where(eq(evolutionApplies.id, applyId)).run();
|
|
2571
|
+
writeLog(logFd, `[wrapper] finalize apply: status=${status}${failureReason ? ` reason=${failureReason}` : ""}
|
|
2572
|
+
`);
|
|
2573
|
+
}
|
|
2574
|
+
async function applyMode(id, logFd) {
|
|
2575
|
+
const row = loadApplyRow(id);
|
|
2576
|
+
const applyDir = wsEvoApplyDir(row.workspacePath, id);
|
|
2577
|
+
fs9.mkdirSync(applyDir, { recursive: true });
|
|
2578
|
+
fs9.writeFileSync(path12.join(applyDir, "meta.json"), JSON.stringify({
|
|
2579
|
+
id,
|
|
2580
|
+
sourceRunIds: row.sourceRunIds,
|
|
2581
|
+
reviewerHint: row.reviewerHint,
|
|
2582
|
+
workspacePath: row.workspacePath,
|
|
2583
|
+
createdAt: Date.now()
|
|
2584
|
+
}, null, 2), "utf-8");
|
|
2585
|
+
const hb = setInterval(() => {
|
|
2586
|
+
try {
|
|
2587
|
+
getEvoDb().update(evolutionApplies).set({ heartbeatAt: Date.now() }).where(eq(evolutionApplies.id, id)).run();
|
|
2588
|
+
} catch (err) {
|
|
2589
|
+
writeLog(logFd, `[wrapper] heartbeat (apply) failed: ${err instanceof Error ? err.message : String(err)}
|
|
2590
|
+
`);
|
|
2591
|
+
}
|
|
2592
|
+
}, 6e4);
|
|
2593
|
+
const ctx = {
|
|
2594
|
+
applyId: id,
|
|
2595
|
+
workspacePath: row.workspacePath,
|
|
2596
|
+
applyDir,
|
|
2597
|
+
sourceRunIds: row.sourceRunIds,
|
|
2598
|
+
reviewerHint: row.reviewerHint,
|
|
2599
|
+
langHint: resolveLangHint(),
|
|
2600
|
+
logFd
|
|
2601
|
+
};
|
|
2602
|
+
const isResume = row.status === "syncing";
|
|
2603
|
+
if (isResume) {
|
|
2604
|
+
writeLog(logFd, `[wrapper] resume path: row was in 'syncing', skipping phase A'/B' and re-running phase 12 publish only
|
|
2605
|
+
`);
|
|
2606
|
+
}
|
|
2607
|
+
try {
|
|
2608
|
+
if (!isResume) {
|
|
2609
|
+
const merged = await phaseApplyMerge(ctx);
|
|
2610
|
+
if (!merged.ok) {
|
|
2611
|
+
finalizeApply(id, "failed", `phase A': ${merged.reason}`, logFd);
|
|
2612
|
+
return;
|
|
2613
|
+
}
|
|
2614
|
+
const { allOk, outcomes } = await phaseApplyRegress(ctx);
|
|
2615
|
+
if (!allOk) {
|
|
2616
|
+
const regressedRuns = outcomes.filter((o) => o.regressed).map((o) => `${o.runId}(${o.reason ?? "?"})`).join("; ");
|
|
2617
|
+
finalizeApply(id, "failed", `phase B': regression in ${regressedRuns}`, logFd);
|
|
2618
|
+
return;
|
|
2619
|
+
}
|
|
2620
|
+
}
|
|
2621
|
+
try {
|
|
2622
|
+
const preflight = await phaseApplyPreflight(ctx);
|
|
2623
|
+
if (!preflight.ok) {
|
|
2624
|
+
finalizeApply(id, "failed", `phase 12 preflight: ${preflight.reason}`, logFd);
|
|
2625
|
+
return;
|
|
2626
|
+
}
|
|
2627
|
+
if (!isResume)
|
|
2628
|
+
markSyncing(id, logFd);
|
|
2629
|
+
await phaseApplyPublish(ctx, preflight.result);
|
|
2630
|
+
for (const runId of ctx.sourceRunIds) {
|
|
2631
|
+
getEvoDb().update(evolutionRuns).set({ status: "applied", appliedAt: Date.now() }).where(eq(evolutionRuns.id, runId)).run();
|
|
2632
|
+
}
|
|
2633
|
+
finalizeApplied(id, logFd);
|
|
2634
|
+
writeLog(logFd, `[wrapper] phase 12: published ${preflight.result.fileCount} files to main; history snapshot at ${preflight.result.historyDir}
|
|
2635
|
+
`);
|
|
2636
|
+
writeLog(logFd, `[wrapper] note: no explicit session-release step needed. Halo's SessionManager evicts every session from the in-memory cache as soon as its current message turn finishes (runSession's finally block). The next message \u2014 channel reply, web reload, /new \u2014 runs ensureSession \u2192 buildAgentInstance, which re-reads agent.yaml + INSTRUCTIONS.md + USER.md from disk. So the new prompts kick in on the next turn for free, and currently-running turns finish on the old prompts (which is the right behavior \u2014 we don't want to abort a user mid-conversation).
|
|
2637
|
+
`);
|
|
2638
|
+
} catch (err) {
|
|
2639
|
+
const currentStatus = getEvoDb().select({ status: evolutionApplies.status }).from(evolutionApplies).where(eq(evolutionApplies.id, id)).get()?.status;
|
|
2640
|
+
if (currentStatus === "syncing") {
|
|
2641
|
+
writeLog(logFd, `[wrapper] phase 12 publish threw (${err instanceof Error ? err.message : String(err)}); leaving status='syncing' so ticker can resume \u2014 main may be half-applied
|
|
2642
|
+
`);
|
|
2643
|
+
getEvoDb().update(evolutionApplies).set({ startedAt: null, heartbeatAt: null }).where(and(eq(evolutionApplies.id, id), eq(evolutionApplies.status, "syncing"))).run();
|
|
2644
|
+
} else {
|
|
2645
|
+
finalizeApply(id, "failed", `phase 12: ${err instanceof Error ? err.message : String(err)}`, logFd);
|
|
2646
|
+
}
|
|
2647
|
+
}
|
|
2648
|
+
} finally {
|
|
2649
|
+
clearInterval(hb);
|
|
2650
|
+
}
|
|
2651
|
+
}
|
|
2652
|
+
async function phaseApplyPreflight(ctx) {
|
|
2653
|
+
writeLog(ctx.logFd, `
|
|
2654
|
+
=== Phase 12 preflight: diff + history backup ===
|
|
2655
|
+
`);
|
|
2656
|
+
const sandboxHalo = evoSandboxHaloDir(ctx.applyDir);
|
|
2657
|
+
const mainHalo = wsHaloDir(ctx.workspacePath);
|
|
2658
|
+
if (!fs9.existsSync(sandboxHalo))
|
|
2659
|
+
return { ok: false, reason: "sandbox missing \u2014 phase 11 must run first" };
|
|
2660
|
+
const whitelistTop = new Set(PUBLISH_WHITELIST);
|
|
2661
|
+
const filesToConsider = [];
|
|
2662
|
+
for (const top of fs9.readdirSync(sandboxHalo)) {
|
|
2663
|
+
if (!whitelistTop.has(top))
|
|
2664
|
+
continue;
|
|
2665
|
+
walkInto(path12.join(sandboxHalo, top), top, filesToConsider);
|
|
2666
|
+
}
|
|
2667
|
+
const historyDir = wsEvoHistoryDir(ctx.workspacePath, ctx.applyId);
|
|
2668
|
+
const changed = [];
|
|
2669
|
+
for (const { rel, full } of filesToConsider) {
|
|
2670
|
+
const mainPath = path12.join(mainHalo, rel);
|
|
2671
|
+
let existsInMain = false;
|
|
2672
|
+
try {
|
|
2673
|
+
fs9.accessSync(mainPath);
|
|
2674
|
+
existsInMain = true;
|
|
2675
|
+
} catch {
|
|
2676
|
+
}
|
|
2677
|
+
if (existsInMain) {
|
|
2678
|
+
const sandboxBuf = fs9.readFileSync(full);
|
|
2679
|
+
const mainBuf = fs9.readFileSync(mainPath);
|
|
2680
|
+
if (sandboxBuf.equals(mainBuf))
|
|
2681
|
+
continue;
|
|
2682
|
+
}
|
|
2683
|
+
changed.push({ rel, full, existsInMain });
|
|
2684
|
+
}
|
|
2685
|
+
writeLog(ctx.logFd, `[preflight] ${changed.length} of ${filesToConsider.length} whitelisted files differ from main
|
|
2686
|
+
`);
|
|
2687
|
+
if (changed.length === 0) {
|
|
2688
|
+
return { ok: true, result: { changed, historyDir, fileCount: 0 } };
|
|
2689
|
+
}
|
|
2690
|
+
fs9.mkdirSync(historyDir, { recursive: true });
|
|
2691
|
+
const manifestPath = path12.join(historyDir, "MANIFEST.json");
|
|
2692
|
+
const isFirstPreflight = !fs9.existsSync(manifestPath);
|
|
2693
|
+
if (isFirstPreflight) {
|
|
2694
|
+
for (const { rel, existsInMain } of changed) {
|
|
2695
|
+
if (!existsInMain)
|
|
2696
|
+
continue;
|
|
2697
|
+
const mainPath = path12.join(mainHalo, rel);
|
|
2698
|
+
const histPath = path12.join(historyDir, rel);
|
|
2699
|
+
fs9.mkdirSync(path12.dirname(histPath), { recursive: true });
|
|
2700
|
+
fs9.copyFileSync(mainPath, histPath);
|
|
2701
|
+
}
|
|
2702
|
+
fs9.writeFileSync(manifestPath, JSON.stringify({
|
|
2703
|
+
applyId: ctx.applyId,
|
|
2704
|
+
sourceRunIds: ctx.sourceRunIds,
|
|
2705
|
+
capturedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2706
|
+
files: changed.map((c) => ({ rel: c.rel, kind: c.existsInMain ? "overwrite" : "new" }))
|
|
2707
|
+
}, null, 2), "utf-8");
|
|
2708
|
+
} else {
|
|
2709
|
+
writeLog(ctx.logFd, `[preflight] MANIFEST.json already exists \u2014 resume path, skipping history backup (the pre-apply state is already preserved from the first preflight)
|
|
2710
|
+
`);
|
|
2711
|
+
}
|
|
2712
|
+
writeLog(ctx.logFd, `[preflight] history snapshot: ${historyDir} (${changed.filter((c) => c.existsInMain).length} backed up, ${changed.filter((c) => !c.existsInMain).length} new)
|
|
2713
|
+
`);
|
|
2714
|
+
return { ok: true, result: { changed, historyDir, fileCount: changed.length } };
|
|
2715
|
+
}
|
|
2716
|
+
async function phaseApplyPublish(ctx, preflight) {
|
|
2717
|
+
writeLog(ctx.logFd, `
|
|
2718
|
+
=== Phase 12 publish: cp sandbox \u2192 main ===
|
|
2719
|
+
`);
|
|
2720
|
+
const mainHalo = wsHaloDir(ctx.workspacePath);
|
|
2721
|
+
for (const { rel, full } of preflight.changed) {
|
|
2722
|
+
const mainPath = path12.join(mainHalo, rel);
|
|
2723
|
+
fs9.mkdirSync(path12.dirname(mainPath), { recursive: true });
|
|
2724
|
+
fs9.copyFileSync(full, mainPath);
|
|
2725
|
+
}
|
|
2726
|
+
writeLog(ctx.logFd, `[publish] copied ${preflight.changed.length} files to ${mainHalo}
|
|
2727
|
+
`);
|
|
2728
|
+
}
|
|
2729
|
+
function markSyncing(applyId, logFd) {
|
|
2730
|
+
getEvoDb().update(evolutionApplies).set({ status: "syncing" }).where(eq(evolutionApplies.id, applyId)).run();
|
|
2731
|
+
writeLog(logFd, `[wrapper] checkpoint: status=syncing (publishing main)
|
|
2732
|
+
`);
|
|
2733
|
+
}
|
|
2734
|
+
function walkInto(absPath, relPath, out) {
|
|
2735
|
+
const stat = fs9.statSync(absPath);
|
|
2736
|
+
if (stat.isFile()) {
|
|
2737
|
+
out.push({ rel: relPath, full: absPath });
|
|
2738
|
+
return;
|
|
2739
|
+
}
|
|
2740
|
+
if (!stat.isDirectory())
|
|
2741
|
+
return;
|
|
2742
|
+
for (const entry of fs9.readdirSync(absPath)) {
|
|
2743
|
+
walkInto(path12.join(absPath, entry), path12.join(relPath, entry), out);
|
|
2744
|
+
}
|
|
2745
|
+
}
|
|
2746
|
+
function finalizeApplied(applyId, logFd) {
|
|
2747
|
+
const now = Date.now();
|
|
2748
|
+
getEvoDb().update(evolutionApplies).set({ status: "applied", completedAt: now }).where(eq(evolutionApplies.id, applyId)).run();
|
|
2749
|
+
writeLog(logFd, `[wrapper] finalize apply: status=applied
|
|
2750
|
+
`);
|
|
2751
|
+
}
|
|
2752
|
+
function writeLog(fd, msg) {
|
|
2753
|
+
try {
|
|
2754
|
+
fs9.writeSync(fd, msg);
|
|
2755
|
+
} catch {
|
|
2756
|
+
}
|
|
2757
|
+
}
|
|
2758
|
+
function openLogFor(mode, id) {
|
|
2759
|
+
fs9.mkdirSync(evoLogsDir(), { recursive: true });
|
|
2760
|
+
const logPath = mode === "run" ? evoWrapperLogFile(id) : evoApplyLogFile(id);
|
|
2761
|
+
const fd = fs9.openSync(logPath, "a");
|
|
2762
|
+
return { fd, logPath };
|
|
2763
|
+
}
|
|
2764
|
+
async function main() {
|
|
2765
|
+
const { mode, id } = parseCli(process.argv.slice(2));
|
|
2766
|
+
setEvoDb(createEvoDb(path12.join(homedir6(), ".halo", "global")));
|
|
2767
|
+
const { fd, logPath } = openLogFor(mode, id);
|
|
2768
|
+
writeLog(fd, `
|
|
2769
|
+
=== ${(/* @__PURE__ */ new Date()).toISOString()} wrapper start mode=${mode} id=${id} pid=${process.pid} ===
|
|
2770
|
+
`);
|
|
2771
|
+
try {
|
|
2772
|
+
if (mode === "run")
|
|
2773
|
+
await runMode(id, fd);
|
|
2774
|
+
else
|
|
2775
|
+
await applyMode(id, fd);
|
|
2776
|
+
writeLog(fd, `[wrapper] done; log: ${logPath}
|
|
2777
|
+
`);
|
|
2778
|
+
process.exit(0);
|
|
2779
|
+
} catch (err) {
|
|
2780
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
2781
|
+
writeLog(fd, `[wrapper] fatal: ${msg}
|
|
2782
|
+
`);
|
|
2783
|
+
try {
|
|
2784
|
+
const now = Date.now();
|
|
2785
|
+
if (mode === "run") {
|
|
2786
|
+
getEvoDb().update(evolutionRuns).set({ status: "failed", failureReason: `wrapper crash: ${msg}`, completedAt: now }).where(eq(evolutionRuns.id, id)).run();
|
|
2787
|
+
}
|
|
2788
|
+
} catch {
|
|
2789
|
+
}
|
|
2790
|
+
process.exit(1);
|
|
2791
|
+
}
|
|
2792
|
+
}
|
|
2793
|
+
if (process.argv[1] && path12.resolve(process.argv[1]) === fileURLToPath2(import.meta.url)) {
|
|
2794
|
+
main().catch((err) => {
|
|
2795
|
+
process.stderr.write(`[wrapper] startup error: ${err instanceof Error ? err.message : String(err)}
|
|
2796
|
+
`);
|
|
2797
|
+
process.exit(1);
|
|
2798
|
+
});
|
|
2799
|
+
}
|
|
2800
|
+
export {
|
|
2801
|
+
PUBLISH_WHITELIST,
|
|
2802
|
+
SANDBOX_WHITELIST,
|
|
2803
|
+
buildEvoSandbox,
|
|
2804
|
+
extractTestScenario,
|
|
2805
|
+
judgeDraft,
|
|
2806
|
+
phaseApplyPreflight,
|
|
2807
|
+
phaseApplyPublish,
|
|
2808
|
+
readPatchFrontmatter,
|
|
2809
|
+
spawnProc
|
|
2810
|
+
};
|