helloloop 0.8.6 → 0.9.1

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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "helloloop",
3
- "version": "0.8.6",
3
+ "version": "0.9.1",
4
4
  "description": "HelloLoop 的 Claude Code 原生插件元数据,用于多 CLI 宿主分发。",
5
5
  "author": {
6
6
  "name": "HelloLoop"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "helloloop",
3
- "version": "0.8.6",
3
+ "version": "0.9.1",
4
4
  "description": "面向 Codex CLI、Claude Code、Gemini CLI 的多宿主开发工作流插件,Codex 路径为首发与参考实现。",
5
5
  "author": {
6
6
  "name": "HelloLoop"
package/README.md CHANGED
@@ -102,6 +102,7 @@ npx helloloop gemini <PATH> 接续完成剩余开发
102
102
  从当前版本开始,`HelloLoop` 的自动执行主线统一走 **detached supervisor + host lease** 模式:
103
103
 
104
104
  - `analyze` 确认后的自动执行、`run-once`、`run-loop` 都默认切到后台 supervisor
105
+ - 交互终端里默认会**自动附着观察**这个后台 supervisor:你仍然能在当前 CLI 里看到实时进度与流式输出
105
106
  - 当前对话 turn 就算被误按 `Esc`、被宿主暂停、或当前工具调用被中断,后台 supervisor 仍会继续
106
107
  - 原有的 15 分钟级恢复、健康探测、同引擎自动恢复,也会继续由这个后台 supervisor 接管
107
108
  - 这不是“当前进程死掉后每 15 分钟重新拉起一遍主进程”,而是 supervisor 本身持续存活,所以恢复链不会因为当前 turn 消失而断掉
@@ -109,9 +110,11 @@ npx helloloop gemini <PATH> 接续完成剩余开发
109
110
 
110
111
  常见场景:
111
112
 
112
- - 在 `Codex` / `Claude` / `Gemini` 宿主里运行 `helloloop`:确认后默认转入后台执行,可用 `helloloop status` 查看进度
113
- - 在普通终端里运行 `npx helloloop`、`npx helloloop run-once`、`npx helloloop run-loop`:默认也会转入后台执行
114
- - `--supervised` 仍然保留,但现在只是兼容参数,不再是开启后台 supervisor 的前提
113
+ - 在 `Codex` / `Claude` / `Gemini` 宿主里运行 `helloloop`:确认后默认后台执行,并尽量保持当前 CLI 可观察
114
+ - 在普通终端里运行 `npx helloloop`、`npx helloloop run-once`、`npx helloloop run-loop`:默认也是“后台执行 + 当前终端附着观察”
115
+ - 如果你就是想让命令立刻返回、不占当前终端:显式加 `--detach`
116
+ - 如果你稍后想重新接上实时观察:运行 `helloloop watch` 或 `helloloop status --watch`
117
+ - 因此当前版本**不需要先上 Web 看板** 才能解决“后台执行看不到过程”的问题;CLI 观察链已经是第一优先级
115
118
 
116
119
  ## 无人值守恢复
117
120
 
@@ -374,7 +377,7 @@ npx helloloop install --host all --force
374
377
  - `Codex` 会刷新 home 根下的插件源码目录、已安装缓存、`config.toml` 启用项和 marketplace 条目
375
378
  - `Claude` 会刷新 marketplace、缓存插件目录,以及 `settings.json` / `known_marketplaces.json` / `installed_plugins.json` 中的 `helloloop` 条目
376
379
  - `Gemini` 会刷新 `extensions/helloloop/`,不会动同目录下其他扩展
377
- - 安装 / 升级 / 重装时,会同步校准 `~/.helloloop/settings.json` 的当前版本结构:补齐缺失项、清理未知项、保留已知项现有值
380
+ - 安装 / 升级 / 重装时,会把 `~/.helloloop/settings.json` 严格收敛到当前版本 schema:补齐缺失项、清理未知项、把非法值重置为当前版本默认值
378
381
  - 如果 `~/.helloloop/settings.json` 被确认不是合法 JSON,会先备份原文件,再按当前版本结构重建
379
382
  - 如果只是首次读取时出现瞬时异常,但重读后内容合法,则不会误生成备份文件
380
383
  - 如果宿主自己的配置 JSON(如 `Codex marketplace.json`、`Claude settings.json`、`known_marketplaces.json`、`installed_plugins.json`)本身已损坏,`HelloLoop` 会先明确报错并停止,不会先清理现有安装再失败
@@ -443,6 +446,7 @@ npx helloloop
443
446
  | `doctor` | 检查宿主环境、插件资产与目标仓库状态 |
444
447
  | `init` | 手动初始化 `.helloloop/` 模板 |
445
448
  | `status` | 查看 backlog 摘要和当前状态 |
449
+ | `watch` | 重新附着后台 supervisor,持续查看实时进度 |
446
450
  | `next` | 生成下一任务的干跑预览 |
447
451
  | `run-once` | 执行一个任务 |
448
452
  | `run-loop` | 连续执行多个任务 |
@@ -456,7 +460,8 @@ npx helloloop
456
460
  | `-y` / `--yes` | 跳过执行确认直接开始;但如果未显式指定引擎,会直接报错而不是自动选引擎 |
457
461
  | `--repo <dir>` | 高级覆盖:显式指定项目仓库 |
458
462
  | `--docs <dir|file>` | 高级覆盖:显式指定开发文档 |
459
- | `--supervised` | 兼容保留;当前版本默认已启用 detached supervisor |
463
+ | `--watch` | 启动后台 supervisor 后,当前终端继续附着观察实时输出 |
464
+ | `--detach` | 仅启动后台 supervisor,立即返回,不进入观察模式 |
460
465
  | `--rebuild-existing` | 项目与文档冲突时,自动清理现有项目后重建 |
461
466
  | `--host <name>` | 安装宿主:`codex` / `claude` / `gemini` / `all` |
462
467
  | `--config-dir <dir>` | 状态目录名,默认 `.helloloop` |
@@ -465,8 +470,11 @@ npx helloloop
465
470
 
466
471
  ```bash
467
472
  npx helloloop status
473
+ npx helloloop status --watch
474
+ npx helloloop watch
468
475
  npx helloloop next
469
476
  npx helloloop run-once
477
+ npx helloloop run-loop --detach
470
478
  ```
471
479
 
472
480
  ## Doctor
@@ -527,7 +535,7 @@ git push origin vX.Y.Z-beta.N
527
535
 
528
536
  - 正式版本使用 npm `latest` 渠道,beta 版本使用 npm `beta` 渠道
529
537
  - 如果测试、版本校验或打包检查失败,npm 发布与 GitHub Release 都不会继续执行
530
- - `0.8.6` 起已统一为全流程后台 supervisor:`analyze` 确认后的自动执行、`run-once`、`run-loop` 默认都后台化,不再要求普通终端显式追加 `--supervised`
538
+ - `0.9.1` 起按破坏性升级思路继续收紧:后台 supervisor + CLI 附着观察成为默认工作流,同时 `settings.json` Codex 安装布局只接受当前版本规范,不再为旧布局做兼容兜底
531
539
  - GitHub Release 阶段现已改为使用官方 `gh` CLI + `generate-notes` API 创建 / 更新 release,不再依赖会触发 Node runtime deprecation warning 的第三方 action
532
540
 
533
541
  ## 宿主写入范围
@@ -567,7 +575,7 @@ git push origin vX.Y.Z-beta.N
567
575
  说明:
568
576
 
569
577
  - 这里不保存项目 backlog、状态、运行记录
570
- - 安装 / 升级 / 重装时,会对 `settings.json` 做结构校准,但不会校验或篡改你已存在的已知项内容
578
+ - 安装 / 升级 / 重装时,会对 `settings.json` 做严格 schema 校准;已知字段只要值不合法,也会按当前版本默认值重置
571
579
  - 只有在 `settings.json` 被确认非法时,才会先备份,再重建为当前版本结构
572
580
  - 如果只是读取瞬时异常、重读后合法,不会误生成 `.bak`
573
581
 
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "helloloop",
3
- "version": "0.8.6",
3
+ "version": "0.9.1",
4
4
  "description": "HelloLoop 的 Claude Code 原生插件元数据,用于多 CLI 宿主分发。",
5
5
  "author": {
6
6
  "name": "HelloLoop"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "helloloop",
3
- "version": "0.8.6",
3
+ "version": "0.9.1",
4
4
  "description": "HelloLoop 的 Gemini CLI 原生扩展,用于按开发文档接续推进项目开发。",
5
5
  "contextFileName": "GEMINI.md",
6
6
  "excludeTools": [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "helloloop",
3
- "version": "0.8.6",
3
+ "version": "0.9.1",
4
4
  "description": "面向 Codex CLI、Claude Code、Gemini CLI 的多宿主开发工作流插件",
5
5
  "author": "HelloLoop",
6
6
  "license": "Apache-2.0",
@@ -30,7 +30,7 @@
30
30
  "templates"
31
31
  ],
32
32
  "scripts": {
33
- "test": "node --test tests/analyze_cli.test.mjs tests/analyze_cli_path_resolution.test.mjs tests/analyze_intent_cli.test.mjs tests/analyze_runtime_failure.test.mjs tests/output_schema_contract.test.mjs tests/engine_process_support.test.mjs tests/engine_selection_cli.test.mjs tests/cli_surface.test.mjs tests/cli_doctor_surface.test.mjs tests/host_lifecycle_integrity.test.mjs tests/user_settings_lifecycle.test.mjs tests/host_single_host_integrity.test.mjs tests/install_script.test.mjs tests/mainline_continuation.test.mjs tests/multi_host_runtime.test.mjs tests/multi_host_runtime_recovery.test.mjs tests/process_shell.test.mjs tests/prompt_guardrails.test.mjs tests/ralph_loop.test.mjs tests/runtime_recovery.test.mjs tests/plugin_bundle.test.mjs tests/supervisor_runtime.test.mjs tests/terminal_session_limits.test.mjs"
33
+ "test": "node --test tests/analyze_cli.test.mjs tests/analyze_cli_path_resolution.test.mjs tests/analyze_intent_cli.test.mjs tests/analyze_runtime_failure.test.mjs tests/output_schema_contract.test.mjs tests/engine_process_support.test.mjs tests/engine_selection_cli.test.mjs tests/engine_selection_runtime.test.mjs tests/cli_surface.test.mjs tests/cli_doctor_surface.test.mjs tests/host_lifecycle_integrity.test.mjs tests/user_settings_lifecycle.test.mjs tests/host_single_host_integrity.test.mjs tests/install_script.test.mjs tests/mainline_continuation.test.mjs tests/multi_host_runtime.test.mjs tests/multi_host_runtime_recovery.test.mjs tests/process_shell.test.mjs tests/prompt_guardrails.test.mjs tests/ralph_loop.test.mjs tests/runtime_recovery.test.mjs tests/plugin_bundle.test.mjs tests/supervisor_runtime.test.mjs tests/supervisor_watch.test.mjs tests/terminal_session_limits.test.mjs"
34
34
  },
35
35
  "engines": {
36
36
  "node": ">=20"
package/src/cli.mjs CHANGED
@@ -10,6 +10,7 @@ import {
10
10
  handleRunOnceCommand,
11
11
  handleStatusCommand,
12
12
  handleUninstallCommand,
13
+ handleWatchCommand,
13
14
  } from "./cli_command_handlers.mjs";
14
15
  import { resolveContextFromOptions, resolveStandardCommandOptions } from "./cli_context.mjs";
15
16
  import { runDoctor } from "./cli_support.mjs";
@@ -66,6 +67,7 @@ export async function runCli(argv) {
66
67
  "run-loop": () => handleRunLoopCommand(context, options),
67
68
  "run-once": () => handleRunOnceCommand(context, options),
68
69
  status: () => handleStatusCommand(context, options),
70
+ watch: () => handleWatchCommand(context, options),
69
71
  };
70
72
  if (!handlers[command]) {
71
73
  throw new Error(`未知命令:${command}`);
@@ -18,10 +18,7 @@ import { resolveEngineSelection } from "./engine_selection.mjs";
18
18
  import { resetRepoForRebuild } from "./rebuild.mjs";
19
19
  import { renderRebuildSummary } from "./cli_render.mjs";
20
20
  import { shouldConfirmRepoRebuild } from "./cli_support.mjs";
21
- import {
22
- launchSupervisedCommand,
23
- renderSupervisorLaunchSummary,
24
- } from "./supervisor_runtime.mjs";
21
+ import { launchAndMaybeWatchSupervisedCommand } from "./supervisor_cli_support.mjs";
25
22
 
26
23
  async function resolveAnalyzeEngineSelection(options) {
27
24
  if (options.engineResolution?.ok) {
@@ -224,16 +221,13 @@ async function maybeRunAutoExecution(result, activeOptions) {
224
221
 
225
222
  console.log("");
226
223
  console.log("开始自动接续执行...");
227
- const session = launchSupervisedCommand(result.context, "run-loop", {
224
+ const payload = await launchAndMaybeWatchSupervisedCommand(result.context, "run-loop", {
228
225
  ...activeOptions,
229
- supervised: false,
230
226
  engineResolution: result.engineResolution?.ok ? result.engineResolution : activeOptions.engineResolution,
231
227
  maxTasks: resolveAutoRunMaxTasks(result.backlog, activeOptions) || undefined,
232
228
  fullAutoMainline: true,
233
229
  });
234
- console.log(renderSupervisorLaunchSummary(session));
235
- console.log("- 已切换为后台执行;可稍后运行 `helloloop status` 查看进度。");
236
- return 0;
230
+ return payload.exitCode || 0;
237
231
  }
238
232
 
239
233
  export async function handleAnalyzeCommand(options) {
package/src/cli_args.mjs CHANGED
@@ -7,6 +7,7 @@ const KNOWN_COMMANDS = new Set([
7
7
  "uninstall",
8
8
  "init",
9
9
  "status",
10
+ "watch",
10
11
  "next",
11
12
  "run-once",
12
13
  "run-loop",
@@ -51,7 +52,10 @@ export function parseArgs(argv) {
51
52
  else if (arg === "--gemini-home") { options.geminiHome = rest[index + 1]; index += 1; }
52
53
  else if (arg === "--config-dir") { options.configDirName = rest[index + 1]; index += 1; }
53
54
  else if (arg === "--session-file") { options.sessionFile = rest[index + 1]; index += 1; }
54
- else if (arg === "--supervised") options.supervised = true;
55
+ else if (arg === "--watch") options.watch = true;
56
+ else if (arg === "--detach") options.detach = true;
57
+ else if (arg === "--session-id") { options.sessionId = rest[index + 1]; index += 1; }
58
+ else if (arg === "--watch-poll-ms") { options.watchPollMs = Number(rest[index + 1]); index += 1; }
55
59
  else if (arg === "--required-doc") { options.requiredDocs.push(rest[index + 1]); index += 1; }
56
60
  else if (arg === "--constraint") { options.constraints.push(rest[index + 1]); index += 1; }
57
61
  else { options.positionalArgs.push(arg); }
@@ -70,6 +74,7 @@ function helpText() {
70
74
  " uninstall 从所选宿主卸载插件并清理注册信息",
71
75
  " init 初始化 .helloloop 配置",
72
76
  " status 查看 backlog 与下一任务",
77
+ " watch 附着到后台 supervisor,持续查看实时进度",
73
78
  " next 生成下一任务干跑预览",
74
79
  " run-once 执行一个任务",
75
80
  " run-loop 连续执行多个任务",
@@ -90,7 +95,9 @@ function helpText() {
90
95
  " --max-tasks <n> run-loop 最多执行 n 个任务",
91
96
  " --max-attempts <n> 每种策略内最多重试 n 次",
92
97
  " --max-strategies <n> 单任务最多切换 n 种策略继续重试",
93
- " --supervised 兼容保留;当前版本默认就会通过独立 supervisor 后台执行",
98
+ " --watch 启动后台 supervisor 后,当前终端继续附着观察实时输出",
99
+ " --detach 仅启动后台 supervisor 后立即返回,不进入观察模式",
100
+ " --session-id <id> status/watch 指定附着的后台会话 ID",
94
101
  " --allow-high-risk 允许执行 medium/high/critical 风险任务",
95
102
  " --rebuild-existing 分析判断当前项目与文档冲突时,自动清理当前项目后按文档重建",
96
103
  " --required-doc <p> 增加一个全局必读文档(AGENTS.md 会被自动忽略)",
@@ -100,6 +107,8 @@ function helpText() {
100
107
  " analyze 默认支持在命令后混合传入引擎、路径和自然语言要求。",
101
108
  " 如果同时检测到多个可用引擎且没有明确指定,会先询问你选择。",
102
109
  " 当前版本默认会把自动执行 / run-once / run-loop 切到后台 supervisor。",
110
+ " 交互终端默认会自动附着观察;如只想立即返回,请显式加 --detach。",
111
+ " 任何时候都可运行 `helloloop watch` 或 `helloloop status --watch` 重新附着观察。",
103
112
  " 示例:npx helloloop claude <DOCS_PATH> <PROJECT_ROOT> 先分析偏差,不要执行",
104
113
  ].join("\n");
105
114
  }
@@ -7,26 +7,10 @@ import { installPluginBundle, uninstallPluginBundle } from "./install.mjs";
7
7
  import { runLoop, runOnce, renderStatusText } from "./runner.mjs";
8
8
  import { renderInstallSummary, renderUninstallSummary } from "./cli_render.mjs";
9
9
  import {
10
- launchSupervisedCommand,
11
- renderSupervisorLaunchSummary,
12
- } from "./supervisor_runtime.mjs";
13
-
14
- function shouldUseSupervisor(options = {}) {
15
- return !options.dryRun
16
- && process.env.HELLOLOOP_SUPERVISOR_ACTIVE !== "1";
17
- }
18
-
19
- async function runSupervised(context, command, options = {}) {
20
- const session = launchSupervisedCommand(context, command, options);
21
- console.log(renderSupervisorLaunchSummary(session));
22
- console.log("- 已切换为后台执行;可稍后运行 `helloloop status` 查看进度。");
23
- return {
24
- detached: true,
25
- exitCode: 0,
26
- ok: true,
27
- session,
28
- };
29
- }
10
+ launchAndMaybeWatchSupervisedCommand,
11
+ shouldUseSupervisor,
12
+ } from "./supervisor_cli_support.mjs";
13
+ import { watchSupervisorSession } from "./supervisor_watch.mjs";
30
14
 
31
15
  export function handleInstallCommand(options) {
32
16
  const userSettings = syncUserSettingsFile({
@@ -79,9 +63,33 @@ export async function handleDoctorCommand(context, options, runDoctor) {
79
63
  return 0;
80
64
  }
81
65
 
82
- export function handleStatusCommand(context, options) {
66
+ export async function handleStatusCommand(context, options) {
83
67
  console.log(renderStatusText(context, options));
84
- return 0;
68
+ if (!options.watch) {
69
+ return 0;
70
+ }
71
+
72
+ const result = await watchSupervisorSession(context, {
73
+ sessionId: options.sessionId,
74
+ pollMs: options.watchPollMs,
75
+ });
76
+ if (result.empty) {
77
+ console.log("当前没有正在运行的后台 supervisor。");
78
+ return 1;
79
+ }
80
+ return result.exitCode || 0;
81
+ }
82
+
83
+ export async function handleWatchCommand(context, options) {
84
+ const result = await watchSupervisorSession(context, {
85
+ sessionId: options.sessionId,
86
+ pollMs: options.watchPollMs,
87
+ });
88
+ if (result.empty) {
89
+ console.log("当前没有正在运行的后台 supervisor。");
90
+ return 1;
91
+ }
92
+ return result.exitCode || 0;
85
93
  }
86
94
 
87
95
  export async function handleNextCommand(context, options) {
@@ -109,7 +117,7 @@ export async function handleNextCommand(context, options) {
109
117
 
110
118
  export async function handleRunOnceCommand(context, options) {
111
119
  if (shouldUseSupervisor(options)) {
112
- const payload = await runSupervised(context, "run-once", options);
120
+ const payload = await launchAndMaybeWatchSupervisedCommand(context, "run-once", options);
113
121
  return payload.exitCode || 0;
114
122
  }
115
123
 
@@ -133,7 +141,7 @@ export async function handleRunOnceCommand(context, options) {
133
141
 
134
142
  export async function handleRunLoopCommand(context, options) {
135
143
  if (shouldUseSupervisor(options)) {
136
- const payload = await runSupervised(context, "run-loop", options);
144
+ const payload = await launchAndMaybeWatchSupervisedCommand(context, "run-loop", options);
137
145
  return payload.exitCode || 0;
138
146
  }
139
147
 
package/src/common.mjs CHANGED
@@ -26,6 +26,11 @@ export function writeText(filePath, content) {
26
26
  fs.writeFileSync(filePath, content, "utf8");
27
27
  }
28
28
 
29
+ export function appendText(filePath, content) {
30
+ ensureDir(path.dirname(filePath));
31
+ fs.appendFileSync(filePath, content, "utf8");
32
+ }
33
+
29
34
  export function writeJson(filePath, value) {
30
35
  writeText(filePath, `${JSON.stringify(value, null, 2)}\n`);
31
36
  }
@@ -64,3 +69,9 @@ export function resolveFrom(rootDir, ...segments) {
64
69
  export function normalizeRelative(rootDir, targetPath) {
65
70
  return path.relative(rootDir, targetPath).replaceAll("\\", "/");
66
71
  }
72
+
73
+ export function sleep(ms) {
74
+ return new Promise((resolve) => {
75
+ setTimeout(resolve, Math.max(0, Number(ms || 0)));
76
+ });
77
+ }
@@ -121,17 +121,21 @@ export function runChild(command, args, options = {}) {
121
121
  emitHeartbeat("running");
122
122
 
123
123
  child.stdout.on("data", (chunk) => {
124
- stdout += chunk.toString();
124
+ const text = chunk.toString();
125
+ stdout += text;
125
126
  stdoutBytes += chunk.length;
126
127
  lastOutputAt = Date.now();
127
128
  stallWarned = false;
129
+ options.onStdout?.(text);
128
130
  emitHeartbeat("running");
129
131
  });
130
132
  child.stderr.on("data", (chunk) => {
131
- stderr += chunk.toString();
133
+ const text = chunk.toString();
134
+ stderr += text;
132
135
  stderrBytes += chunk.length;
133
136
  lastOutputAt = Date.now();
134
137
  stallWarned = false;
138
+ options.onStderr?.(text);
135
139
  emitHeartbeat("running");
136
140
  });
137
141
 
@@ -46,25 +46,34 @@ function defaultUserSettings() {
46
46
  };
47
47
  }
48
48
 
49
- function cloneJsonValue(value) {
50
- return JSON.parse(JSON.stringify(value));
51
- }
52
-
53
49
  function isPlainObject(value) {
54
50
  return Boolean(value) && typeof value === "object" && !Array.isArray(value);
55
51
  }
56
52
 
57
- function syncValueBySchema(schemaValue, currentValue) {
58
- if (!isPlainObject(schemaValue)) {
59
- return currentValue === undefined ? cloneJsonValue(schemaValue) : currentValue;
60
- }
53
+ function normalizeString(value, fallback = "") {
54
+ return typeof value === "string" ? value.trim() : fallback;
55
+ }
61
56
 
62
- const source = isPlainObject(currentValue) ? currentValue : {};
63
- const next = {};
64
- for (const [key, childSchema] of Object.entries(schemaValue)) {
65
- next[key] = syncValueBySchema(childSchema, Object.hasOwn(source, key) ? source[key] : undefined);
57
+ function normalizeBoolean(value, fallback = false) {
58
+ return typeof value === "boolean" ? value : fallback;
59
+ }
60
+
61
+ function normalizePositiveInteger(value, fallback, minimum = 1) {
62
+ const numericValue = Number(value);
63
+ if (!Number.isInteger(numericValue) || numericValue < minimum) {
64
+ return fallback;
66
65
  }
67
- return next;
66
+ return numericValue;
67
+ }
68
+
69
+ function normalizeTerminalConcurrencySettings(settings = {}) {
70
+ const defaults = defaultTerminalConcurrencySettings();
71
+ return {
72
+ enabled: normalizeBoolean(settings?.enabled, defaults.enabled),
73
+ visibleMax: normalizePositiveInteger(settings?.visibleMax, defaults.visibleMax, 0),
74
+ backgroundMax: normalizePositiveInteger(settings?.backgroundMax, defaults.backgroundMax, 0),
75
+ totalMax: normalizePositiveInteger(settings?.totalMax, defaults.totalMax, 0),
76
+ };
68
77
  }
69
78
 
70
79
  function mergeValueBySchema(schemaValue, baseValue, patchValue) {
@@ -87,16 +96,6 @@ function mergeValueBySchema(schemaValue, baseValue, patchValue) {
87
96
  return next;
88
97
  }
89
98
 
90
- export function syncUserSettingsShape(settings = {}) {
91
- return syncValueBySchema(defaultUserSettings(), settings);
92
- }
93
-
94
- function readRawUserSettingsDocument(options = {}) {
95
- const settingsFile = resolveUserSettingsFile(options.userSettingsFile);
96
- const settings = fileExists(settingsFile) ? readJson(settingsFile) : {};
97
- return syncUserSettingsShape(settings);
98
- }
99
-
100
99
  export function resolveUserSettingsHome() {
101
100
  return String(process.env.HELLOLOOP_HOME || "").trim()
102
101
  || path.join(os.homedir(), ".helloloop");
@@ -113,32 +112,49 @@ function normalizeEmailNotificationSettings(emailSettings = {}) {
113
112
  const smtp = emailSettings?.smtp || {};
114
113
 
115
114
  return {
116
- ...defaults,
117
- ...emailSettings,
115
+ enabled: normalizeBoolean(emailSettings?.enabled, defaults.enabled),
118
116
  to: Array.isArray(emailSettings?.to)
119
117
  ? emailSettings.to.map((item) => String(item || "").trim()).filter(Boolean)
120
- : (typeof emailSettings?.to === "string" && emailSettings.to.trim() ? [emailSettings.to.trim()] : []),
118
+ : defaults.to,
119
+ from: normalizeString(emailSettings?.from, defaults.from),
121
120
  smtp: {
122
- ...defaults.smtp,
123
- ...smtp,
121
+ host: normalizeString(smtp?.host, defaults.smtp.host),
122
+ port: normalizePositiveInteger(smtp?.port, defaults.smtp.port),
123
+ secure: normalizeBoolean(smtp?.secure, defaults.smtp.secure),
124
+ starttls: normalizeBoolean(smtp?.starttls, defaults.smtp.starttls),
125
+ username: normalizeString(smtp?.username, defaults.smtp.username),
126
+ usernameEnv: normalizeString(smtp?.usernameEnv, defaults.smtp.usernameEnv),
127
+ password: typeof smtp?.password === "string" ? smtp.password : defaults.smtp.password,
128
+ passwordEnv: normalizeString(smtp?.passwordEnv, defaults.smtp.passwordEnv),
129
+ timeoutSeconds: normalizePositiveInteger(smtp?.timeoutSeconds, defaults.smtp.timeoutSeconds),
130
+ rejectUnauthorized: normalizeBoolean(smtp?.rejectUnauthorized, defaults.smtp.rejectUnauthorized),
124
131
  },
125
132
  };
126
133
  }
127
134
 
128
- export function loadUserSettingsDocument(options = {}) {
129
- const settings = readRawUserSettingsDocument(options);
130
-
135
+ export function syncUserSettingsShape(settings = {}) {
131
136
  return {
132
- ...settings,
133
- defaultEngine: normalizeEngineName(settings?.defaultEngine),
134
- lastSelectedEngine: normalizeEngineName(settings?.lastSelectedEngine),
137
+ defaultEngine: normalizeEngineName(settings?.defaultEngine) || "",
138
+ lastSelectedEngine: normalizeEngineName(settings?.lastSelectedEngine) || "",
135
139
  notifications: {
136
- ...(settings?.notifications || {}),
137
140
  email: normalizeEmailNotificationSettings(settings?.notifications?.email || {}),
138
141
  },
142
+ runtime: {
143
+ terminalConcurrency: normalizeTerminalConcurrencySettings(settings?.runtime?.terminalConcurrency || {}),
144
+ },
139
145
  };
140
146
  }
141
147
 
148
+ function readRawUserSettingsDocument(options = {}) {
149
+ const settingsFile = resolveUserSettingsFile(options.userSettingsFile);
150
+ const settings = fileExists(settingsFile) ? readJson(settingsFile) : {};
151
+ return syncUserSettingsShape(settings);
152
+ }
153
+
154
+ export function loadUserSettingsDocument(options = {}) {
155
+ return readRawUserSettingsDocument(options);
156
+ }
157
+
142
158
  export function loadUserSettings(options = {}) {
143
159
  const settings = loadUserSettingsDocument(options);
144
160
  return {
@@ -133,7 +133,6 @@ export function installCodexHost(bundleRoot, options = {}) {
133
133
  const resolvedLocalRoot = resolveCodexLocalRoot(options.codexHome);
134
134
  const targetPluginsRoot = path.join(resolvedLocalRoot, "plugins");
135
135
  const targetPluginRoot = path.join(targetPluginsRoot, CODEX_PLUGIN_NAME);
136
- const legacyTargetPluginRoot = path.join(resolvedCodexHome, "plugins", CODEX_PLUGIN_NAME);
137
136
  const targetPluginCacheRoot = path.join(
138
137
  resolvedCodexHome,
139
138
  "plugins",
@@ -143,13 +142,9 @@ export function installCodexHost(bundleRoot, options = {}) {
143
142
  );
144
143
  const targetInstalledPluginRoot = path.join(targetPluginCacheRoot, "local");
145
144
  const marketplaceFile = path.join(resolvedLocalRoot, ".agents", "plugins", "marketplace.json");
146
- const legacyMarketplaceFile = path.join(resolvedCodexHome, ".agents", "plugins", "marketplace.json");
147
145
  const configFile = path.join(resolvedCodexHome, "config.toml");
148
146
  const manifestFile = path.join(bundleRoot, ".codex-plugin", "plugin.json");
149
147
  const existingMarketplace = readExistingJsonOrThrow(marketplaceFile, "Codex marketplace 配置");
150
- const existingLegacyMarketplace = legacyMarketplaceFile === marketplaceFile
151
- ? existingMarketplace
152
- : readExistingJsonOrThrow(legacyMarketplaceFile, "Codex legacy marketplace 配置");
153
148
 
154
149
  if (!fileExists(manifestFile)) {
155
150
  throw new Error(`未找到 Codex 插件 manifest:${manifestFile}`);
@@ -159,9 +154,6 @@ export function installCodexHost(bundleRoot, options = {}) {
159
154
  assertPathInside(resolvedCodexHome, targetPluginCacheRoot, "Codex 插件缓存目录");
160
155
  removeTargetIfNeeded(targetPluginRoot, options.force);
161
156
  removeTargetIfNeeded(targetPluginCacheRoot, options.force);
162
- if (legacyTargetPluginRoot !== targetPluginRoot) {
163
- removePathIfExists(legacyTargetPluginRoot);
164
- }
165
157
 
166
158
  ensureDir(targetPluginsRoot);
167
159
  ensureDir(targetPluginRoot);
@@ -173,9 +165,6 @@ export function installCodexHost(bundleRoot, options = {}) {
173
165
 
174
166
  ensureDir(path.dirname(marketplaceFile));
175
167
  updateCodexMarketplace(marketplaceFile, existingMarketplace);
176
- if (legacyMarketplaceFile !== marketplaceFile) {
177
- removeCodexMarketplaceEntry(legacyMarketplaceFile, existingLegacyMarketplace);
178
- }
179
168
  upsertCodexPluginConfig(configFile);
180
169
 
181
170
  return {
@@ -192,7 +181,6 @@ export function uninstallCodexHost(options = {}) {
192
181
  const resolvedCodexHome = resolveHomeDir(options.codexHome, ".codex");
193
182
  const resolvedLocalRoot = resolveCodexLocalRoot(options.codexHome);
194
183
  const targetPluginRoot = path.join(resolvedLocalRoot, "plugins", CODEX_PLUGIN_NAME);
195
- const legacyTargetPluginRoot = path.join(resolvedCodexHome, "plugins", CODEX_PLUGIN_NAME);
196
184
  const targetPluginCacheRoot = path.join(
197
185
  resolvedCodexHome,
198
186
  "plugins",
@@ -201,29 +189,19 @@ export function uninstallCodexHost(options = {}) {
201
189
  CODEX_PLUGIN_NAME,
202
190
  );
203
191
  const marketplaceFile = path.join(resolvedLocalRoot, ".agents", "plugins", "marketplace.json");
204
- const legacyMarketplaceFile = path.join(resolvedCodexHome, ".agents", "plugins", "marketplace.json");
205
192
  const configFile = path.join(resolvedCodexHome, "config.toml");
206
193
  const existingMarketplace = readExistingJsonOrThrow(marketplaceFile, "Codex marketplace 配置");
207
- const existingLegacyMarketplace = legacyMarketplaceFile === marketplaceFile
208
- ? existingMarketplace
209
- : readExistingJsonOrThrow(legacyMarketplaceFile, "Codex legacy marketplace 配置");
210
194
 
211
195
  const removedPlugin = removePathIfExists(targetPluginRoot);
212
- const removedLegacyPlugin = legacyTargetPluginRoot === targetPluginRoot
213
- ? false
214
- : removePathIfExists(legacyTargetPluginRoot);
215
196
  const removedCache = removePathIfExists(targetPluginCacheRoot);
216
197
  const removedMarketplace = removeCodexMarketplaceEntry(marketplaceFile, existingMarketplace);
217
- const removedLegacyMarketplace = legacyMarketplaceFile === marketplaceFile
218
- ? false
219
- : removeCodexMarketplaceEntry(legacyMarketplaceFile, existingLegacyMarketplace);
220
198
  const removedConfig = removeCodexPluginConfig(configFile);
221
199
 
222
200
  return {
223
201
  host: "codex",
224
202
  displayName: "Codex",
225
203
  targetRoot: targetPluginRoot,
226
- removed: removedPlugin || removedLegacyPlugin || removedCache || removedMarketplace || removedLegacyMarketplace || removedConfig,
204
+ removed: removedPlugin || removedCache || removedMarketplace || removedConfig,
227
205
  marketplaceFile,
228
206
  configFile,
229
207
  };
@@ -4,7 +4,7 @@ import { rememberEngineSelection } from "./engine_selection.mjs";
4
4
  import { getEngineDisplayName } from "./engine_metadata.mjs";
5
5
  import { ensureDir, nowIso, writeText } from "./common.mjs";
6
6
  import { isHostLeaseAlive } from "./host_lease.mjs";
7
- import { saveBacklog } from "./config.mjs";
7
+ import { saveBacklog, writeStatus } from "./config.mjs";
8
8
  import { reviewTaskCompletion } from "./completion_review.mjs";
9
9
  import { updateTask } from "./backlog.mjs";
10
10
  import { buildTaskPrompt } from "./prompt.mjs";
@@ -267,6 +267,16 @@ export async function executeSingleTask(context, options = {}) {
267
267
 
268
268
  updateTask(execution.backlog, execution.task.id, { status: "in_progress", startedAt: nowIso() });
269
269
  saveBacklog(context, execution.backlog);
270
+ writeStatus(context, {
271
+ ok: true,
272
+ sessionId: options.supervisorSessionId || "",
273
+ stage: "task-started",
274
+ taskId: execution.task.id,
275
+ taskTitle: execution.task.title,
276
+ runDir: execution.runDir,
277
+ summary: "",
278
+ message: `开始执行任务:${execution.task.title}`,
279
+ });
270
280
 
271
281
  const state = {
272
282
  engineResolution: execution.engineResolution,
@@ -11,6 +11,7 @@ export async function runOnce(context, options = {}) {
11
11
 
12
12
  writeStatus(context, {
13
13
  ok: result.ok,
14
+ sessionId: options.supervisorSessionId || "",
14
15
  stage: result.kind,
15
16
  taskId: result.task?.id || null,
16
17
  taskTitle: result.task?.title || "",
@@ -90,6 +90,9 @@ export function renderStatusText(context, options = {}) {
90
90
  const supervisor = fileExists(context.supervisorStateFile)
91
91
  ? readJson(context.supervisorStateFile)
92
92
  : null;
93
+ const latestStatus = fileExists(context.statusFile)
94
+ ? readJson(context.statusFile)
95
+ : null;
93
96
 
94
97
  return [
95
98
  "HelloLoop 状态",
@@ -108,8 +111,17 @@ export function renderStatusText(context, options = {}) {
108
111
  `后台租约:${renderHostLeaseLabel(supervisor.lease)}`,
109
112
  ]
110
113
  : []),
114
+ ...(latestStatus?.taskTitle
115
+ ? [
116
+ `当前运行任务:${latestStatus.taskTitle}`,
117
+ `当前运行目录:${latestStatus.runDir || "unknown"}`,
118
+ `当前运行阶段:${latestStatus.stage || "unknown"}`,
119
+ ]
120
+ : []),
111
121
  "",
112
122
  nextTask ? "下一任务:" : "下一任务:无",
113
123
  nextTask ? renderTaskSummary(nextTask) : "",
124
+ "",
125
+ "实时观察:helloloop watch",
114
126
  ].filter(Boolean).join("\n");
115
127
  }
@@ -1,7 +1,7 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
3
 
4
- import { nowIso, writeText } from "./common.mjs";
4
+ import { appendText, nowIso, writeText } from "./common.mjs";
5
5
  import { getEngineDisplayName } from "./engine_metadata.mjs";
6
6
  import {
7
7
  buildClaudeArgs,
@@ -165,6 +165,8 @@ export async function runEngineAttempt({
165
165
  }) {
166
166
  const attemptPromptFile = path.join(runDir, `${attemptPrefix}-prompt.md`);
167
167
  const attemptLastMessageFile = path.join(runDir, `${attemptPrefix}-last-message.txt`);
168
+ const attemptStdoutFile = path.join(runDir, `${attemptPrefix}-stdout.log`);
169
+ const attemptStderrFile = path.join(runDir, `${attemptPrefix}-stderr.log`);
168
170
 
169
171
  if (invocation.error) {
170
172
  const result = {
@@ -208,6 +210,8 @@ export async function runEngineAttempt({
208
210
  recoveryCount,
209
211
  recoveryHistory,
210
212
  });
213
+ writeText(attemptStdoutFile, "");
214
+ writeText(attemptStderrFile, "");
211
215
 
212
216
  const result = await runChild(invocation.command, finalArgs, {
213
217
  cwd: context.repoRoot,
@@ -226,6 +230,12 @@ export async function runEngineAttempt({
226
230
  heartbeat: payload,
227
231
  });
228
232
  },
233
+ onStdout(text) {
234
+ appendText(attemptStdoutFile, text);
235
+ },
236
+ onStderr(text) {
237
+ appendText(attemptStderrFile, text);
238
+ },
229
239
  shouldKeepRunning() {
230
240
  return isHostLeaseAlive(hostLease);
231
241
  },
@@ -0,0 +1,48 @@
1
+ import { launchSupervisedCommand, renderSupervisorLaunchSummary } from "./supervisor_runtime.mjs";
2
+ import { watchSupervisorSession } from "./supervisor_watch.mjs";
3
+
4
+ export function shouldUseSupervisor(options = {}) {
5
+ return !options.dryRun
6
+ && process.env.HELLOLOOP_SUPERVISOR_ACTIVE !== "1";
7
+ }
8
+
9
+ export function shouldAutoWatchSupervisor(options = {}) {
10
+ if (options.detach) {
11
+ return false;
12
+ }
13
+ if (options.watch === true) {
14
+ return true;
15
+ }
16
+ if (options.watch === false) {
17
+ return false;
18
+ }
19
+ return Boolean(process.stdout.isTTY);
20
+ }
21
+
22
+ export async function launchAndMaybeWatchSupervisedCommand(context, command, options = {}) {
23
+ const session = launchSupervisedCommand(context, command, options);
24
+ console.log(renderSupervisorLaunchSummary(session));
25
+
26
+ if (!shouldAutoWatchSupervisor(options)) {
27
+ console.log("- 已切换为后台执行;可稍后运行 `helloloop watch` 或 `helloloop status` 查看进度。");
28
+ return {
29
+ detached: true,
30
+ exitCode: 0,
31
+ ok: true,
32
+ session,
33
+ };
34
+ }
35
+
36
+ console.log("- 已进入附着观察模式;按 Ctrl+C 仅退出观察,不会停止后台任务。");
37
+ const watchResult = await watchSupervisorSession(context, {
38
+ sessionId: session.sessionId,
39
+ pollMs: options.watchPollMs,
40
+ });
41
+ return {
42
+ detached: false,
43
+ exitCode: watchResult.exitCode,
44
+ ok: watchResult.ok,
45
+ session,
46
+ watchResult,
47
+ };
48
+ }
@@ -96,7 +96,10 @@ export function launchSupervisedCommand(context, command, options = {}) {
96
96
  repoRoot: context.repoRoot,
97
97
  configDirName: context.configDirName,
98
98
  },
99
- options: toSerializableOptions(options),
99
+ options: toSerializableOptions({
100
+ ...options,
101
+ supervisorSessionId: sessionId,
102
+ }),
100
103
  lease,
101
104
  terminalSessionFile: terminalSession?.file || "",
102
105
  };
@@ -0,0 +1,320 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+
4
+ import { fileExists, readJson, sleep } from "./common.mjs";
5
+ import { renderHostLeaseLabel } from "./host_lease.mjs";
6
+
7
+ const FINAL_STATUSES = new Set(["completed", "failed", "stopped"]);
8
+
9
+ function readJsonIfExists(filePath) {
10
+ if (!filePath || !fileExists(filePath)) {
11
+ return null;
12
+ }
13
+
14
+ try {
15
+ return readJson(filePath);
16
+ } catch {
17
+ return null;
18
+ }
19
+ }
20
+
21
+ function writeLine(stream, message) {
22
+ stream.write(`${message}\n`);
23
+ }
24
+
25
+ function buildSessionSummary(supervisor) {
26
+ if (!supervisor?.sessionId) {
27
+ return "";
28
+ }
29
+
30
+ return [
31
+ `[HelloLoop watch] 已附着后台会话:${supervisor.sessionId}`,
32
+ `[HelloLoop watch] 宿主租约:${renderHostLeaseLabel(supervisor.lease)}`,
33
+ ].join("\n");
34
+ }
35
+
36
+ function formatSupervisorState(supervisor) {
37
+ if (!supervisor?.status) {
38
+ return "";
39
+ }
40
+
41
+ const label = {
42
+ launching: "后台 supervisor 启动中",
43
+ running: "后台 supervisor 正在运行",
44
+ completed: "后台 supervisor 已完成",
45
+ failed: "后台 supervisor 执行失败",
46
+ stopped: "后台 supervisor 已停止",
47
+ }[String(supervisor.status)] || `后台 supervisor 状态:${supervisor.status}`;
48
+
49
+ const suffix = supervisor.message ? `:${supervisor.message}` : "";
50
+ return `[HelloLoop watch] ${label}${suffix}`;
51
+ }
52
+
53
+ function formatTaskStatus(status) {
54
+ if (!status?.taskTitle) {
55
+ return "";
56
+ }
57
+
58
+ const lines = [`[HelloLoop watch] 当前任务:${status.taskTitle}`];
59
+ if (status.runDir) {
60
+ lines.push(`[HelloLoop watch] 运行目录:${status.runDir}`);
61
+ }
62
+ if (status.stage) {
63
+ lines.push(`[HelloLoop watch] 阶段:${status.stage}`);
64
+ }
65
+ return lines.join("\n");
66
+ }
67
+
68
+ function selectRuntimeFile(runDir) {
69
+ if (!runDir || !fileExists(runDir)) {
70
+ return "";
71
+ }
72
+
73
+ const candidates = [];
74
+ for (const entry of fs.readdirSync(runDir, { withFileTypes: true })) {
75
+ if (entry.isFile() && entry.name.endsWith("-runtime.json")) {
76
+ candidates.push(path.join(runDir, entry.name));
77
+ continue;
78
+ }
79
+ if (!entry.isDirectory()) {
80
+ continue;
81
+ }
82
+ const nestedDir = path.join(runDir, entry.name);
83
+ for (const nestedName of fs.readdirSync(nestedDir)) {
84
+ if (nestedName.endsWith("-runtime.json")) {
85
+ candidates.push(path.join(nestedDir, nestedName));
86
+ }
87
+ }
88
+ }
89
+
90
+ candidates.sort((left, right) => {
91
+ const rightTime = fs.statSync(right).mtimeMs;
92
+ const leftTime = fs.statSync(left).mtimeMs;
93
+ return rightTime - leftTime;
94
+ });
95
+
96
+ return candidates[0] || "";
97
+ }
98
+
99
+ function formatRuntimeState(runtime, previousRuntime) {
100
+ if (!runtime?.status) {
101
+ return "";
102
+ }
103
+
104
+ const idleSeconds = Number(runtime?.heartbeat?.idleSeconds || 0);
105
+ const idleBucket = Math.floor(idleSeconds / 30);
106
+ const previousIdleBucket = Math.floor(Number(previousRuntime?.heartbeat?.idleSeconds || 0) / 30);
107
+ const signature = [
108
+ runtime.status,
109
+ runtime.attemptPrefix || "",
110
+ runtime.recoveryCount || 0,
111
+ runtime.failureCode || "",
112
+ runtime.failureReason || "",
113
+ runtime.nextRetryAt || "",
114
+ runtime.notification?.reason || "",
115
+ ].join("|");
116
+ const previousSignature = previousRuntime
117
+ ? [
118
+ previousRuntime.status,
119
+ previousRuntime.attemptPrefix || "",
120
+ previousRuntime.recoveryCount || 0,
121
+ previousRuntime.failureCode || "",
122
+ previousRuntime.failureReason || "",
123
+ previousRuntime.nextRetryAt || "",
124
+ previousRuntime.notification?.reason || "",
125
+ ].join("|")
126
+ : "";
127
+
128
+ if (signature === previousSignature && (runtime.status !== "running" || idleBucket === previousIdleBucket || idleBucket === 0)) {
129
+ return "";
130
+ }
131
+
132
+ if (runtime.status === "running") {
133
+ if (idleBucket === 0 || idleBucket === previousIdleBucket) {
134
+ return "";
135
+ }
136
+ return `[HelloLoop watch] 仍在执行:${runtime.attemptPrefix || "当前尝试"},最近输出距今约 ${idleBucket * 30} 秒`;
137
+ }
138
+
139
+ const labels = {
140
+ recovering: "进入同引擎恢复",
141
+ suspected_stall: "疑似卡住,继续观察",
142
+ watchdog_terminating: "触发 watchdog,准备终止当前子进程",
143
+ watchdog_waiting: "watchdog 等待子进程退出",
144
+ retry_waiting: "等待自动重试",
145
+ probe_waiting: "准备执行健康探测",
146
+ probe_running: "正在执行健康探测",
147
+ paused_manual: "自动恢复预算已耗尽,任务暂停",
148
+ lease_terminating: "宿主租约失效,正在停止当前子进程",
149
+ stopped_host_closed: "宿主窗口已关闭,后台任务停止",
150
+ completed: "当前任务执行完成",
151
+ failed: "当前任务执行失败",
152
+ };
153
+ const label = labels[String(runtime.status)] || `运行状态更新:${runtime.status}`;
154
+ const details = [
155
+ runtime.attemptPrefix ? `attempt=${runtime.attemptPrefix}` : "",
156
+ Number.isFinite(Number(runtime.recoveryCount)) ? `recovery=${runtime.recoveryCount}` : "",
157
+ runtime.nextRetryAt ? `next=${runtime.nextRetryAt}` : "",
158
+ runtime.failureReason || "",
159
+ ].filter(Boolean).join(" | ");
160
+
161
+ return `[HelloLoop watch] ${label}${details ? ` | ${details}` : ""}`;
162
+ }
163
+
164
+ function readTextDelta(filePath, offset) {
165
+ if (!filePath || !fileExists(filePath)) {
166
+ return { nextOffset: 0, text: "" };
167
+ }
168
+
169
+ const stats = fs.statSync(filePath);
170
+ if (stats.size <= 0) {
171
+ return { nextOffset: 0, text: "" };
172
+ }
173
+
174
+ const start = Math.max(0, Math.min(Number(offset || 0), stats.size));
175
+ if (stats.size === start) {
176
+ return { nextOffset: start, text: "" };
177
+ }
178
+
179
+ const handle = fs.openSync(filePath, "r");
180
+ try {
181
+ const buffer = Buffer.alloc(stats.size - start);
182
+ fs.readSync(handle, buffer, 0, buffer.length, start);
183
+ return {
184
+ nextOffset: stats.size,
185
+ text: buffer.toString("utf8"),
186
+ };
187
+ } finally {
188
+ fs.closeSync(handle);
189
+ }
190
+ }
191
+
192
+ function readAndWriteDelta(cursor, filePath, stream) {
193
+ if (cursor.file !== filePath) {
194
+ cursor.file = filePath || "";
195
+ cursor.offset = 0;
196
+ }
197
+
198
+ if (!filePath) {
199
+ return;
200
+ }
201
+
202
+ const delta = readTextDelta(filePath, cursor.offset);
203
+ cursor.offset = delta.nextOffset;
204
+ if (!delta.text) {
205
+ return;
206
+ }
207
+
208
+ stream.write(delta.text);
209
+ if (!delta.text.endsWith("\n")) {
210
+ stream.write("\n");
211
+ }
212
+ }
213
+
214
+ function resolveStatusForSession(status, sessionId) {
215
+ if (!status) {
216
+ return null;
217
+ }
218
+ if (!sessionId || !status.sessionId || status.sessionId === sessionId) {
219
+ return status;
220
+ }
221
+ return null;
222
+ }
223
+
224
+ function buildWatchResult(supervisor, result) {
225
+ const exitCode = Number(result?.exitCode ?? supervisor?.exitCode ?? (supervisor?.status === "completed" ? 0 : 1));
226
+ return {
227
+ sessionId: result?.sessionId || supervisor?.sessionId || "",
228
+ status: result?.ok === true
229
+ ? "completed"
230
+ : (supervisor?.status || (exitCode === 0 ? "completed" : "failed")),
231
+ ok: result?.ok === true || exitCode === 0,
232
+ exitCode,
233
+ };
234
+ }
235
+
236
+ export async function watchSupervisorSession(context, options = {}) {
237
+ const pollMs = Math.max(200, Number(options.pollMs || 1000));
238
+ const stdoutStream = options.stdoutStream || process.stdout;
239
+ const stderrStream = options.stderrStream || process.stderr;
240
+ const expectedSessionId = String(options.sessionId || "").trim();
241
+ const stdoutCursor = { file: "", offset: 0 };
242
+ const stderrCursor = { file: "", offset: 0 };
243
+ let printedSessionId = "";
244
+ let lastSupervisorSignature = "";
245
+ let lastTaskSignature = "";
246
+ let previousRuntime = null;
247
+ let missingPolls = 0;
248
+
249
+ while (true) {
250
+ const supervisor = readJsonIfExists(context.supervisorStateFile);
251
+ const result = readJsonIfExists(context.supervisorResultFile);
252
+ const activeSessionId = expectedSessionId || supervisor?.sessionId || result?.sessionId || "";
253
+ const taskStatus = resolveStatusForSession(readJsonIfExists(context.statusFile), activeSessionId);
254
+ const runtimeFile = taskStatus?.runDir ? selectRuntimeFile(taskStatus.runDir) : "";
255
+ const runtime = readJsonIfExists(runtimeFile);
256
+
257
+ if (!supervisor && !result) {
258
+ missingPolls += 1;
259
+ if (missingPolls >= 3) {
260
+ return {
261
+ sessionId: activeSessionId,
262
+ status: "",
263
+ ok: false,
264
+ exitCode: 1,
265
+ empty: true,
266
+ };
267
+ }
268
+ await sleep(pollMs);
269
+ continue;
270
+ }
271
+ missingPolls = 0;
272
+
273
+ if (supervisor?.sessionId && supervisor.sessionId !== printedSessionId) {
274
+ printedSessionId = supervisor.sessionId;
275
+ writeLine(stdoutStream, buildSessionSummary(supervisor));
276
+ }
277
+
278
+ const supervisorSignature = supervisor
279
+ ? [supervisor.sessionId || "", supervisor.status || "", supervisor.message || ""].join("|")
280
+ : "";
281
+ if (supervisorSignature && supervisorSignature !== lastSupervisorSignature) {
282
+ lastSupervisorSignature = supervisorSignature;
283
+ writeLine(stdoutStream, formatSupervisorState(supervisor));
284
+ }
285
+
286
+ const taskSignature = taskStatus
287
+ ? [taskStatus.sessionId || "", taskStatus.taskId || "", taskStatus.taskTitle || "", taskStatus.runDir || "", taskStatus.stage || ""].join("|")
288
+ : "";
289
+ if (taskSignature && taskSignature !== lastTaskSignature) {
290
+ lastTaskSignature = taskSignature;
291
+ writeLine(stdoutStream, formatTaskStatus(taskStatus));
292
+ }
293
+
294
+ const runtimeMessage = formatRuntimeState(runtime, previousRuntime);
295
+ if (runtimeMessage) {
296
+ writeLine(stdoutStream, runtimeMessage);
297
+ }
298
+ previousRuntime = runtime || previousRuntime;
299
+
300
+ const activePrefix = runtime?.attemptPrefix || "";
301
+ const runtimeDir = runtimeFile ? path.dirname(runtimeFile) : "";
302
+ const stdoutFile = runtimeDir && activePrefix
303
+ ? path.join(runtimeDir, `${activePrefix}-stdout.log`)
304
+ : "";
305
+ const stderrFile = runtimeDir && activePrefix
306
+ ? path.join(runtimeDir, `${activePrefix}-stderr.log`)
307
+ : "";
308
+
309
+ readAndWriteDelta(stdoutCursor, stdoutFile, stdoutStream);
310
+ readAndWriteDelta(stderrCursor, stderrFile, stderrStream);
311
+
312
+ if (supervisor?.status && FINAL_STATUSES.has(String(supervisor.status))) {
313
+ readAndWriteDelta(stdoutCursor, stdoutFile, stdoutStream);
314
+ readAndWriteDelta(stderrCursor, stderrFile, stderrStream);
315
+ return buildWatchResult(supervisor, result);
316
+ }
317
+
318
+ await sleep(pollMs);
319
+ }
320
+ }