@tea-agent/loop-agent 0.28.2-beta.1 → 0.28.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/AGENTS.md +1 -1
  2. package/CHANGELOG.md +37 -0
  3. package/README.md +11 -1
  4. package/dist/cli/command-definitions.js +2 -1
  5. package/dist/commands/client-recovery.js +111 -8
  6. package/dist/commands/dag-init-hybrid.js +1 -1
  7. package/dist/commands/init-upgrade.js +2479 -0
  8. package/dist/commands/init.js +120 -9
  9. package/dist/governance/manifest-types.js +65 -0
  10. package/dist/shared/operator/capabilities.js +350 -2
  11. package/dist/task/worktree.js +256 -39
  12. package/dist/worker/cli.js +22 -12
  13. package/dist/worker/console/chat/workspace-landing.js +16 -6
  14. package/dist/worker/console/observe-health-match.js +2 -0
  15. package/dist/worker/console/observe-link.js +4 -0
  16. package/dist/worker/console/operator-actions.js +183 -4
  17. package/dist/worker/console/operator-selection.js +13 -0
  18. package/dist/worker/console/static/assets/index-BfRgtLF4.js +29 -0
  19. package/dist/worker/console/static/index.html +1 -1
  20. package/dist/worker/observe/health.js +1 -0
  21. package/dist/worker/observe/night-jobs.js +104 -0
  22. package/dist/worker/observe/routes.js +48 -0
  23. package/dist/worker/observe/static/app.js +3 -0
  24. package/dist/worker/observe/static/constants.js +1 -0
  25. package/dist/worker/observe/static/index.html +47 -0
  26. package/dist/worker/observe/static/router.js +10 -0
  27. package/dist/worker/observe/static/shell-chrome.js +1 -0
  28. package/dist/worker/observe/static/views/night.js +201 -0
  29. package/dist/worker/report/morning-report.js +56 -16
  30. package/dist/worker/run-task/execute-prepared-task.js +153 -0
  31. package/dist/worker/runner/single-task-attempt.js +147 -0
  32. package/dist/worker/scheduler/admission.js +538 -0
  33. package/dist/worker/scheduler/auto-followup.js +99 -0
  34. package/dist/worker/scheduler/cli.js +539 -0
  35. package/dist/worker/scheduler/dispatcher.js +503 -0
  36. package/dist/worker/scheduler/doctor.js +346 -0
  37. package/dist/worker/scheduler/evidence.js +170 -0
  38. package/dist/worker/scheduler/git-base.js +90 -0
  39. package/dist/worker/scheduler/index.js +23 -0
  40. package/dist/worker/scheduler/lease.js +114 -0
  41. package/dist/worker/scheduler/lifecycle.js +348 -0
  42. package/dist/worker/scheduler/lock.js +80 -0
  43. package/dist/worker/scheduler/morning-window.js +161 -0
  44. package/dist/worker/scheduler/night-git-finalizer.js +88 -0
  45. package/dist/worker/scheduler/night-harvest.js +412 -0
  46. package/dist/worker/scheduler/paths.js +84 -0
  47. package/dist/worker/scheduler/prepared-attempt-recovery.js +471 -0
  48. package/dist/worker/scheduler/recovery.js +277 -0
  49. package/dist/worker/scheduler/reservation.js +146 -0
  50. package/dist/worker/scheduler/retry.js +199 -0
  51. package/dist/worker/scheduler/scheduler-loop.js +272 -0
  52. package/dist/worker/scheduler/store.js +275 -0
  53. package/dist/worker/scheduler/traceability.js +54 -0
  54. package/dist/worker/scheduler/trigger.js +258 -0
  55. package/dist/worker/scheduler/types.js +369 -0
  56. package/dist/worker/scheduler/workspace-adapter.js +91 -0
  57. package/dist/workflows/dag/frontend-implementation-contract.js +2 -102
  58. package/docs/architecture/runtime-boundaries.md +9 -0
  59. package/docs/init-surface.manifest.json +9 -2
  60. package/docs/templates/harness.schema.json +107 -0
  61. package/docs/templates/init-managed-agents.md +18 -8
  62. package/harness.json +22 -0
  63. package/package.json +1 -1
  64. package/skills/loop-agent/SKILL.md +28 -36
  65. package/skills/loop-agent/references/command-reference.md +40 -16
  66. package/skills/loop-agent/references/hybrid-dag.md +1 -1
  67. package/dist/worker/console/static/assets/index-CNO7n6qB.js +0 -29
