helloloop 0.8.5 → 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.
@@ -24,6 +24,15 @@ function defaultEmailNotificationSettings() {
24
24
  };
25
25
  }
26
26
 
27
+ function defaultTerminalConcurrencySettings() {
28
+ return {
29
+ enabled: true,
30
+ visibleMax: 8,
31
+ backgroundMax: 8,
32
+ totalMax: 8,
33
+ };
34
+ }
35
+
27
36
  function defaultUserSettings() {
28
37
  return {
29
38
  defaultEngine: "",
@@ -31,28 +40,40 @@ function defaultUserSettings() {
31
40
  notifications: {
32
41
  email: defaultEmailNotificationSettings(),
33
42
  },
43
+ runtime: {
44
+ terminalConcurrency: defaultTerminalConcurrencySettings(),
45
+ },
34
46
  };
35
47
  }
36
48
 
37
- function cloneJsonValue(value) {
38
- return JSON.parse(JSON.stringify(value));
39
- }
40
-
41
49
  function isPlainObject(value) {
42
50
  return Boolean(value) && typeof value === "object" && !Array.isArray(value);
43
51
  }
44
52
 
45
- function syncValueBySchema(schemaValue, currentValue) {
46
- if (!isPlainObject(schemaValue)) {
47
- return currentValue === undefined ? cloneJsonValue(schemaValue) : currentValue;
48
- }
53
+ function normalizeString(value, fallback = "") {
54
+ return typeof value === "string" ? value.trim() : fallback;
55
+ }
49
56
 
50
- const source = isPlainObject(currentValue) ? currentValue : {};
51
- const next = {};
52
- for (const [key, childSchema] of Object.entries(schemaValue)) {
53
- 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;
54
65
  }
55
- 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
+ };
56
77
  }
57
78
 
