@task0/cli 0.8.0 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +14 -6
- package/dist/main.js +707 -21
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -65,15 +65,19 @@ task0 run <id> # drive the full triage → plan → refine → exec lo
|
|
|
65
65
|
## Run as a service (autostart on login / boot)
|
|
66
66
|
|
|
67
67
|
`task0 daemon register` installs an OS-level autostart service in addition to
|
|
68
|
-
recording the daemon identity.
|
|
69
|
-
|
|
68
|
+
recording the daemon identity. The server URL comes from the active profile's
|
|
69
|
+
`api_url` — set it once per profile, then `register` reads it. After registering
|
|
70
|
+
you can sign out, reboot, and the daemon reconnects on its own.
|
|
70
71
|
|
|
71
72
|
```sh
|
|
72
|
-
#
|
|
73
|
-
task0
|
|
73
|
+
# 1. Tell the active profile which server to talk to (once per profile)
|
|
74
|
+
task0 profile set api_url https://central.example.com:4318
|
|
74
75
|
|
|
75
|
-
#
|
|
76
|
-
|
|
76
|
+
# 2a. user-level (default) — starts at user login, no sudo needed
|
|
77
|
+
task0 daemon register
|
|
78
|
+
|
|
79
|
+
# 2b. system-level — starts at boot, runs without an active login
|
|
80
|
+
sudo -E task0 daemon register --system
|
|
77
81
|
|
|
78
82
|
# pause / resume without forgetting the identity
|
|
79
83
|
task0 daemon stop
|
|
@@ -83,6 +87,10 @@ task0 daemon start
|
|
|
83
87
|
task0 daemon logout
|
|
84
88
|
```
|
|
85
89
|
|
|
90
|
+
To target a different server for one invocation without writing it to the
|
|
91
|
+
profile, export `TASK0_API_URL` — it overrides the profile's `api_url` for
|
|
92
|
+
that shell session.
|
|
93
|
+
|
|
86
94
|
Per platform:
|
|
87
95
|
|
|
88
96
|
| Platform | Scope | File written |
|
package/dist/main.js
CHANGED
|
@@ -207,6 +207,8 @@ function scanProject(projectPath, sourceName) {
|
|
|
207
207
|
const summary = raw.summary || void 0;
|
|
208
208
|
const displayTitle = summary?.title || title;
|
|
209
209
|
const displayTags = summary?.tags ?? tags;
|
|
210
|
+
const agentRuns = Array.isArray(raw.agent_runs) ? raw.agent_runs.filter((v) => typeof v === "string") : void 0;
|
|
211
|
+
const runtimes = Array.isArray(raw.runtimes) ? raw.runtimes.filter((v) => typeof v === "string") : void 0;
|
|
210
212
|
tasks.push({
|
|
211
213
|
id,
|
|
212
214
|
object_id: objectId,
|
|
@@ -227,6 +229,8 @@ function scanProject(projectPath, sourceName) {
|
|
|
227
229
|
workflow: raw.workflow || void 0,
|
|
228
230
|
summary,
|
|
229
231
|
comments: raw.comments || void 0,
|
|
232
|
+
agent_runs: agentRuns,
|
|
233
|
+
runtimes,
|
|
230
234
|
display_title: displayTitle,
|
|
231
235
|
display_tags: displayTags
|
|
232
236
|
});
|
|
@@ -1306,6 +1310,9 @@ function profileExists(name) {
|
|
|
1306
1310
|
}
|
|
1307
1311
|
}
|
|
1308
1312
|
var activeProfileCache = null;
|
|
1313
|
+
function getActiveProfile() {
|
|
1314
|
+
return activeProfileCache;
|
|
1315
|
+
}
|
|
1309
1316
|
function activateProfile(argv) {
|
|
1310
1317
|
ensureDefaultProfile();
|
|
1311
1318
|
const flagValue = parseProfileFlag(argv);
|
|
@@ -1329,9 +1336,14 @@ function activateProfile(argv) {
|
|
|
1329
1336
|
}
|
|
1330
1337
|
}
|
|
1331
1338
|
process.env.TASK0_PROFILE = name;
|
|
1339
|
+
const envApiUrlOverride = process.env.TASK0_API_URL?.trim() || void 0;
|
|
1332
1340
|
const apiUrl = getApiUrl();
|
|
1333
|
-
activeProfileCache = {
|
|
1334
|
-
|
|
1341
|
+
activeProfileCache = {
|
|
1342
|
+
name,
|
|
1343
|
+
...apiUrl ? { apiUrl } : {},
|
|
1344
|
+
...envApiUrlOverride ? { envApiUrlOverride } : {}
|
|
1345
|
+
};
|
|
1346
|
+
if (apiUrl && !envApiUrlOverride) {
|
|
1335
1347
|
process.env.TASK0_API_URL = apiUrl;
|
|
1336
1348
|
}
|
|
1337
1349
|
}
|
|
@@ -1695,7 +1707,7 @@ function requireLocalDaemon() {
|
|
|
1695
1707
|
const id = localDaemonId();
|
|
1696
1708
|
if (!id) {
|
|
1697
1709
|
console.error(chalk.red("This host is not registered as a daemon."));
|
|
1698
|
-
console.error(chalk.dim("Run `task0 daemon register
|
|
1710
|
+
console.error(chalk.dim("Run `task0 daemon register` first (set api_url with `task0 profile set api_url <url>` if needed)."));
|
|
1699
1711
|
process.exit(1);
|
|
1700
1712
|
}
|
|
1701
1713
|
return id;
|
|
@@ -4809,8 +4821,10 @@ function pickRegisterAuth(flagToken) {
|
|
|
4809
4821
|
|
|
4810
4822
|
// src/core/daemon-rpc-handlers.ts
|
|
4811
4823
|
init_node();
|
|
4824
|
+
import { execSync as execSync3 } from "child_process";
|
|
4812
4825
|
import fs28 from "fs";
|
|
4813
4826
|
import path26 from "path";
|
|
4827
|
+
import yaml10 from "js-yaml";
|
|
4814
4828
|
|
|
4815
4829
|
// src/core/daemon-agent-run-runner.ts
|
|
4816
4830
|
import { execFileSync } from "child_process";
|
|
@@ -5032,6 +5046,44 @@ function optionalBoolean(value) {
|
|
|
5032
5046
|
function listProjects() {
|
|
5033
5047
|
return loadConfig().sources.filter((source2) => source2.type === "project");
|
|
5034
5048
|
}
|
|
5049
|
+
function resolveTaskDir(params, opts = {}) {
|
|
5050
|
+
const projectName = ensureString(params.projectName, "projectName");
|
|
5051
|
+
const taskId = ensureSafeTaskId(params.taskId);
|
|
5052
|
+
const project2 = listProjects().find((p) => p.name === projectName);
|
|
5053
|
+
if (!project2) {
|
|
5054
|
+
throw Object.assign(new Error(`project not found: ${projectName}`), { code: "not_found" });
|
|
5055
|
+
}
|
|
5056
|
+
const projectAbs = path26.resolve(project2.path);
|
|
5057
|
+
const projectConfig = readYaml(path26.join(projectAbs, "task0.yml"));
|
|
5058
|
+
if (!projectConfig) {
|
|
5059
|
+
throw Object.assign(
|
|
5060
|
+
new Error(`invalid project: missing task0.yml at ${projectAbs}`),
|
|
5061
|
+
{ code: "invalid_project" }
|
|
5062
|
+
);
|
|
5063
|
+
}
|
|
5064
|
+
const tasksRoot = path26.resolve(projectAbs, projectConfig.tasks_dir);
|
|
5065
|
+
const taskDir = path26.resolve(tasksRoot, taskId);
|
|
5066
|
+
assertContained(tasksRoot, taskDir);
|
|
5067
|
+
const taskYml = path26.join(taskDir, "task0.yml");
|
|
5068
|
+
if (opts.requireExists !== false && !fs28.existsSync(taskDir)) {
|
|
5069
|
+
throw Object.assign(new Error(`task not found: ${taskId}`), { code: "not_found" });
|
|
5070
|
+
}
|
|
5071
|
+
return { projectName, taskId, projectAbs, tasksRoot, taskDir, taskYml };
|
|
5072
|
+
}
|
|
5073
|
+
async function withTaskYaml(ctx, resolved, mutate) {
|
|
5074
|
+
const outcome = await withTaskYamlLock(resolved.taskDir, async () => {
|
|
5075
|
+
const raw = readYaml(resolved.taskYml);
|
|
5076
|
+
if (!raw) {
|
|
5077
|
+
throw Object.assign(new Error(`task yaml missing or unreadable: ${resolved.taskYml}`), { code: "not_found" });
|
|
5078
|
+
}
|
|
5079
|
+
const { yaml: yaml12, result } = await mutate(raw);
|
|
5080
|
+
if (yaml12) writeYaml(resolved.taskYml, yaml12);
|
|
5081
|
+
const objectId = typeof (yaml12 ?? raw).object_id === "string" ? (yaml12 ?? raw).object_id : void 0;
|
|
5082
|
+
return { result, object_id: objectId, mutated: yaml12 !== void 0 };
|
|
5083
|
+
});
|
|
5084
|
+
if (outcome.mutated) ctx.notifyManifestChanged();
|
|
5085
|
+
return { result: outcome.result, object_id: outcome.object_id };
|
|
5086
|
+
}
|
|
5035
5087
|
function applyRepair(r) {
|
|
5036
5088
|
const raw = readYaml(r.taskYml);
|
|
5037
5089
|
if (!raw) return;
|
|
@@ -5158,12 +5210,12 @@ var rpcHandlers = {
|
|
|
5158
5210
|
throw Object.assign(new Error(`task not found: ${taskId}`), { code: "not_found" });
|
|
5159
5211
|
}
|
|
5160
5212
|
const taskYml = path26.join(taskDir, "task0.yml");
|
|
5161
|
-
const
|
|
5162
|
-
if (!
|
|
5213
|
+
const yaml12 = readYaml(taskYml);
|
|
5214
|
+
if (!yaml12) {
|
|
5163
5215
|
throw Object.assign(new Error(`task yaml missing or unreadable: ${taskYml}`), { code: "not_found" });
|
|
5164
5216
|
}
|
|
5165
5217
|
const files = fs28.readdirSync(taskDir).filter((name) => name !== "task0.yml");
|
|
5166
|
-
return { task_dir: taskDir, yaml:
|
|
5218
|
+
return { task_dir: taskDir, yaml: yaml12, files };
|
|
5167
5219
|
},
|
|
5168
5220
|
// Create a task directory + write task0.yml + write any additional named
|
|
5169
5221
|
// files. Hub decides the taskId and yaml content (slug/object_id are
|
|
@@ -5171,8 +5223,8 @@ var rpcHandlers = {
|
|
|
5171
5223
|
async task_create(params, ctx) {
|
|
5172
5224
|
const projectName = ensureString(params.projectName, "projectName");
|
|
5173
5225
|
const taskId = ensureSafeTaskId(params.taskId);
|
|
5174
|
-
const
|
|
5175
|
-
if (!
|
|
5226
|
+
const yaml12 = params.yaml;
|
|
5227
|
+
if (!yaml12 || typeof yaml12 !== "object") {
|
|
5176
5228
|
throw Object.assign(new Error("yaml (object) is required"), { code: "invalid_params" });
|
|
5177
5229
|
}
|
|
5178
5230
|
const files = params.files ?? {};
|
|
@@ -5192,7 +5244,7 @@ var rpcHandlers = {
|
|
|
5192
5244
|
throw Object.assign(new Error(`task already exists: ${taskId}`), { code: "already_exists" });
|
|
5193
5245
|
}
|
|
5194
5246
|
fs28.mkdirSync(taskDir, { recursive: true });
|
|
5195
|
-
writeYaml(path26.join(taskDir, "task0.yml"),
|
|
5247
|
+
writeYaml(path26.join(taskDir, "task0.yml"), yaml12);
|
|
5196
5248
|
for (const [name, content] of Object.entries(files)) {
|
|
5197
5249
|
if (name.includes("/") || name.includes("\\") || name === ".." || name === ".") {
|
|
5198
5250
|
throw Object.assign(new Error(`invalid file name: ${name}`), { code: "invalid_params" });
|
|
@@ -5209,8 +5261,8 @@ var rpcHandlers = {
|
|
|
5209
5261
|
async task_write_yaml(params, ctx) {
|
|
5210
5262
|
const projectName = ensureString(params.projectName, "projectName");
|
|
5211
5263
|
const taskId = ensureSafeTaskId(params.taskId);
|
|
5212
|
-
const
|
|
5213
|
-
if (!
|
|
5264
|
+
const yaml12 = params.yaml;
|
|
5265
|
+
if (!yaml12 || typeof yaml12 !== "object") {
|
|
5214
5266
|
throw Object.assign(new Error("yaml (object) is required"), { code: "invalid_params" });
|
|
5215
5267
|
}
|
|
5216
5268
|
const project2 = listProjects().find((p) => p.name === projectName);
|
|
@@ -5229,7 +5281,7 @@ var rpcHandlers = {
|
|
|
5229
5281
|
if (!fs28.existsSync(taskYml)) {
|
|
5230
5282
|
throw Object.assign(new Error(`task yaml not found: ${taskYml}`), { code: "not_found" });
|
|
5231
5283
|
}
|
|
5232
|
-
writeYaml(taskYml,
|
|
5284
|
+
writeYaml(taskYml, yaml12);
|
|
5233
5285
|
ctx.notifyManifestChanged();
|
|
5234
5286
|
return { task_dir: taskDir };
|
|
5235
5287
|
},
|
|
@@ -5257,6 +5309,596 @@ var rpcHandlers = {
|
|
|
5257
5309
|
ctx.notifyManifestChanged();
|
|
5258
5310
|
return { task_dir: taskDir, deleted: true };
|
|
5259
5311
|
},
|
|
5312
|
+
// Archive a task directory: append it to `<tasks_dir>.tar`, then remove
|
|
5313
|
+
// the active directory. Returns the task's object_id so the hub can emit
|
|
5314
|
+
// a `task.archived` event. After the archive completes, notifyManifestChanged
|
|
5315
|
+
// re-pushes the daemon's manifest so the hub's daemon_tasks cache drops
|
|
5316
|
+
// the archived row.
|
|
5317
|
+
async task_archive(params, ctx) {
|
|
5318
|
+
const projectName = ensureString(params.projectName, "projectName");
|
|
5319
|
+
const taskId = ensureSafeTaskId(params.taskId);
|
|
5320
|
+
const project2 = listProjects().find((p) => p.name === projectName);
|
|
5321
|
+
if (!project2) {
|
|
5322
|
+
throw Object.assign(new Error(`project not found: ${projectName}`), { code: "not_found" });
|
|
5323
|
+
}
|
|
5324
|
+
const projectAbs = path26.resolve(project2.path);
|
|
5325
|
+
const projectConfig = readYaml(path26.join(projectAbs, "task0.yml"));
|
|
5326
|
+
if (!projectConfig) {
|
|
5327
|
+
throw Object.assign(new Error(`invalid project: missing task0.yml at ${projectAbs}`), { code: "invalid_project" });
|
|
5328
|
+
}
|
|
5329
|
+
const tasksRoot = path26.resolve(projectAbs, projectConfig.tasks_dir);
|
|
5330
|
+
const taskDir = path26.resolve(tasksRoot, taskId);
|
|
5331
|
+
assertContained(tasksRoot, taskDir);
|
|
5332
|
+
const tarFile = tasksRoot + ".tar";
|
|
5333
|
+
if (fs28.existsSync(tarFile) && !fs28.statSync(tarFile).isFile()) {
|
|
5334
|
+
throw Object.assign(
|
|
5335
|
+
new Error(`${tarFile} exists but is not a file. Remove it manually first.`),
|
|
5336
|
+
{ code: "invalid_target" }
|
|
5337
|
+
);
|
|
5338
|
+
}
|
|
5339
|
+
if (!fs28.existsSync(taskDir)) {
|
|
5340
|
+
if (fs28.existsSync(tarFile)) {
|
|
5341
|
+
const listing = execSync3(`tar -tf ${JSON.stringify(tarFile)}`).toString();
|
|
5342
|
+
const dirs = new Set(listing.split("\n").map((line) => line.split("/")[0]).filter(Boolean));
|
|
5343
|
+
if (dirs.has(taskId)) {
|
|
5344
|
+
return { task_dir: taskDir, already_archived: true };
|
|
5345
|
+
}
|
|
5346
|
+
}
|
|
5347
|
+
throw Object.assign(new Error(`task directory missing on disk: ${taskId}`), { code: "not_found" });
|
|
5348
|
+
}
|
|
5349
|
+
const taskYaml = readYaml(path26.join(taskDir, "task0.yml"));
|
|
5350
|
+
const objectId = typeof taskYaml?.object_id === "string" ? taskYaml.object_id : void 0;
|
|
5351
|
+
const tarFlag = fs28.existsSync(tarFile) ? "-rf" : "-cf";
|
|
5352
|
+
execSync3(`tar ${tarFlag} ${JSON.stringify(tarFile)} -C ${JSON.stringify(tasksRoot)} ${JSON.stringify(taskId)}`);
|
|
5353
|
+
fs28.rmSync(taskDir, { recursive: true });
|
|
5354
|
+
ctx.notifyManifestChanged();
|
|
5355
|
+
return { task_dir: taskDir, object_id: objectId, already_archived: false };
|
|
5356
|
+
},
|
|
5357
|
+
// Reverse of task_archive: extract `<taskId>` from `<tasks_dir>.tar`, then
|
|
5358
|
+
// rewrite the tar without it (or delete the tar if it becomes empty). Refuses
|
|
5359
|
+
// when an active directory already exists. Returns the extracted task's
|
|
5360
|
+
// object_id (read from the freshly-restored task0.yml) so the hub can emit
|
|
5361
|
+
// a `task.unarchived` event.
|
|
5362
|
+
async task_unarchive(params, ctx) {
|
|
5363
|
+
const resolved = resolveTaskDir(params, { requireExists: false });
|
|
5364
|
+
const { tasksRoot, taskDir, taskId, projectAbs } = resolved;
|
|
5365
|
+
const tarFile = tasksRoot + ".tar";
|
|
5366
|
+
if (!fs28.existsSync(tarFile)) {
|
|
5367
|
+
throw Object.assign(new Error("no archive found"), { code: "not_found" });
|
|
5368
|
+
}
|
|
5369
|
+
if (fs28.existsSync(taskDir)) {
|
|
5370
|
+
throw Object.assign(
|
|
5371
|
+
new Error(`task already exists in active tasks: ${taskId}`),
|
|
5372
|
+
{ code: "already_exists" }
|
|
5373
|
+
);
|
|
5374
|
+
}
|
|
5375
|
+
execSync3(`tar -xf ${JSON.stringify(tarFile)} -C ${JSON.stringify(tasksRoot)} ${JSON.stringify(taskId)}`);
|
|
5376
|
+
const listing = execSync3(`tar -tf ${JSON.stringify(tarFile)}`).toString();
|
|
5377
|
+
const remainingDirs = [
|
|
5378
|
+
...new Set(
|
|
5379
|
+
listing.split("\n").map((line) => line.split("/")[0]).filter((dir) => dir && dir !== taskId)
|
|
5380
|
+
)
|
|
5381
|
+
];
|
|
5382
|
+
if (remainingDirs.length === 0) {
|
|
5383
|
+
fs28.unlinkSync(tarFile);
|
|
5384
|
+
} else {
|
|
5385
|
+
const tmpTar = tarFile + ".tmp";
|
|
5386
|
+
const tmpDir = fs28.mkdtempSync(path26.join(projectAbs, ".task0", ".unarchive-"));
|
|
5387
|
+
try {
|
|
5388
|
+
execSync3(`tar -xf ${JSON.stringify(tarFile)} -C ${JSON.stringify(tmpDir)}`);
|
|
5389
|
+
fs28.rmSync(path26.join(tmpDir, taskId), { recursive: true, force: true });
|
|
5390
|
+
const dirs = fs28.readdirSync(tmpDir).filter((dir) => fs28.statSync(path26.join(tmpDir, dir)).isDirectory());
|
|
5391
|
+
if (dirs.length === 0) {
|
|
5392
|
+
fs28.unlinkSync(tarFile);
|
|
5393
|
+
} else {
|
|
5394
|
+
execSync3(
|
|
5395
|
+
`tar -cf ${JSON.stringify(tmpTar)} -C ${JSON.stringify(tmpDir)} ${dirs.map((dir) => JSON.stringify(dir)).join(" ")}`
|
|
5396
|
+
);
|
|
5397
|
+
fs28.renameSync(tmpTar, tarFile);
|
|
5398
|
+
}
|
|
5399
|
+
} finally {
|
|
5400
|
+
fs28.rmSync(tmpDir, { recursive: true, force: true });
|
|
5401
|
+
}
|
|
5402
|
+
}
|
|
5403
|
+
const taskYaml = readYaml(path26.join(taskDir, "task0.yml"));
|
|
5404
|
+
const objectId = typeof taskYaml?.object_id === "string" ? taskYaml.object_id : void 0;
|
|
5405
|
+
ctx.notifyManifestChanged();
|
|
5406
|
+
return { task_dir: taskDir, object_id: objectId };
|
|
5407
|
+
},
|
|
5408
|
+
// ---------------------------------------------------------------------
|
|
5409
|
+
// Task-yaml field mutations. Each handler is a single read-modify-write
|
|
5410
|
+
// transaction under the task's file lock, then re-pushes the manifest so
|
|
5411
|
+
// the hub's daemon_tasks cache catches up immediately. Hub controllers
|
|
5412
|
+
// call these instead of writing task0.yml directly.
|
|
5413
|
+
// ---------------------------------------------------------------------
|
|
5414
|
+
async task_link_issue(params, ctx) {
|
|
5415
|
+
const resolved = resolveTaskDir(params);
|
|
5416
|
+
const link = params.link;
|
|
5417
|
+
if (!link || typeof link !== "object") {
|
|
5418
|
+
throw Object.assign(new Error("link (object) is required"), { code: "invalid_params" });
|
|
5419
|
+
}
|
|
5420
|
+
const identifier = ensureString(link.identifier, "link.identifier");
|
|
5421
|
+
const { object_id } = await withTaskYaml(ctx, resolved, (raw) => {
|
|
5422
|
+
const linked = Array.isArray(raw.linked_issues) ? raw.linked_issues : [];
|
|
5423
|
+
if (linked.some((issue2) => issue2.identifier === identifier)) {
|
|
5424
|
+
return { result: { linked: false } };
|
|
5425
|
+
}
|
|
5426
|
+
linked.push({ ...link, linked_at: (/* @__PURE__ */ new Date()).toISOString() });
|
|
5427
|
+
raw.linked_issues = linked;
|
|
5428
|
+
return { yaml: raw, result: { linked: true } };
|
|
5429
|
+
});
|
|
5430
|
+
return { object_id, linked: true };
|
|
5431
|
+
},
|
|
5432
|
+
async task_unlink_issue(params, ctx) {
|
|
5433
|
+
const resolved = resolveTaskDir(params);
|
|
5434
|
+
const identifier = ensureString(params.identifier, "identifier");
|
|
5435
|
+
const { object_id } = await withTaskYaml(ctx, resolved, (raw) => {
|
|
5436
|
+
const linked = Array.isArray(raw.linked_issues) ? raw.linked_issues : [];
|
|
5437
|
+
const next = linked.filter((issue2) => issue2.identifier !== identifier);
|
|
5438
|
+
if (next.length === linked.length) {
|
|
5439
|
+
return { result: { removed: false } };
|
|
5440
|
+
}
|
|
5441
|
+
if (next.length === 0) delete raw.linked_issues;
|
|
5442
|
+
else raw.linked_issues = next;
|
|
5443
|
+
return { yaml: raw, result: { removed: true } };
|
|
5444
|
+
});
|
|
5445
|
+
return { object_id, removed: true };
|
|
5446
|
+
},
|
|
5447
|
+
async task_set_description(params, ctx) {
|
|
5448
|
+
const resolved = resolveTaskDir(params);
|
|
5449
|
+
const description2 = typeof params.description === "string" ? params.description : null;
|
|
5450
|
+
if (description2 === null) {
|
|
5451
|
+
throw Object.assign(new Error("description (string) is required"), { code: "invalid_params" });
|
|
5452
|
+
}
|
|
5453
|
+
const { object_id, result } = await withTaskYaml(ctx, resolved, (raw) => {
|
|
5454
|
+
const previous = typeof raw.description === "string" ? raw.description : void 0;
|
|
5455
|
+
if (description2.length === 0) delete raw.description;
|
|
5456
|
+
else raw.description = description2;
|
|
5457
|
+
return { yaml: raw, result: { previous } };
|
|
5458
|
+
});
|
|
5459
|
+
return { object_id, description: description2, previous: result.previous };
|
|
5460
|
+
},
|
|
5461
|
+
async task_set_summary(params, ctx) {
|
|
5462
|
+
const resolved = resolveTaskDir(params);
|
|
5463
|
+
const title = ensureString(params.title, "title");
|
|
5464
|
+
const tagsRaw = params.tags;
|
|
5465
|
+
if (!Array.isArray(tagsRaw) || tagsRaw.some((t) => typeof t !== "string")) {
|
|
5466
|
+
throw Object.assign(new Error("tags (string[]) is required"), { code: "invalid_params" });
|
|
5467
|
+
}
|
|
5468
|
+
const tags = tagsRaw;
|
|
5469
|
+
const { object_id } = await withTaskYaml(ctx, resolved, (raw) => {
|
|
5470
|
+
raw.title = title;
|
|
5471
|
+
raw.tags = [...tags];
|
|
5472
|
+
delete raw.summary;
|
|
5473
|
+
return { yaml: raw, result: null };
|
|
5474
|
+
});
|
|
5475
|
+
return { object_id, title, tags };
|
|
5476
|
+
},
|
|
5477
|
+
async task_set_metadata(params, ctx) {
|
|
5478
|
+
const resolved = resolveTaskDir(params);
|
|
5479
|
+
const patch = params.patch;
|
|
5480
|
+
if (!patch || typeof patch !== "object") {
|
|
5481
|
+
throw Object.assign(new Error("patch (object) is required"), { code: "invalid_params" });
|
|
5482
|
+
}
|
|
5483
|
+
const { object_id, result: metadata } = await withTaskYaml(
|
|
5484
|
+
ctx,
|
|
5485
|
+
resolved,
|
|
5486
|
+
(raw) => {
|
|
5487
|
+
const current = raw.metadata || {};
|
|
5488
|
+
const next = { ...current };
|
|
5489
|
+
for (const [key, value] of Object.entries(patch)) {
|
|
5490
|
+
if (value === null) delete next[key];
|
|
5491
|
+
else if (typeof value === "string") next[key] = value;
|
|
5492
|
+
}
|
|
5493
|
+
if (Object.keys(next).length === 0) delete raw.metadata;
|
|
5494
|
+
else raw.metadata = next;
|
|
5495
|
+
return { yaml: raw, result: next };
|
|
5496
|
+
}
|
|
5497
|
+
);
|
|
5498
|
+
return { object_id, metadata };
|
|
5499
|
+
},
|
|
5500
|
+
async task_add_agent_run(params, ctx) {
|
|
5501
|
+
const resolved = resolveTaskDir(params);
|
|
5502
|
+
const agentRunObjectId = ensureString(params.agentRunObjectId, "agentRunObjectId");
|
|
5503
|
+
const { object_id } = await withTaskYaml(ctx, resolved, (raw) => {
|
|
5504
|
+
if (raw.kind !== "task") return { result: null };
|
|
5505
|
+
const fromCanonical = Array.isArray(raw.agent_runs) ? raw.agent_runs.filter((v) => typeof v === "string") : [];
|
|
5506
|
+
const fromLegacy = Array.isArray(raw.runtimes) ? raw.runtimes.filter((v) => typeof v === "string") : [];
|
|
5507
|
+
const merged = [.../* @__PURE__ */ new Set([...fromCanonical, ...fromLegacy])];
|
|
5508
|
+
if (!merged.includes(agentRunObjectId)) merged.push(agentRunObjectId);
|
|
5509
|
+
raw.agent_runs = merged;
|
|
5510
|
+
delete raw.runtimes;
|
|
5511
|
+
return { yaml: raw, result: null };
|
|
5512
|
+
});
|
|
5513
|
+
return { object_id };
|
|
5514
|
+
},
|
|
5515
|
+
async task_remove_agent_run(params, ctx) {
|
|
5516
|
+
const resolved = resolveTaskDir(params);
|
|
5517
|
+
const agentRunObjectId = ensureString(params.agentRunObjectId, "agentRunObjectId");
|
|
5518
|
+
const { object_id } = await withTaskYaml(ctx, resolved, (raw) => {
|
|
5519
|
+
if (raw.kind !== "task") return { result: null };
|
|
5520
|
+
const fromCanonical = Array.isArray(raw.agent_runs) ? raw.agent_runs.filter((v) => typeof v === "string") : [];
|
|
5521
|
+
const fromLegacy = Array.isArray(raw.runtimes) ? raw.runtimes.filter((v) => typeof v === "string") : [];
|
|
5522
|
+
const merged = [.../* @__PURE__ */ new Set([...fromCanonical, ...fromLegacy])].filter((id) => id !== agentRunObjectId);
|
|
5523
|
+
delete raw.runtimes;
|
|
5524
|
+
if (merged.length === 0) delete raw.agent_runs;
|
|
5525
|
+
else raw.agent_runs = merged;
|
|
5526
|
+
return { yaml: raw, result: null };
|
|
5527
|
+
});
|
|
5528
|
+
return { object_id };
|
|
5529
|
+
},
|
|
5530
|
+
// Append a comment (or a reply to parentCommentId). The hub provides the
|
|
5531
|
+
// pre-generated comment record (id, author, timestamps, body, metadata).
|
|
5532
|
+
// Daemon serializes it into task0.yml.comments under the file lock.
|
|
5533
|
+
async task_add_comment(params, ctx) {
|
|
5534
|
+
const resolved = resolveTaskDir(params);
|
|
5535
|
+
const comment2 = params.comment;
|
|
5536
|
+
if (!comment2 || typeof comment2 !== "object") {
|
|
5537
|
+
throw Object.assign(new Error("comment (object) is required"), { code: "invalid_params" });
|
|
5538
|
+
}
|
|
5539
|
+
const parentCommentId = typeof params.parentCommentId === "string" ? params.parentCommentId : void 0;
|
|
5540
|
+
const dedupeMetadata = params.dedupeMetadata;
|
|
5541
|
+
const { object_id, result } = await withTaskYaml(
|
|
5542
|
+
ctx,
|
|
5543
|
+
resolved,
|
|
5544
|
+
(raw) => {
|
|
5545
|
+
const comments = Array.isArray(raw.comments) ? raw.comments : [];
|
|
5546
|
+
if (dedupeMetadata && typeof dedupeMetadata === "object") {
|
|
5547
|
+
const dedupeEntries = Object.entries(dedupeMetadata);
|
|
5548
|
+
if (dedupeEntries.length > 0) {
|
|
5549
|
+
const matches = (m) => {
|
|
5550
|
+
if (!m) return false;
|
|
5551
|
+
return dedupeEntries.every(([k, v]) => m[k] === v);
|
|
5552
|
+
};
|
|
5553
|
+
const existing = comments.find((c) => {
|
|
5554
|
+
if (matches(c.metadata)) return true;
|
|
5555
|
+
const replies = Array.isArray(c.replies) ? c.replies : [];
|
|
5556
|
+
return replies.some((r) => matches(r.metadata));
|
|
5557
|
+
});
|
|
5558
|
+
if (existing) {
|
|
5559
|
+
const replies = Array.isArray(existing.replies) ? existing.replies : [];
|
|
5560
|
+
const matched = matches(existing.metadata) ? existing : replies.find((r) => matches(r.metadata));
|
|
5561
|
+
return { result: { comment: matched, skipped: true } };
|
|
5562
|
+
}
|
|
5563
|
+
}
|
|
5564
|
+
}
|
|
5565
|
+
if (parentCommentId) {
|
|
5566
|
+
const idx = comments.findIndex((c) => c.id === parentCommentId);
|
|
5567
|
+
if (idx < 0) {
|
|
5568
|
+
throw Object.assign(
|
|
5569
|
+
new Error(`parent comment ${parentCommentId} not found`),
|
|
5570
|
+
{ code: "not_found" }
|
|
5571
|
+
);
|
|
5572
|
+
}
|
|
5573
|
+
const parent = comments[idx];
|
|
5574
|
+
const replies = Array.isArray(parent.replies) ? parent.replies : [];
|
|
5575
|
+
const reply = { ...comment2, parent_comment_id: parent.id };
|
|
5576
|
+
const updated = [...comments];
|
|
5577
|
+
updated[idx] = { ...parent, replies: [...replies, reply] };
|
|
5578
|
+
raw.comments = updated;
|
|
5579
|
+
return { yaml: raw, result: { comment: reply, skipped: false } };
|
|
5580
|
+
}
|
|
5581
|
+
raw.comments = [...comments, comment2];
|
|
5582
|
+
return { yaml: raw, result: { comment: comment2, skipped: false } };
|
|
5583
|
+
}
|
|
5584
|
+
);
|
|
5585
|
+
return { object_id, comment: result.comment, skipped: result.skipped };
|
|
5586
|
+
},
|
|
5587
|
+
async task_update_comment(params, ctx) {
|
|
5588
|
+
const resolved = resolveTaskDir(params);
|
|
5589
|
+
const commentId = ensureString(params.commentId, "commentId");
|
|
5590
|
+
const patch = params.patch;
|
|
5591
|
+
if (!patch || typeof patch !== "object") {
|
|
5592
|
+
throw Object.assign(new Error("patch (object) is required"), { code: "invalid_params" });
|
|
5593
|
+
}
|
|
5594
|
+
const { object_id, result } = await withTaskYaml(
|
|
5595
|
+
ctx,
|
|
5596
|
+
resolved,
|
|
5597
|
+
(raw) => {
|
|
5598
|
+
const comments = Array.isArray(raw.comments) ? raw.comments : [];
|
|
5599
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
5600
|
+
for (let i = 0; i < comments.length; i++) {
|
|
5601
|
+
if (comments[i].id === commentId) {
|
|
5602
|
+
const updated = { ...comments[i], ...patch, updated_at: now };
|
|
5603
|
+
const next = [...comments];
|
|
5604
|
+
next[i] = updated;
|
|
5605
|
+
raw.comments = next;
|
|
5606
|
+
return { yaml: raw, result: { comment: updated } };
|
|
5607
|
+
}
|
|
5608
|
+
const replies = Array.isArray(comments[i].replies) ? comments[i].replies : [];
|
|
5609
|
+
for (let j = 0; j < replies.length; j++) {
|
|
5610
|
+
if (replies[j].id === commentId) {
|
|
5611
|
+
const updatedReply = { ...replies[j], ...patch, updated_at: now };
|
|
5612
|
+
const nextReplies = [...replies];
|
|
5613
|
+
nextReplies[j] = updatedReply;
|
|
5614
|
+
const next = [...comments];
|
|
5615
|
+
next[i] = { ...comments[i], replies: nextReplies };
|
|
5616
|
+
raw.comments = next;
|
|
5617
|
+
return { yaml: raw, result: { comment: updatedReply } };
|
|
5618
|
+
}
|
|
5619
|
+
}
|
|
5620
|
+
}
|
|
5621
|
+
throw Object.assign(new Error(`comment ${commentId} not found`), { code: "not_found" });
|
|
5622
|
+
}
|
|
5623
|
+
);
|
|
5624
|
+
return { object_id, comment: result.comment };
|
|
5625
|
+
},
|
|
5626
|
+
async task_delete_comment(params, ctx) {
|
|
5627
|
+
const resolved = resolveTaskDir(params);
|
|
5628
|
+
const commentId = ensureString(params.commentId, "commentId");
|
|
5629
|
+
const { object_id, result } = await withTaskYaml(
|
|
5630
|
+
ctx,
|
|
5631
|
+
resolved,
|
|
5632
|
+
(raw) => {
|
|
5633
|
+
const comments = Array.isArray(raw.comments) ? raw.comments : [];
|
|
5634
|
+
for (let i = 0; i < comments.length; i++) {
|
|
5635
|
+
if (comments[i].id === commentId) {
|
|
5636
|
+
const deleted = comments[i];
|
|
5637
|
+
const next = comments.filter((_, idx) => idx !== i);
|
|
5638
|
+
if (next.length === 0) delete raw.comments;
|
|
5639
|
+
else raw.comments = next;
|
|
5640
|
+
return { yaml: raw, result: { deleted } };
|
|
5641
|
+
}
|
|
5642
|
+
const replies = Array.isArray(comments[i].replies) ? comments[i].replies : [];
|
|
5643
|
+
for (let j = 0; j < replies.length; j++) {
|
|
5644
|
+
if (replies[j].id === commentId) {
|
|
5645
|
+
const deleted = replies[j];
|
|
5646
|
+
const nextReplies = replies.filter((_, idx) => idx !== j);
|
|
5647
|
+
const next = [...comments];
|
|
5648
|
+
next[i] = { ...comments[i], replies: nextReplies };
|
|
5649
|
+
if (nextReplies.length === 0) delete next[i].replies;
|
|
5650
|
+
raw.comments = next;
|
|
5651
|
+
return { yaml: raw, result: { deleted } };
|
|
5652
|
+
}
|
|
5653
|
+
}
|
|
5654
|
+
}
|
|
5655
|
+
throw Object.assign(new Error(`comment ${commentId} not found`), { code: "not_found" });
|
|
5656
|
+
}
|
|
5657
|
+
);
|
|
5658
|
+
return { object_id, deleted: result.deleted };
|
|
5659
|
+
},
|
|
5660
|
+
// ---------------------------------------------------------------------
|
|
5661
|
+
// Task-directory file ops. task_write_file supports auto_index: { prefix }
|
|
5662
|
+
// for "next IDEA-NN.md" semantics — daemon scans the dir and picks the next
|
|
5663
|
+
// 2-digit number atomically (under taskDir lock) so concurrent saves don't
|
|
5664
|
+
// collide.
|
|
5665
|
+
// ---------------------------------------------------------------------
|
|
5666
|
+
async task_write_file(params, ctx) {
|
|
5667
|
+
const resolved = resolveTaskDir(params);
|
|
5668
|
+
const content = ensureString(params.content, "content");
|
|
5669
|
+
const explicitName = optionalString(params.name);
|
|
5670
|
+
const autoIndex = params.auto_index;
|
|
5671
|
+
if (!explicitName && !autoIndex) {
|
|
5672
|
+
throw Object.assign(new Error("either name or auto_index is required"), { code: "invalid_params" });
|
|
5673
|
+
}
|
|
5674
|
+
return withTaskYamlLock(resolved.taskDir, () => {
|
|
5675
|
+
let fileName;
|
|
5676
|
+
if (explicitName) {
|
|
5677
|
+
if (explicitName.includes("/") || explicitName.includes("\\") || explicitName === "." || explicitName === ".." || explicitName.startsWith(".")) {
|
|
5678
|
+
throw Object.assign(new Error(`invalid file name: ${explicitName}`), { code: "invalid_params" });
|
|
5679
|
+
}
|
|
5680
|
+
fileName = explicitName;
|
|
5681
|
+
} else {
|
|
5682
|
+
const prefix = ensureString(autoIndex.prefix, "auto_index.prefix");
|
|
5683
|
+
const suffix = autoIndex.suffix ?? ".md";
|
|
5684
|
+
const pattern = new RegExp(`^${prefix.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}(\\d+)${suffix.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`);
|
|
5685
|
+
const existing = fs28.readdirSync(resolved.taskDir);
|
|
5686
|
+
const maxIdx = existing.reduce((max, name) => {
|
|
5687
|
+
const m = name.match(pattern);
|
|
5688
|
+
if (!m) return max;
|
|
5689
|
+
const n = parseInt(m[1], 10);
|
|
5690
|
+
return n > max ? n : max;
|
|
5691
|
+
}, 0);
|
|
5692
|
+
const nextIdx = String(maxIdx + 1).padStart(2, "0");
|
|
5693
|
+
fileName = `${prefix}${nextIdx}${suffix}`;
|
|
5694
|
+
}
|
|
5695
|
+
const target = path26.resolve(resolved.taskDir, fileName);
|
|
5696
|
+
assertContained(resolved.taskDir, target);
|
|
5697
|
+
fs28.writeFileSync(target, content, "utf-8");
|
|
5698
|
+
ctx.notifyManifestChanged();
|
|
5699
|
+
return { file: fileName, path: target };
|
|
5700
|
+
});
|
|
5701
|
+
},
|
|
5702
|
+
async task_delete_file(params, ctx) {
|
|
5703
|
+
const resolved = resolveTaskDir(params);
|
|
5704
|
+
const fileName = ensureString(params.name, "name");
|
|
5705
|
+
if (fileName.includes("/") || fileName.includes("\\") || fileName === "." || fileName === ".." || fileName === "task0.yml") {
|
|
5706
|
+
throw Object.assign(new Error(`invalid file name: ${fileName}`), { code: "invalid_params" });
|
|
5707
|
+
}
|
|
5708
|
+
const target = path26.resolve(resolved.taskDir, fileName);
|
|
5709
|
+
assertContained(resolved.taskDir, target);
|
|
5710
|
+
if (!fs28.existsSync(target)) {
|
|
5711
|
+
throw Object.assign(new Error(`file not found: ${fileName}`), { code: "not_found" });
|
|
5712
|
+
}
|
|
5713
|
+
fs28.unlinkSync(target);
|
|
5714
|
+
ctx.notifyManifestChanged();
|
|
5715
|
+
return { deleted: true };
|
|
5716
|
+
},
|
|
5717
|
+
// Build the four-section context blob (yaml subset + IDEA + ISSUE + PLAN)
|
|
5718
|
+
// hub uses for summarize / agent run prompts. Reads files daemon-side so
|
|
5719
|
+
// remote projects work the same as local ones. Sections are truncated to
|
|
5720
|
+
// 4000 chars to keep prompts bounded.
|
|
5721
|
+
async task_build_context(params) {
|
|
5722
|
+
const resolved = resolveTaskDir(params);
|
|
5723
|
+
const SECTION_CHAR_LIMIT = 4e3;
|
|
5724
|
+
function truncate2(text, max) {
|
|
5725
|
+
if (text.length <= max) return text;
|
|
5726
|
+
return text.slice(0, max) + "\n\n[...truncated]";
|
|
5727
|
+
}
|
|
5728
|
+
function latestMatching(pattern) {
|
|
5729
|
+
const matches = fs28.readdirSync(resolved.taskDir).filter((n) => pattern.test(n)).sort();
|
|
5730
|
+
return matches.length === 0 ? null : matches[matches.length - 1];
|
|
5731
|
+
}
|
|
5732
|
+
function readOrEmpty(file) {
|
|
5733
|
+
try {
|
|
5734
|
+
return fs28.readFileSync(file, "utf-8");
|
|
5735
|
+
} catch {
|
|
5736
|
+
return "";
|
|
5737
|
+
}
|
|
5738
|
+
}
|
|
5739
|
+
let ymlSection = "";
|
|
5740
|
+
if (fs28.existsSync(resolved.taskYml)) {
|
|
5741
|
+
const raw = yaml10.load(fs28.readFileSync(resolved.taskYml, "utf-8"));
|
|
5742
|
+
if (raw) {
|
|
5743
|
+
const slim = {};
|
|
5744
|
+
for (const key of ["title", "tags", "status", "current_step", "linked_issues"]) {
|
|
5745
|
+
if (raw[key] !== void 0) slim[key] = raw[key];
|
|
5746
|
+
}
|
|
5747
|
+
ymlSection = yaml10.dump(slim, { lineWidth: 120 }).trim();
|
|
5748
|
+
}
|
|
5749
|
+
}
|
|
5750
|
+
const ideaFile = latestMatching(/^IDEA-\d+\.md$/);
|
|
5751
|
+
const ideaSection = ideaFile ? truncate2(readOrEmpty(path26.join(resolved.taskDir, ideaFile)), SECTION_CHAR_LIMIT) : "";
|
|
5752
|
+
let issueSection = "";
|
|
5753
|
+
const issueOverview = path26.join(resolved.taskDir, "ISSUE.md");
|
|
5754
|
+
if (fs28.existsSync(issueOverview)) {
|
|
5755
|
+
issueSection = truncate2(readOrEmpty(issueOverview), SECTION_CHAR_LIMIT);
|
|
5756
|
+
} else if (fs28.existsSync(resolved.taskDir)) {
|
|
5757
|
+
const details = fs28.readdirSync(resolved.taskDir).filter((n) => /^ISSUE-\d+\.md$/.test(n)).sort();
|
|
5758
|
+
if (details.length > 0) {
|
|
5759
|
+
const concatenated = details.map((name) => `## ${name}
|
|
5760
|
+
${readOrEmpty(path26.join(resolved.taskDir, name))}`).join("\n\n");
|
|
5761
|
+
issueSection = truncate2(concatenated, SECTION_CHAR_LIMIT);
|
|
5762
|
+
}
|
|
5763
|
+
}
|
|
5764
|
+
const refinedPlan = latestMatching(/^PLAN-\d+-refined\.md$/);
|
|
5765
|
+
const anyPlan = refinedPlan ?? latestMatching(/^PLAN-\d+.*\.md$/);
|
|
5766
|
+
const planSection = anyPlan ? truncate2(readOrEmpty(path26.join(resolved.taskDir, anyPlan)), SECTION_CHAR_LIMIT) : "";
|
|
5767
|
+
return { yaml: ymlSection, idea: ideaSection, issue: issueSection, plan: planSection };
|
|
5768
|
+
},
|
|
5769
|
+
// List files (non-yaml) in a task directory with size + mtime. Hub calls
|
|
5770
|
+
// this from GET /api/tasks/:taskId/files so the dashboard can render the
|
|
5771
|
+
// task-detail file list regardless of which daemon hosts the project.
|
|
5772
|
+
async task_list_files(params) {
|
|
5773
|
+
const resolved = resolveTaskDir(params);
|
|
5774
|
+
const entries = fs28.readdirSync(resolved.taskDir, { withFileTypes: true });
|
|
5775
|
+
const files = entries.filter((entry) => entry.isFile()).map((entry) => {
|
|
5776
|
+
const full = path26.join(resolved.taskDir, entry.name);
|
|
5777
|
+
const stat = fs28.statSync(full);
|
|
5778
|
+
return { name: entry.name, size: stat.size, modified: stat.mtime.toISOString(), path: full };
|
|
5779
|
+
}).sort((a, b) => a.name.localeCompare(b.name));
|
|
5780
|
+
return { files };
|
|
5781
|
+
},
|
|
5782
|
+
// List archived tasks (one per top-level directory inside `<tasks_dir>.tar`)
|
|
5783
|
+
// along with a summary derived from each task's archived task0.yml + first
|
|
5784
|
+
// matching markdown file. Hub uses this to populate the archived-tasks view.
|
|
5785
|
+
async archive_list(params) {
|
|
5786
|
+
const projectName = ensureString(params.projectName, "projectName");
|
|
5787
|
+
const project2 = listProjects().find((p) => p.name === projectName);
|
|
5788
|
+
if (!project2) {
|
|
5789
|
+
throw Object.assign(new Error(`project not found: ${projectName}`), { code: "not_found" });
|
|
5790
|
+
}
|
|
5791
|
+
const projectAbs = path26.resolve(project2.path);
|
|
5792
|
+
const projectConfig = readYaml(path26.join(projectAbs, "task0.yml"));
|
|
5793
|
+
if (!projectConfig) {
|
|
5794
|
+
throw Object.assign(
|
|
5795
|
+
new Error(`invalid project: missing task0.yml at ${projectAbs}`),
|
|
5796
|
+
{ code: "invalid_project" }
|
|
5797
|
+
);
|
|
5798
|
+
}
|
|
5799
|
+
const tasksRoot = path26.resolve(projectAbs, projectConfig.tasks_dir);
|
|
5800
|
+
const tarFile = tasksRoot + ".tar";
|
|
5801
|
+
if (!fs28.existsSync(tarFile)) return { tasks: [] };
|
|
5802
|
+
const listing = execSync3(`tar -tf ${JSON.stringify(tarFile)}`).toString();
|
|
5803
|
+
const lines = listing.split("\n");
|
|
5804
|
+
const dirs = [...new Set(lines.map((line) => line.split("/")[0]).filter(Boolean))];
|
|
5805
|
+
const tasks = [];
|
|
5806
|
+
for (const dir of dirs) {
|
|
5807
|
+
try {
|
|
5808
|
+
const ymlContent = execSync3(
|
|
5809
|
+
`tar -xf ${JSON.stringify(tarFile)} -O ${JSON.stringify(dir + "/task0.yml")}`
|
|
5810
|
+
).toString();
|
|
5811
|
+
const raw = yaml10.load(ymlContent);
|
|
5812
|
+
if (!raw || raw.kind !== "task") continue;
|
|
5813
|
+
const mdFiles = lines.filter((line) => line.startsWith(dir + "/") && line.endsWith(".md"));
|
|
5814
|
+
let context = "";
|
|
5815
|
+
if (mdFiles.length > 0) {
|
|
5816
|
+
try {
|
|
5817
|
+
const md = execSync3(
|
|
5818
|
+
`tar -xf ${JSON.stringify(tarFile)} -O ${JSON.stringify(mdFiles[0])}`
|
|
5819
|
+
).toString();
|
|
5820
|
+
context = md.replace(/^---[\s\S]*?---\s*/, "").trim();
|
|
5821
|
+
} catch {
|
|
5822
|
+
}
|
|
5823
|
+
}
|
|
5824
|
+
tasks.push({
|
|
5825
|
+
id: raw.id || dir,
|
|
5826
|
+
object_id: raw.object_id,
|
|
5827
|
+
title: raw.title || dir,
|
|
5828
|
+
status: "archived",
|
|
5829
|
+
created_at: raw.created_at || "",
|
|
5830
|
+
context
|
|
5831
|
+
});
|
|
5832
|
+
} catch {
|
|
5833
|
+
}
|
|
5834
|
+
}
|
|
5835
|
+
return { tasks };
|
|
5836
|
+
},
|
|
5837
|
+
// List files inside a specific archived task. Returns name + size; mtime is
|
|
5838
|
+
// not preserved by tar's default flags so we omit it.
|
|
5839
|
+
async archive_list_task_files(params) {
|
|
5840
|
+
const projectName = ensureString(params.projectName, "projectName");
|
|
5841
|
+
const taskId = ensureSafeTaskId(params.taskId);
|
|
5842
|
+
const project2 = listProjects().find((p) => p.name === projectName);
|
|
5843
|
+
if (!project2) {
|
|
5844
|
+
throw Object.assign(new Error(`project not found: ${projectName}`), { code: "not_found" });
|
|
5845
|
+
}
|
|
5846
|
+
const projectAbs = path26.resolve(project2.path);
|
|
5847
|
+
const projectConfig = readYaml(path26.join(projectAbs, "task0.yml"));
|
|
5848
|
+
if (!projectConfig) {
|
|
5849
|
+
throw Object.assign(
|
|
5850
|
+
new Error(`invalid project: missing task0.yml at ${projectAbs}`),
|
|
5851
|
+
{ code: "invalid_project" }
|
|
5852
|
+
);
|
|
5853
|
+
}
|
|
5854
|
+
const tarFile = path26.resolve(projectAbs, projectConfig.tasks_dir) + ".tar";
|
|
5855
|
+
if (!fs28.existsSync(tarFile)) return { files: [] };
|
|
5856
|
+
const listing = execSync3(`tar -tvf ${JSON.stringify(tarFile)}`).toString();
|
|
5857
|
+
const prefix = taskId + "/";
|
|
5858
|
+
const files = listing.split("\n").filter((line) => {
|
|
5859
|
+
const parts = line.trim().split(/\s+/);
|
|
5860
|
+
const filePath = parts[parts.length - 1];
|
|
5861
|
+
return filePath && filePath.startsWith(prefix) && filePath !== prefix && !filePath.endsWith("/");
|
|
5862
|
+
}).map((line) => {
|
|
5863
|
+
const parts = line.trim().split(/\s+/);
|
|
5864
|
+
const filePath = parts[parts.length - 1];
|
|
5865
|
+
const size = parseInt(parts[2], 10) || 0;
|
|
5866
|
+
const name = filePath.slice(prefix.length);
|
|
5867
|
+
return { name, size, modified: "", path: filePath };
|
|
5868
|
+
}).sort((a, b) => a.name.localeCompare(b.name));
|
|
5869
|
+
return { files };
|
|
5870
|
+
},
|
|
5871
|
+
// Read a specific file (path is relative to a directory inside the tar:
|
|
5872
|
+
// e.g., `<taskId>/task0.yml` or `<taskId>/IDEA-01.md`). Hub uses this for
|
|
5873
|
+
// the archived-task detail view.
|
|
5874
|
+
async archive_read_file(params) {
|
|
5875
|
+
const projectName = ensureString(params.projectName, "projectName");
|
|
5876
|
+
const archivePath = ensureString(params.path, "path");
|
|
5877
|
+
if (archivePath.includes("..") || archivePath.startsWith("/")) {
|
|
5878
|
+
throw Object.assign(new Error(`invalid archive path: ${archivePath}`), { code: "invalid_params" });
|
|
5879
|
+
}
|
|
5880
|
+
const project2 = listProjects().find((p) => p.name === projectName);
|
|
5881
|
+
if (!project2) {
|
|
5882
|
+
throw Object.assign(new Error(`project not found: ${projectName}`), { code: "not_found" });
|
|
5883
|
+
}
|
|
5884
|
+
const projectAbs = path26.resolve(project2.path);
|
|
5885
|
+
const projectConfig = readYaml(path26.join(projectAbs, "task0.yml"));
|
|
5886
|
+
if (!projectConfig) {
|
|
5887
|
+
throw Object.assign(
|
|
5888
|
+
new Error(`invalid project: missing task0.yml at ${projectAbs}`),
|
|
5889
|
+
{ code: "invalid_project" }
|
|
5890
|
+
);
|
|
5891
|
+
}
|
|
5892
|
+
const tasksRoot = path26.resolve(projectAbs, projectConfig.tasks_dir);
|
|
5893
|
+
const tarFile = tasksRoot + ".tar";
|
|
5894
|
+
if (!fs28.existsSync(tarFile)) {
|
|
5895
|
+
throw Object.assign(new Error("no archive found"), { code: "not_found" });
|
|
5896
|
+
}
|
|
5897
|
+
const content = execSync3(
|
|
5898
|
+
`tar -xOf ${JSON.stringify(tarFile)} ${JSON.stringify(archivePath)}`
|
|
5899
|
+
).toString("utf-8");
|
|
5900
|
+
return { content, path: archivePath };
|
|
5901
|
+
},
|
|
5260
5902
|
// ---------------------------------------------------------------------
|
|
5261
5903
|
// Agent run lifecycle (P3.A scaffolding — handlers reject until P3.B
|
|
5262
5904
|
// moves the actual spawn / tmux / supervise logic from hub's
|
|
@@ -5787,10 +6429,20 @@ function fail6(message, code = 1) {
|
|
|
5787
6429
|
function loadRequiredIdentity() {
|
|
5788
6430
|
const identity = readDaemonIdentity();
|
|
5789
6431
|
if (!identity) {
|
|
5790
|
-
fail6(`No daemon identity at ${daemonConfigPath()}. Run \`task0 daemon register
|
|
6432
|
+
fail6(`No daemon identity at ${daemonConfigPath()}. Run \`task0 daemon register\` first (set api_url with \`task0 profile set api_url <url>\` if it isn't already configured).`);
|
|
5791
6433
|
}
|
|
5792
6434
|
return identity;
|
|
5793
6435
|
}
|
|
6436
|
+
function resolveTargetServerUrl() {
|
|
6437
|
+
const url = process.env.TASK0_API_URL?.trim();
|
|
6438
|
+
if (url) return url.replace(/\/$/, "");
|
|
6439
|
+
fail6(
|
|
6440
|
+
`No server URL configured for the active profile.
|
|
6441
|
+
Set it with:
|
|
6442
|
+
task0 profile set api_url <url>
|
|
6443
|
+
\u2026or export TASK0_API_URL for a one-off override.`
|
|
6444
|
+
);
|
|
6445
|
+
}
|
|
5794
6446
|
function serverBase(identity) {
|
|
5795
6447
|
if (identity) return identity.server_url.replace(/\/$/, "");
|
|
5796
6448
|
return (process.env.TASK0_API_URL || "http://127.0.0.1:4318").replace(/\/$/, "");
|
|
@@ -5828,7 +6480,7 @@ function rerunArgv() {
|
|
|
5828
6480
|
return ["task0", ...process.argv.slice(2)].join(" ");
|
|
5829
6481
|
}
|
|
5830
6482
|
var daemonCmd = new Command19("daemon").description("Manage this host as a task0 daemon registered with a central server");
|
|
5831
|
-
daemonCmd.command("register").description("Register this host with
|
|
6483
|
+
daemonCmd.command("register").description("Register this host with the server configured in the active profile, install the autostart service, and start it").option("-n, --name <name>", "Display name for this daemon (defaults to hostname)").option("-t, --token <token>", "API token (apit_...) for registration. Falls back to TASK0_API_TOKEN, then admin token.").option("--force", "Overwrite existing identity if present").option("--system", "Install at the system layer (LaunchDaemons / /etc/systemd/system, requires sudo)").option("--no-install", "Skip installing the autostart service unit").option("--no-start", "Install the service unit but do not start it now").action(async (opts) => {
|
|
5832
6484
|
const scope = opts.system ? "system" : "user";
|
|
5833
6485
|
if (opts.install) {
|
|
5834
6486
|
requireRootIfSystem(scope, rerunArgv());
|
|
@@ -5837,7 +6489,7 @@ daemonCmd.command("register").description("Register this host with a central ser
|
|
|
5837
6489
|
if (existing && !opts.force) {
|
|
5838
6490
|
fail6(`Already registered as ${existing.daemon_id}. Pass --force to re-register.`);
|
|
5839
6491
|
}
|
|
5840
|
-
const base =
|
|
6492
|
+
const base = resolveTargetServerUrl();
|
|
5841
6493
|
let authHeader;
|
|
5842
6494
|
try {
|
|
5843
6495
|
const choice = pickRegisterAuth(opts.token);
|
|
@@ -6665,7 +7317,11 @@ import fs32 from "fs";
|
|
|
6665
7317
|
import path31 from "path";
|
|
6666
7318
|
import { Command as Command23 } from "commander";
|
|
6667
7319
|
import chalk23 from "chalk";
|
|
6668
|
-
import
|
|
7320
|
+
import yaml11 from "js-yaml";
|
|
7321
|
+
var PROFILE_SCALAR_KEYS = ["api_url"];
|
|
7322
|
+
function isProfileScalarKey(key) {
|
|
7323
|
+
return PROFILE_SCALAR_KEYS.includes(key);
|
|
7324
|
+
}
|
|
6669
7325
|
function fail9(msg) {
|
|
6670
7326
|
console.error(chalk23.red(msg));
|
|
6671
7327
|
process.exit(1);
|
|
@@ -6683,7 +7339,7 @@ function readProfileApiUrl(name) {
|
|
|
6683
7339
|
const file = path31.join(profileDir(name), "config.yml");
|
|
6684
7340
|
if (!fs32.existsSync(file)) return void 0;
|
|
6685
7341
|
try {
|
|
6686
|
-
const data =
|
|
7342
|
+
const data = yaml11.load(fs32.readFileSync(file, "utf-8"));
|
|
6687
7343
|
return data?.api_url;
|
|
6688
7344
|
} catch {
|
|
6689
7345
|
return void 0;
|
|
@@ -6767,6 +7423,25 @@ profile2.command("use <name>").description("Set the current profile").action((na
|
|
|
6767
7423
|
profile2.command("current").description('Print the current profile name (always succeeds; defaults to "default")').action(() => {
|
|
6768
7424
|
console.log(currentProfileName());
|
|
6769
7425
|
});
|
|
7426
|
+
profile2.command("set <key> <value>").description(`Set a config field on the active profile. Supported keys: ${PROFILE_SCALAR_KEYS.join(", ")}.`).action((key, value) => {
|
|
7427
|
+
if (!isProfileScalarKey(key)) {
|
|
7428
|
+
fail9(`Unknown key "${key}". Supported keys: ${PROFILE_SCALAR_KEYS.join(", ")}.`);
|
|
7429
|
+
}
|
|
7430
|
+
const config = loadConfig();
|
|
7431
|
+
config[key] = value;
|
|
7432
|
+
saveConfig(config);
|
|
7433
|
+
console.log(chalk23.green(`${currentProfileName()}.${key} = ${value}`));
|
|
7434
|
+
});
|
|
7435
|
+
profile2.command("get <key>").description(`Read a config field from the active profile. Supported keys: ${PROFILE_SCALAR_KEYS.join(", ")}.`).action((key) => {
|
|
7436
|
+
if (!isProfileScalarKey(key)) {
|
|
7437
|
+
fail9(`Unknown key "${key}". Supported keys: ${PROFILE_SCALAR_KEYS.join(", ")}.`);
|
|
7438
|
+
}
|
|
7439
|
+
const value = loadConfig()[key];
|
|
7440
|
+
if (typeof value !== "string" || value.length === 0) {
|
|
7441
|
+
process.exit(1);
|
|
7442
|
+
}
|
|
7443
|
+
console.log(value);
|
|
7444
|
+
});
|
|
6770
7445
|
profile2.command("show [name]").description("Show a profile's configuration and daemon registration").action((name) => {
|
|
6771
7446
|
const target = name ?? currentProfileName();
|
|
6772
7447
|
if (!isValidProfileName(target)) {
|
|
@@ -6779,19 +7454,30 @@ profile2.command("show [name]").description("Show a profile's configuration and
|
|
|
6779
7454
|
const current = currentProfileName();
|
|
6780
7455
|
const apiUrl = readProfileApiUrl(target);
|
|
6781
7456
|
const identity = readDaemonAt(dir);
|
|
7457
|
+
const envOverride = target === current ? getActiveProfile()?.envApiUrlOverride : void 0;
|
|
7458
|
+
const effective = envOverride ?? apiUrl;
|
|
6782
7459
|
console.log(`${chalk23.bold(target)}${current === target ? chalk23.green(" (current)") : ""}`);
|
|
6783
7460
|
console.log(` dir: ${dir}`);
|
|
6784
7461
|
console.log(` config: ${path31.join(dir, "config.yml")}`);
|
|
6785
|
-
|
|
7462
|
+
if (envOverride && apiUrl && envOverride !== apiUrl) {
|
|
7463
|
+
console.log(` api_url: ${envOverride} ${chalk23.dim("(TASK0_API_URL env, overrides profile)")}`);
|
|
7464
|
+
console.log(` ${chalk23.dim(`profile: ${apiUrl}`)}`);
|
|
7465
|
+
} else if (envOverride && !apiUrl) {
|
|
7466
|
+
console.log(` api_url: ${envOverride} ${chalk23.dim("(TASK0_API_URL env; profile config.yml is unset)")}`);
|
|
7467
|
+
} else if (apiUrl) {
|
|
7468
|
+
console.log(` api_url: ${apiUrl} ${chalk23.dim("(profile config.yml)")}`);
|
|
7469
|
+
} else {
|
|
7470
|
+
console.log(` api_url: ${chalk23.dim("(unset \u2014 run `task0 profile set api_url <url>`)")}`);
|
|
7471
|
+
}
|
|
6786
7472
|
if (!identity) {
|
|
6787
7473
|
console.log(chalk23.dim(" daemon.json: (not registered yet)"));
|
|
6788
7474
|
return;
|
|
6789
7475
|
}
|
|
6790
7476
|
console.log(` daemon_id: ${identity.daemon_id ?? chalk23.dim("(missing)")}`);
|
|
6791
7477
|
console.log(` daemon.server_url: ${identity.server_url ?? chalk23.dim("(missing)")}`);
|
|
6792
|
-
if (
|
|
6793
|
-
console.log(chalk23.yellow(` warn:
|
|
6794
|
-
console.log(chalk23.dim(` Re-register the daemon to align: task0 --profile ${target} daemon register --
|
|
7478
|
+
if (effective && identity.server_url && effective !== identity.server_url) {
|
|
7479
|
+
console.log(chalk23.yellow(` warn: effective api_url and daemon.json.server_url disagree.`));
|
|
7480
|
+
console.log(chalk23.dim(` Re-register the daemon to align: task0 --profile ${target} daemon register --force`));
|
|
6795
7481
|
}
|
|
6796
7482
|
});
|
|
6797
7483
|
|