@@ -1,12 +1,14 @@
1
- import { access, mkdir, realpath } from 'node:fs/promises';
2
- import path from 'node:path';
3
- import { spawn } from 'node:child_process';
1
+ import { createHash, randomBytes } from "node:crypto";
2
+ import { access, appendFile, mkdir, readFile, realpath, writeFile, } from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { spawn } from "node:child_process";
5
+ const OWNERSHIP_FILENAME = ".loop-agent-worktree-ownership.json";
4
6
  /**
5
7
  * Resolve the worktree root directory from a harness manifest.
6
8
  * Falls back to `<repoRoot>/.worktrees` when not configured.
7
9
  */
8
10
  export function resolveWorktreeRoot(repoRoot, manifest) {
9
- const relativePath = manifest?.worktree?.rootRelativePath ?? '.worktrees';
11
+ const relativePath = manifest?.worktree?.rootRelativePath ?? ".worktrees";
10
12
  return path.resolve(repoRoot, relativePath);
11
13
  }
12
14
  /**
@@ -37,66 +39,215 @@ export async function createWorktree(repoRoot, manifest, opts) {
37
39
  const resolvedRoot = await resolveRepoRoot(repoRoot);
38
40
  const worktreePath = resolveWorktreePath(resolvedRoot, manifest, opts.taskId);
39
41
  const branch = opts.branch ?? `task/${opts.taskId}`;
40
- const base = opts.base ?? 'HEAD';
42
+ const base = opts.base ?? "HEAD";
41
43
  // Check if worktree path already exists
42
44
  try {
43
45
  await access(worktreePath);
44
46
  throw new Error(`worktree path already exists: ${worktreePath}`);
45
47
  }
46
48
  catch (error) {
47
- if (error.code !== 'ENOENT') {
49
+ if (error.code !== "ENOENT") {
48
50
  throw error;
49
51
  }
50
52
  }
51
53
  // Ensure parent directory exists
52
54
  await mkdir(path.dirname(worktreePath), { recursive: true });
53
55
  // Build git worktree add arguments
54
- const addArgs = ['worktree', 'add', worktreePath];
56
+ const addArgs = ["worktree", "add", worktreePath];
55
57
  // Check if branch already exists; if so, don't pass -b
56
58
  const branchExists = await gitBranchExists(resolvedRoot, branch);
57
59
  if (!branchExists) {
58
- addArgs.push('-b', branch);
60
+ addArgs.push("-b", branch);
59
61
  }
60
62
  addArgs.push(base);
61
63
  await runGit(resolvedRoot, addArgs);
62
64
  return { path: worktreePath, branch, base };
63
65
  }
66
+ /**
67
+ * Idempotent ensure for a schedule-owned worktree.
68
+ * Validates path ↔ branch ↔ baseCommit and ownership token; never reuses an
69
+ * unowned path/branch with the same names.
70
+ */
71
+ export async function ensureOwnedWorktree(repoRoot, options) {
72
+ const resolvedRoot = await resolveRepoRoot(repoRoot);
73
+ const worktreePath = resolveAgainstCanonicalRepoRoot(repoRoot, resolvedRoot, options.worktreePath);
74
+ assertOwnedWorktreePath(resolvedRoot, worktreePath);
75
+ const ownershipPath = path.join(worktreePath, OWNERSHIP_FILENAME);
76
+ const now = options.now ?? new Date();
77
+ let pathExists = false;
78
+ try {
79
+ await access(worktreePath);
80
+ pathExists = true;
81
+ }
82
+ catch (error) {
83
+ if (error.code !== "ENOENT")
84
+ throw error;
85
+ }
86
+ if (pathExists) {
87
+ const ownership = await readOwnershipRecord(ownershipPath);
88
+ if (!ownership) {
89
+ throw new Error(`worktree path exists without ownership record: ${worktreePath}`);
90
+ }
91
+ if (ownership.scheduleId !== options.scheduleId) {
92
+ throw new Error(`worktree ownership schedule mismatch: expected ${options.scheduleId}, found ${ownership.scheduleId}`);
93
+ }
94
+ if (options.ownershipToken &&
95
+ ownership.ownershipToken !== options.ownershipToken) {
96
+ throw new Error(`worktree ownership token mismatch at ${worktreePath}`);
97
+ }
98
+ if (ownership.branch !== options.branch ||
99
+ ownership.baseCommit !== options.baseCommit ||
100
+ ownership.baseBranch !== options.baseBranch) {
101
+ throw new Error(`worktree ownership triple mismatch at ${worktreePath}: expected branch=${options.branch} base=${options.baseBranch}@${options.baseCommit}`);
102
+ }
103
+ const entries = await listWorktrees(resolvedRoot);
104
+ const registered = entries.find((entry) => path.normalize(entry.path) === path.normalize(worktreePath));
105
+ if (!registered) {
106
+ throw new Error(`worktree path exists but is not registered in git worktree list: ${worktreePath}`);
107
+ }
108
+ if (registered.branch && registered.branch !== options.branch) {
109
+ throw new Error(`worktree registry branch mismatch: expected ${options.branch}, found ${registered.branch}`);
110
+ }
111
+ const head = (await runGit(worktreePath, ["rev-parse", "HEAD"])).trim();
112
+ if (head !== options.baseCommit) {
113
+ throw new Error(`worktree HEAD drift at ${worktreePath}: expected ${options.baseCommit}, found ${head}`);
114
+ }
115
+ await ensureOwnershipFileIgnored(worktreePath);
116
+ return {
117
+ path: worktreePath,
118
+ branch: options.branch,
119
+ baseBranch: options.baseBranch,
120
+ baseCommit: options.baseCommit,
121
+ ownershipToken: ownership.ownershipToken,
122
+ created: false,
123
+ reused: true,
124
+ head,
125
+ };
126
+ }
127
+ // Create path: branch must not already exist without our ownership.
128
+ const branchExists = await gitBranchExists(resolvedRoot, options.branch);
129
+ if (branchExists) {
130
+ throw new Error(`branch already exists without owned worktree: ${options.branch}`);
131
+ }
132
+ await mkdir(path.dirname(worktreePath), { recursive: true });
133
+ await runGit(resolvedRoot, [
134
+ "worktree",
135
+ "add",
136
+ worktreePath,
137
+ "-b",
138
+ options.branch,
139
+ options.baseCommit,
140
+ ]);
141
+ const ownershipToken = options.ownershipToken ??
142
+ `sha256:${createHash("sha256")
143
+ .update(`${options.scheduleId}\0${options.branch}\0${options.baseCommit}\0${randomBytes(8).toString("hex")}`)
144
+ .digest("hex")}`;
145
+ const ownership = {
146
+ schemaVersion: 1,
147
+ scheduleId: options.scheduleId,
148
+ branch: options.branch,
149
+ baseBranch: options.baseBranch,
150
+ baseCommit: options.baseCommit,
151
+ ownershipToken,
152
+ createdAt: now.toISOString(),
153
+ worktreePath,
154
+ };
155
+ await writeFile(ownershipPath, `${JSON.stringify(ownership, null, 2)}\n`, "utf-8");
156
+ await ensureOwnershipFileIgnored(worktreePath);
157
+ const head = (await runGit(worktreePath, ["rev-parse", "HEAD"])).trim();
158
+ return {
159
+ path: worktreePath,
160
+ branch: options.branch,
161
+ baseBranch: options.baseBranch,
162
+ baseCommit: options.baseCommit,
163
+ ownershipToken,
164
+ created: true,
165
+ reused: false,
166
+ head,
167
+ };
168
+ }
169
+ export async function readWorktreeOwnership(worktreePath) {
170
+ return readOwnershipRecord(path.join(worktreePath, OWNERSHIP_FILENAME));
171
+ }
172
+ export async function verifyOwnedWorktree(input) {
173
+ const resolvedRoot = await resolveRepoRoot(input.repoRoot);
174
+ const worktreePath = resolveAgainstCanonicalRepoRoot(input.repoRoot, resolvedRoot, input.worktreePath);
175
+ assertOwnedWorktreePath(resolvedRoot, worktreePath);
176
+ const ownership = await readWorktreeOwnership(worktreePath);
177
+ if (!ownership) {
178
+ throw new Error(`missing worktree ownership at ${worktreePath}`);
179
+ }
180
+ if (ownership.scheduleId !== input.scheduleId) {
181
+ throw new Error(`worktree ownership schedule mismatch: expected ${input.scheduleId}, found ${ownership.scheduleId}`);
182
+ }
183
+ if (ownership.ownershipToken !== input.ownershipToken) {
184
+ throw new Error(`worktree ownership token mismatch at ${worktreePath}`);
185
+ }
186
+ if (ownership.branch !== input.branch ||
187
+ ownership.baseCommit !== input.baseCommit) {
188
+ throw new Error(`worktree ownership branch/base mismatch at ${worktreePath}`);
189
+ }
190
+ const entries = await listWorktrees(resolvedRoot);
191
+ const registered = entries.find((entry) => path.normalize(entry.path) === path.normalize(worktreePath));
192
+ if (!registered) {
193
+ throw new Error(`worktree not registered: ${worktreePath}`);
194
+ }
195
+ if (registered.branch && registered.branch !== input.branch) {
196
+ throw new Error(`worktree registry branch mismatch: expected ${input.branch}, found ${registered.branch}`);
197
+ }
198
+ const head = (await runGit(worktreePath, ["rev-parse", "HEAD"])).trim();
199
+ if (head !== input.baseCommit) {
200
+ throw new Error(`worktree HEAD drift at ${worktreePath}: expected ${input.baseCommit}, found ${head}`);
201
+ }
202
+ await ensureOwnershipFileIgnored(worktreePath);
203
+ return { head, ownership };
204
+ }
64
205
  /**
65
206
  * List git worktrees by parsing `git worktree list --porcelain`.
66
207
  */