58
79
  function mergeValueBySchema(schemaValue, baseValue, patchValue) {
@@ -75,16 +96,6 @@ function mergeValueBySchema(schemaValue, baseValue, patchValue) {
75
96
  return next;
76
97
  }
77
98
 
78
- export function syncUserSettingsShape(settings = {}) {
79
- return syncValueBySchema(defaultUserSettings(), settings);
80
- }
81
-
82
- function readRawUserSettingsDocument(options = {}) {
83
- const settingsFile = resolveUserSettingsFile(options.userSettingsFile);
84
- const settings = fileExists(settingsFile) ? readJson(settingsFile) : {};
85
- return syncUserSettingsShape(settings);
86
- }
87
-
88
99
  export function resolveUserSettingsHome() {
89
100
  return String(process.env.HELLOLOOP_HOME || "").trim()
90
101
  || path.join(os.homedir(), ".helloloop");
@@ -101,32 +112,49 @@ function normalizeEmailNotificationSettings(emailSettings = {}) {
101
112
  const smtp = emailSettings?.smtp || {};
102
113
 
103
114
  return {
104
- ...defaults,
105
- ...emailSettings,
115
+ enabled: normalizeBoolean(emailSettings?.enabled, defaults.enabled),
106
116
  to: Array.isArray(emailSettings?.to)
107
117
  ? emailSettings.to.map((item) => String(item || "").trim()).filter(Boolean)
108
- : (typeof emailSettings?.to === "string" && emailSettings.to.trim() ? [emailSettings.to.trim()] : []),
118
+ : defaults.to,
119
+ from: normalizeString(emailSettings?.from, defaults.from),
109
120
  smtp: {
110
- ...defaults.smtp,
111
- ...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),
112
131
  },
113
132
  };
114
133
  }
115
134
 
116
- export function loadUserSettingsDocument(options = {}) {
117
- const settings = readRawUserSettingsDocument(options);
118
-
135
+ export function syncUserSettingsShape(settings = {}) {
119
136
  return {
120
- ...settings,
121
- defaultEngine: normalizeEngineName(settings?.defaultEngine),
122
- lastSelectedEngine: normalizeEngineName(settings?.lastSelectedEngine),
137
+ defaultEngine: normalizeEngineName(settings?.defaultEngine) || "",
138
+ lastSelectedEngine: normalizeEngineName(settings?.lastSelectedEngine) || "",
123
139
  notifications: {
124
- ...(settings?.notifications || {}),
125
140
  email: normalizeEmailNotificationSettings(settings?.notifications?.email || {}),
126
141
  },
142
+ runtime: {
143
+ terminalConcurrency: normalizeTerminalConcurrencySettings(settings?.runtime?.terminalConcurrency || {}),
144
+ },
127
145
  };
128
146
  }
129
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
+
130
158
  export function loadUserSettings(options = {}) {
131
159
  const settings = loadUserSettingsDocument(options);
132
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
+ }
@@ -6,6 +6,12 @@ import { createContext } from "./context.mjs";
6
6
  import { nowIso, readJson, writeJson, readTextIfExists, timestampForFile } from "./common.mjs";
7
7
  import { isHostLeaseAlive, renderHostLeaseLabel, resolveHostLease } from "./host_lease.mjs";
8
8
  import { runLoop, runOnce } from "./runner.mjs";
9
+ import {
10
+ bindBackgroundTerminalSession,
11
+ cancelPreparedTerminalSessionBackground,
12
+ finalizePreparedTerminalSessionBackground,
13
+ prepareCurrentTerminalSessionForBackground,
14
+ } from "./terminal_session_limits.mjs";
9
15
 
10
16
  const ACTIVE_STATUSES = new Set(["launching", "running"]);
11
17
  const FINAL_STATUSES = new Set(["completed", "failed", "stopped"]);
@@ -78,6 +84,11 @@ export function launchSupervisedCommand(context, command, options = {}) {
78
84
 
79
85
  const sessionId = timestampForFile();
80
86
  const lease = resolveHostLease({ hostContext: options.hostContext });
87
+ const terminalSession = prepareCurrentTerminalSessionForBackground({
88
+ command,
89
+ repoRoot: context.repoRoot,
90
+ sessionId,
91
+ });
81
92
  const request = {
82
93
  sessionId,
83
94
  command,
@@ -85,8 +96,12 @@ export function launchSupervisedCommand(context, command, options = {}) {
85
96
  repoRoot: context.repoRoot,
86
97
  configDirName: context.configDirName,
87
98
  },
88
- options: toSerializableOptions(options),
99
+ options: toSerializableOptions({
100
+ ...options,
101
+ supervisorSessionId: sessionId,
102
+ }),
89
103
  lease,
104
+ terminalSessionFile: terminalSession?.file || "",
90
105
  };
91
106
 
92
107
  fs.mkdirSync(context.supervisorRoot, { recursive: true });
@@ -122,6 +137,11 @@ export function launchSupervisedCommand(context, command, options = {}) {
122
137
  HELLOLOOP_SUPERVISOR_ACTIVE: "1",
123
138
  },
124
139
  });
140
+ finalizePreparedTerminalSessionBackground(child.pid ?? 0, {
141
+ command,
142
+ repoRoot: context.repoRoot,
143
+ sessionId,
144
+ });
125
145
  child.unref();
126
146
  writeState(context, {
127
147
  sessionId,
@@ -137,6 +157,13 @@ export function launchSupervisedCommand(context, command, options = {}) {
137
157
  pid: child.pid ?? 0,
138
158
  lease,
139
159
  };
160
+ } catch (error) {
161
+ cancelPreparedTerminalSessionBackground({
162
+ command,
163
+ repoRoot: context.repoRoot,
164
+ sessionId,
165
+ });
166
+ throw error;
140
167
  } finally {
141
168
  fs.closeSync(stdoutFd);
142
169
  fs.closeSync(stderrFd);
@@ -184,6 +211,11 @@ export async function runSupervisedCommandFromSessionFile(sessionFile) {
184
211
  const context = createContext(request.context || {});
185
212
  const command = String(request.command || "").trim();
186
213
  const lease = request.lease || {};
214
+ bindBackgroundTerminalSession(request.terminalSessionFile || "", {
215
+ command,
216
+ repoRoot: context.repoRoot,
217
+ sessionId: request.sessionId,
218
+ });
187
219
  const commandOptions = {
188
220
  ...(request.options || {}),
189
221
  hostLease: lease,