67
208
  export async function listWorktrees(repoRoot) {
68
209
  const resolvedRoot = await resolveRepoRoot(repoRoot);
69
- const stdout = await runGit(resolvedRoot, ['worktree', 'list', '--porcelain']);
210
+ const stdout = await runGit(resolvedRoot, [
211
+ "worktree",
212
+ "list",
213
+ "--porcelain",
214
+ ]);
70
215
  const entries = [];
71
- const blocks = stdout.split('\n\n').filter((b) => b.trim().length > 0);
216
+ const blocks = stdout.split("\n\n").filter((b) => b.trim().length > 0);
72
217
  for (const block of blocks) {
73
- const lines = block.trim().split('\n');
74
- let entryPath = '';
218
+ const lines = block.trim().split("\n");
219
+ let entryPath = "";
75
220
  let branch;
76
- let head = '';
221
+ let head = "";
77
222
  let bare = false;
78
223
  let locked = false;
79
224
  for (const line of lines) {
80
- if (line.startsWith('worktree ')) {
81
- entryPath = line.slice('worktree '.length);
225
+ if (line.startsWith("worktree ")) {
226
+ entryPath = line.slice("worktree ".length);
82
227
  }
83
- else if (line.startsWith('branch ')) {
84
- const ref = line.slice('branch '.length);
228
+ else if (line.startsWith("branch ")) {
229
+ const ref = line.slice("branch ".length);
85
230
  // Convert refs/heads/<name> to <name>
86
- branch = ref.replace(/^refs\/heads\//, '');
231
+ branch = ref.replace(/^refs\/heads\//, "");
87
232
  }
88
- else if (line.startsWith('HEAD ')) {
89
- head = line.slice('HEAD '.length);
233
+ else if (line.startsWith("HEAD ")) {
234
+ head = line.slice("HEAD ".length);
90
235
  }
91
- else if (line === 'bare') {
236
+ else if (line === "bare") {
92
237
  bare = true;
93
238
  }
94
- else if (line === 'locked') {
239
+ else if (line === "locked") {
95
240
  locked = true;
96
241
  }
97
242
  }
98
243
  if (entryPath) {
99
- entries.push({ path: path.normalize(entryPath), branch, head, bare, locked });
244
+ entries.push({
245
+ path: path.normalize(entryPath),
246
+ branch,
247
+ head,
248
+ bare,
249
+ locked,
250
+ });
100
251
  }
101
252
  }
102
253
  return entries.sort((a, b) => a.path.localeCompare(b.path));
@@ -107,13 +258,16 @@ export async function listWorktrees(repoRoot) {
107
258
  */
108
259
  export async function removeWorktree(repoRoot, manifest, opts) {
109
260
  const resolvedRoot = await resolveRepoRoot(repoRoot);
110
- const worktreePath = opts.path ?? (opts.taskId ? resolveWorktreePath(resolvedRoot, manifest, opts.taskId) : undefined);
261
+ const worktreePath = opts.path ??
262
+ (opts.taskId
263
+ ? resolveWorktreePath(resolvedRoot, manifest, opts.taskId)
264
+ : undefined);
111
265
  if (!worktreePath) {
112
- throw new Error('removeWorktree requires either taskId or path');
266
+ throw new Error("removeWorktree requires either taskId or path");
113
267
  }
114
- const removeArgs = ['worktree', 'remove'];
268
+ const removeArgs = ["worktree", "remove"];
115
269
  if (opts.force) {
116
- removeArgs.push('--force');
270
+ removeArgs.push("--force");
117
271
  }
118
272
  removeArgs.push(worktreePath);
119
273
  await runGit(resolvedRoot, removeArgs);
@@ -121,7 +275,7 @@ export async function removeWorktree(repoRoot, manifest, opts) {
121
275
  if (opts.deleteBranch && opts.taskId) {
122
276
  const branch = `task/${opts.taskId}`;
123
277
  try {
124
- await runGit(resolvedRoot, ['branch', '-d', branch]);
278
+ await runGit(resolvedRoot, ["branch", "-d", branch]);
125
279
  }
126
280
  catch {
127
281
  // Branch may already be deleted or not exist; ignore
@@ -136,21 +290,21 @@ export async function removeWorktree(repoRoot, manifest, opts) {
136
290
  */
137
291
  async function runGit(cwd, args) {
138
292
  return new Promise((resolve, reject) => {
139
- const child = spawn('git', ['-C', cwd, ...args], {
140
- stdio: ['ignore', 'pipe', 'pipe'],
293
+ const child = spawn("git", ["-C", cwd, ...args], {
294
+ stdio: ["ignore", "pipe", "pipe"],
141
295
  });
142
296
  const stdoutChunks = [];
143
297
  const stderrChunks = [];
144
- child.stdout?.on('data', (chunk) => stdoutChunks.push(chunk));
145
- child.stderr?.on('data', (chunk) => stderrChunks.push(chunk));
146
- child.on('error', (err) => {
147
- reject(new Error(`git ${args.join(' ')} spawn error: ${err.message}`));
298
+ child.stdout?.on("data", (chunk) => stdoutChunks.push(chunk));
299
+ child.stderr?.on("data", (chunk) => stderrChunks.push(chunk));
300
+ child.on("error", (err) => {
301
+ reject(new Error(`git ${args.join(" ")} spawn error: ${err.message}`));
148
302
  });
149
- child.on('close', (code) => {
150
- const stdout = Buffer.concat(stdoutChunks).toString('utf-8');
151
- const stderr = Buffer.concat(stderrChunks).toString('utf-8');
303
+ child.on("close", (code) => {
304
+ const stdout = Buffer.concat(stdoutChunks).toString("utf-8");
305
+ const stderr = Buffer.concat(stderrChunks).toString("utf-8");
152
306
  if (code !== 0) {
153
- reject(new Error(`git ${args.join(' ')} failed (exit ${code}): ${stderr.trim()}`));
307
+ reject(new Error(`git ${args.join(" ")} failed (exit ${code}): ${stderr.trim()}`));
154
308
  return;
155
309
  }
156
310
  resolve(stdout);
@@ -162,10 +316,73 @@ async function runGit(cwd, args) {
162
316
  */
163
317
  async function gitBranchExists(cwd, branchName) {
164
318
  try {
165
- const stdout = await runGit(cwd, ['branch', '--list', branchName]);
319
+ const stdout = await runGit(cwd, ["branch", "--list", branchName]);
166
320
  return stdout.trim().length > 0;
167
321
  }
168
322
  catch {
169
323
  return false;
170
324
  }
171
325
  }
326
+ function resolveAgainstCanonicalRepoRoot(providedRepoRoot, resolvedRepoRoot, candidatePath) {
327
+ if (!path.isAbsolute(candidatePath)) {
328
+ return path.resolve(resolvedRepoRoot, candidatePath);
329
+ }
330
+ const relativeToProvided = path.relative(path.resolve(providedRepoRoot), path.resolve(candidatePath));
331
+ if (!relativeToProvided.startsWith("..") &&
332
+ !path.isAbsolute(relativeToProvided)) {
333
+ return path.resolve(resolvedRepoRoot, relativeToProvided);
334
+ }
335
+ return path.resolve(candidatePath);
336
+ }
337
+ function assertOwnedWorktreePath(resolvedRepoRoot, worktreePath) {
338
+ const worktreeRoot = path.resolve(resolvedRepoRoot, ".worktrees");
339
+ const relative = path.relative(worktreeRoot, path.resolve(worktreePath));
340
+ if (relative.length === 0 ||
341
+ relative.startsWith("..") ||
342
+ path.isAbsolute(relative)) {
343
+ throw new Error(`owned worktree path must be a child of ${worktreeRoot}: ${worktreePath}`);
344
+ }
345
+ }
346
+ async function ensureOwnershipFileIgnored(worktreePath) {
347
+ const excludeRef = (await runGit(worktreePath, ["rev-parse", "--git-path", "info/exclude"])).trim();
348
+ const excludePath = path.isAbsolute(excludeRef)
349
+ ? excludeRef
350
+ : path.resolve(worktreePath, excludeRef);
351
+ const ignoreLine = `/${OWNERSHIP_FILENAME}`;
352
+ let existing = "";
353
+ try {
354
+ existing = await readFile(excludePath, "utf-8");
355
+ }
356
+ catch (error) {
357
+ if (error.code !== "ENOENT")
358
+ throw error;
359
+ }
360
+ if (existing
361
+ .split(/\r?\n/)
362
+ .map((line) => line.trim())
363
+ .includes(ignoreLine)) {
364
+ return;
365
+ }
366
+ await mkdir(path.dirname(excludePath), { recursive: true });
367
+ const separator = existing.length > 0 && !existing.endsWith("\n") ? "\n" : "";
368
+ await appendFile(excludePath, `${separator}${ignoreLine}\n`, "utf-8");
369
+ }
370
+ async function readOwnershipRecord(ownershipPath) {
371
+ try {
372
+ const raw = await readFile(ownershipPath, "utf-8");
373
+ const parsed = JSON.parse(raw);
374
+ if (parsed?.schemaVersion !== 1 ||
375
+ typeof parsed.scheduleId !== "string" ||
376
+ typeof parsed.ownershipToken !== "string" ||
377
+ typeof parsed.branch !== "string" ||
378
+ typeof parsed.baseCommit !== "string") {
379
+ return undefined;
380
+ }
381
+ return parsed;
382
+ }
383
+ catch (error) {
384
+ if (error.code === "ENOENT")
385
+ return undefined;
386
+ throw error;
387
+ }
388
+ }
@@ -31,12 +31,13 @@ import { approveFollowUp } from "./follow-up/approve.js";
31
31
  import { prepareFeatureDelivery } from "./delivery/package.js";
32
32
  import { previewFeatureCloseout, renderCloseoutPreview, } from "./closeout/preview.js";
33
33
  import { applyFeatureCloseout } from "./closeout/apply.js";
34
- import { advanceFeature, renderFeatureAdvance, } from "./feature/advance.js";
35
- import { diagnoseFeature, renderFeatureDoctor, } from "./feature/doctor.js";
34
+ import { advanceFeature, renderFeatureAdvance } from "./feature/advance.js";
35
+ import { diagnoseFeature, renderFeatureDoctor } from "./feature/doctor.js";
36
36
  import { FEATURE_SCAFFOLD_TEMPLATES, ScaffoldError, scaffoldFeaturePacket, } from "./feature/scaffold.js";
37
37
  import { projectMonthlyMetrics } from "./metrics/projector.js";
38
38
  import { defaultFeatureEvidencePaths, runFeatureFinalVerification, } from "./delivery/final-verification.js";
39
39
  import { advanceGitCheckpoint } from "./delivery/git-transaction.js";
40
+ import { registerSchedulerCommands } from "./scheduler/cli.js";
40
41
  export function buildAgentWorkerProgram() {
41
42
  const program = new Command();
42
43
  program
@@ -65,6 +66,7 @@ export function buildAgentWorkerProgram() {
65
66
  const pool = program
66
67
  .command("pool")
67
68
  .description("Task Pool state doctor and migration utilities");
69
+ registerSchedulerCommands(program);
68
70
  pool
69
71
  .command("mark-failed")
70
72
  .requiredOption("--repo <repo-root>", "Target repo root")
@@ -285,9 +287,7 @@ export function buildAgentWorkerProgram() {
285
287
  controllerIdentity: client.getIdentity(),
286
288
  controllerExpectation: buildIdentityExpectation(options),
287
289
  evidenceMode: options.evidenceMode,
288
- ...(options.qaEvidence
289
- ? { qaEvidencePath: options.qaEvidence }
290
- : {}),
290
+ ...(options.qaEvidence ? { qaEvidencePath: options.qaEvidence } : {}),
291
291
  ...(options.finalVerification
292
292
  ? { finalVerificationPath: options.finalVerification }
293
293
  : {}),
@@ -299,8 +299,7 @@ export function buildAgentWorkerProgram() {
299
299
  process.stdout.write(options.json
300
300
  ? `${JSON.stringify(result, null, 2)}\n`
301
301
  : renderFeatureAdvance(result));
302
- if (result.status === "failed" ||
303
- result.status === "blocked") {
302
+ if (result.status === "failed" || result.status === "blocked") {
304
303
  process.exitCode = 1;
305
304
  }
306
305
  });
@@ -350,8 +349,7 @@ export function buildAgentWorkerProgram() {
350
349
  feature: {
351
350
  featureId: options.featureId ?? "",
352
351
  title: options.title ?? "",
353
- template: (options.template ??
354
- ""),
352
+ template: (options.template ?? ""),
355
353
  description: options.description,
356
354
  bePaths: options.bePath,
357
355
  fePaths: options.fePath,
@@ -791,18 +789,30 @@ export function buildAgentWorkerProgram() {
791
789
  .command("morning")
792
790
  .requiredOption("--repo <repo-root>", "Target repo root")
793
791
  .option("--batch-run-id <id>", "Filter to one batch run id")
792
+ .option("--window <window>", "Report window: all (Task Pool classic) or night (Night Scheduler)", "all")
793
+ .option("--date <yyyy-mm-dd>", "Local calendar date for --window night (default today)")
794
+ .option("--tz <timezone>", "IANA timezone for --window night", "Asia/Shanghai")
794
795
  .option("--output <path>", "Write markdown report to this path")
795
796
  .description("Render a markdown morning report from Task Pool runs")
796
797
  .action(async (options) => {
797
798
  const repoRoot = path.resolve(options.repo);
799
+ if (options.window !== "all" && options.window !== "night") {
800
+ throw new Error(`--window must be all or night, got ${options.window ?? ""}`);
801
+ }
802
+ const window = options.window;
798
803
  const outputPath = options.output ??
799
- path.join(getTaskPoolRoot(repoRoot), "reports", "morning-report.md");
804
+ path.join(getTaskPoolRoot(repoRoot), "reports", window === "night"
805
+ ? `morning-night-${options.date ?? "latest"}.md`
806
+ : "morning-report.md");
800
807
  await writeMorningReport({
801
808
  repoRoot,
802
809
  ...(options.batchRunId ? { batchRunId: options.batchRunId } : {}),
810
+ window,
811
+ ...(options.date ? { date: options.date } : {}),
812
+ ...(options.tz ? { timezone: options.tz } : {}),
803
813
  outputPath: path.resolve(outputPath),
804
814
  });
805
- process.stdout.write(`${JSON.stringify({ ok: true, outputPath }, null, 2)}\n`);
815
+ process.stdout.write(`${JSON.stringify({ ok: true, outputPath, window }, null, 2)}\n`);
806
816
  });
807
817
  report
808
818
  .command("metrics")
@@ -945,7 +955,7 @@ function isDirectRun() {
945
955
  }
946
956
  if (isDirectRun()) {
947
957
  main().catch((error) => {
948
- console.error(error instanceof Error ? error.message : String(error));
958
+ process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
949
959
  process.exit(1);
950
960
  });
951
961
  }
@@ -10,8 +10,12 @@ function configuredLanding(value) {
10
10
  }
11
11
  function configuredWorkspace(value) {
12
12
  const normalized = value?.trim().toLowerCase();
13
- if (normalized === "chat" || normalized === "tasks" || normalized === "recovery")
13
+ if (normalized === "chat" ||
14
+ normalized === "tasks" ||
15
+ normalized === "recovery" ||
16
+ normalized === "night") {
14
17
  return normalized;
18
+ }
15
19
  return undefined;
16
20
  }
17
21
  function workspaceFromLocation(location) {
@@ -21,14 +25,16 @@ function workspaceFromLocation(location) {
21
25
  const queryValue = configuredLanding(query.get("landing") ?? query.get("workspace"));
22
26
  if (queryValue)
23
27
  return queryValue;
24
- const hash = location.hash.startsWith("#") ? location.hash.slice(1) : location.hash;
28
+ const hash = location.hash.startsWith("#")
29
+ ? location.hash.slice(1)
30
+ : location.hash;
25
31
  return configuredLanding(new URLSearchParams(hash).get("workspace"));
26
32
  }
27
33
  export function resolveDefaultLanding(input) {
28
- return workspaceFromLocation(input.location) ??
34
+ return (workspaceFromLocation(input.location) ??
29
35
  configuredLanding(input.storage?.getItem(DEFAULT_LANDING_STORAGE_KEY)) ??
30
36
  configuredLanding(input.env) ??
31
- "tasks";
37
+ "tasks");
32
38
  }
33
39
  export function buildWorkspaceHref(workspace, params = {}) {
34
40
  const query = new URLSearchParams({ workspace });
@@ -40,9 +46,13 @@ export function buildWorkspaceHref(workspace, params = {}) {
40
46
  }
41
47
  export function parseWorkspaceHref(location) {
42
48
  const query = new URLSearchParams(location.search);
43
- const hash = location.hash.startsWith("#") ? location.hash.slice(1) : location.hash;
49
+ const hash = location.hash.startsWith("#")
50
+ ? location.hash.slice(1)
51
+ : location.hash;
44
52
  const hashQuery = new URLSearchParams(hash);
45
- const workspace = configuredWorkspace(query.get("workspace") ?? query.get("landing") ?? hashQuery.get("workspace"));
53
+ const workspace = configuredWorkspace(query.get("workspace") ??
54
+ query.get("landing") ??
55
+ hashQuery.get("workspace"));
46
56
  if (!workspace)
47
57
  return null;
48
58
  const read = (key) => query.get(key)?.trim() || undefined;
@@ -16,6 +16,8 @@ export function capabilityForObserveTarget(target) {
16
16
  return "workerRun";
17
17
  case "batch":
18
18
  return "batch";
19
+ case "night":
20
+ return "nightJobs";
19
21
  default: {
20
22
  const _exhaustive = target;
21
23
  throw new Error(`capabilityForObserveTarget: unsupported ${JSON.stringify(_exhaustive)}`);
@@ -26,6 +26,10 @@ export function buildObserveDeepLink(baseUrl, target) {
26
26
  return `${base}/#/run/${encodeURIComponent(target.workerRunId)}`;
27
27
  case "batch":
28
28
  return `${base}/#/batch/${encodeURIComponent(target.batchRunId)}`;
29
+ case "night":
30
+ return target.scheduleId
31
+ ? `${base}/#/night/${encodeURIComponent(target.scheduleId)}`
32
+ : `${base}/#/night`;
29
33
  default: {
30
34
  const _exhaustive = target;
31
35
  throw new Error(`buildObserveDeepLink: unsupported target ${JSON.stringify(_exhaustive)}`);