@4pm/cli 1.4.12 → 1.4.20

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 (2) hide show
  1. package/dist/index.js +62 -14
  2. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -28312,7 +28312,10 @@ function defaultProfileConfig() {
28312
28312
  aiFailoverMode: "remember",
28313
28313
  autoUpdate: true,
28314
28314
  commandHistoryUploadMinutes: 10,
28315
- autoClearIdleMinutes: 10
28315
+ autoClearIdleMinutes: 10,
28316
+ // Terminate a single AI run after 5 min by default (ADR-0243) so a hung/looping AI CLI
28317
+ // (e.g. a heavy spec review/compose) can't spin the dispatcher forever; 0 = no limit.
28318
+ aiRunTimeoutSec: 300
28316
28319
  };
28317
28320
  }
28318
28321
  function ensureProfileConfig(dir2) {
@@ -28615,6 +28618,9 @@ function readInstalledVersion() {
28615
28618
  }
28616
28619
  }
28617
28620
  function updateViaNpm(version) {
28621
+ if (!/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/.test(version)) {
28622
+ throw new Error(`Refusing to update: invalid version string "${version}".`);
28623
+ }
28618
28624
  execSync2(`npm i -g @4pm/cli@${version}`, { stdio: "inherit" });
28619
28625
  }
28620
28626
  async function updateViaDownload(tarballUrl, checksum, signature) {
@@ -33973,7 +33979,8 @@ var UpdateScheduler = class {
33973
33979
 
33974
33980
  // src/core/executor.ts
33975
33981
  import { spawn as spawn2 } from "child_process";
33976
- async function runCommand(dispatch, emit) {
33982
+ var KILL_GRACE_MS = 5e3;
33983
+ async function runCommand(dispatch, emit, opts) {
33977
33984
  let seq = 0;
33978
33985
  const child = spawn2(dispatch.cmd, dispatch.args ?? [], {
33979
33986
  cwd: dispatch.path,
@@ -33981,6 +33988,27 @@ async function runCommand(dispatch, emit) {
33981
33988
  shell: false
33982
33989
  });
33983
33990
  child.stdin?.end();
33991
+ const timeoutMs = opts?.timeoutMs && opts.timeoutMs > 0 ? opts.timeoutMs : 0;
33992
+ let timedOut = false;
33993
+ let hardKillTimer = null;
33994
+ const timeoutTimer = timeoutMs > 0 ? setTimeout(() => {
33995
+ timedOut = true;
33996
+ emit({
33997
+ commandId: dispatch.commandId,
33998
+ seq: seq++,
33999
+ chunk: `
34000
+ [4PM] AI run exceeded the ${Math.round(timeoutMs / 1e3)}s time limit \u2014 terminating.
34001
+ `
34002
+ });
34003
+ child.kill("SIGTERM");
34004
+ hardKillTimer = setTimeout(() => child.kill("SIGKILL"), KILL_GRACE_MS);
34005
+ hardKillTimer.unref?.();
34006
+ }, timeoutMs) : null;
34007
+ timeoutTimer?.unref?.();
34008
+ const clearTimers = () => {
34009
+ if (timeoutTimer) clearTimeout(timeoutTimer);
34010
+ if (hardKillTimer) clearTimeout(hardKillTimer);
34011
+ };
33984
34012
  const batcher = new OutputBatcher({
33985
34013
  onFlush: (chunk, truncated) => {
33986
34014
  emit({ commandId: dispatch.commandId, seq: seq++, chunk, truncated });
@@ -33999,17 +34027,21 @@ async function runCommand(dispatch, emit) {
33999
34027
  child.stderr?.on("data", (data) => batcher.push(data.toString("utf8")));
34000
34028
  await new Promise((resolve8) => {
34001
34029
  child.on("close", (exitCode) => {
34030
+ clearTimers();
34002
34031
  batcher.flush();
34003
34032
  emit({
34004
34033
  commandId: dispatch.commandId,
34005
34034
  seq: seq++,
34006
34035
  chunk: "",
34007
34036
  done: true,
34008
- exitCode: exitCode ?? -1
34037
+ // 124 (the conventional timeout code) when we killed it for exceeding the limit, so the
34038
+ // server settles the command as failed and the web surfaces the note instead of spinning.
34039
+ exitCode: timedOut ? 124 : exitCode ?? -1
34009
34040
  });
34010
34041
  resolve8();
34011
34042
  });
34012
34043
  child.on("error", (err) => {
34044
+ clearTimers();
34013
34045
  emit({
34014
34046
  commandId: dispatch.commandId,
34015
34047
  seq: seq++,
@@ -34136,15 +34168,15 @@ function isSessionLimit(text) {
34136
34168
  text
34137
34169
  );
34138
34170
  }
34139
- function runAttempt(commandId, cmd, args, cwd2, env3, onChunk) {
34171
+ function runAttempt(commandId, cmd, args, cwd2, env3, onChunk, timeoutMs) {
34140
34172
  return new Promise((resolve8) => {
34141
34173
  void runCommand({ commandId, cmd, args, path: cwd2, env: env3 }, (out) => {
34142
34174
  if (out.chunk) onChunk(out.chunk);
34143
34175
  if (out.done) resolve8(out.exitCode ?? -1);
34144
- });
34176
+ }, timeoutMs > 0 ? { timeoutMs } : void 0);
34145
34177
  });
34146
34178
  }
34147
- async function runAiFailover(plan, commandId, cwd2, handlers) {
34179
+ async function runAiFailover(plan, commandId, cwd2, handlers, timeoutMs = 0) {
34148
34180
  const total = plan.attempts.length;
34149
34181
  let finalExit = -1;
34150
34182
  let lastCmd = plan.attempts[0]?.cmd ?? "claude";
@@ -34162,7 +34194,7 @@ async function runAiFailover(plan, commandId, cwd2, handlers) {
34162
34194
  handlers.onChunk(text);
34163
34195
  }
34164
34196
  };
34165
- finalExit = await runAttempt(commandId, attempt.cmd, attempt.args, cwd2, attempt.env, emit);
34197
+ finalExit = await runAttempt(commandId, attempt.cmd, attempt.args, cwd2, attempt.env, emit, timeoutMs);
34166
34198
  const tail = parser.flush();
34167
34199
  if (tail) {
34168
34200
  captured += tail;
@@ -34175,8 +34207,8 @@ async function runAiFailover(plan, commandId, cwd2, handlers) {
34175
34207
  reportToolResult(attempt.cmd, true);
34176
34208
  return { exitCode: 0, workedDir: attempt.dir, workedKey: attempt.key, workedCmd: attempt.cmd, usage };
34177
34209
  }
34178
- const reason = isAuthFailure(captured) ? "auth" : isSessionLimit(captured) ? "limit" : null;
34179
- if (!reason || i === total - 1) break;
34210
+ const reason = isAuthFailure(captured) ? "auth" : isSessionLimit(captured) ? "limit" : "other";
34211
+ if (i === total - 1) break;
34180
34212
  handlers.onAttemptFail(attempt.label, reason);
34181
34213
  }
34182
34214
  reportToolResult(lastCmd, false, summarizeAiFailure(lastCaptured, finalExit));
@@ -35075,7 +35107,7 @@ async function runClaudeWithFailover(prompt, ai) {
35075
35107
  if (code === 0 && out.trim()) return { text: out.trim(), usage };
35076
35108
  const combined = err || out;
35077
35109
  lastReason = `exited ${code}: ${combined.slice(0, 500)}`;
35078
- if (!isAuthFailure(combined) && !isSessionLimit(combined) || i === attempts.length - 1) break;
35110
+ if (i === attempts.length - 1) break;
35079
35111
  }
35080
35112
  throw new Error(`claude ${lastReason}`);
35081
35113
  }
@@ -35174,7 +35206,7 @@ async function runKnowledgeCompose(req, ai, physicRoot) {
35174
35206
  }
35175
35207
  const combined = err || out;
35176
35208
  lastReason = `exited ${code}: ${combined.slice(0, 500)}`;
35177
- if (!isAuthFailure(combined) && !isSessionLimit(combined) || i === attempts.length - 1) break;
35209
+ if (i === attempts.length - 1) break;
35178
35210
  } catch (e) {
35179
35211
  lastReason = String(e);
35180
35212
  break;
@@ -36177,6 +36209,9 @@ async function ragQuery(root, queryText, k = 8) {
36177
36209
  }
36178
36210
  }
36179
36211
  async function ragInstall(root, model) {
36212
+ if (!/^[A-Za-z0-9._/-]+$/.test(model)) {
36213
+ return { ok: false, started: false, error: "invalid model name" };
36214
+ }
36180
36215
  const py = await detectPython();
36181
36216
  if (!py.found) return { ok: false, started: false, error: "python3 not found on the worker" };
36182
36217
  if (existsSync10(join20(root, RAG_REL, ".installing"))) return { ok: true, started: true };
@@ -36590,7 +36625,14 @@ async function addProject(payload, profileDir2, onProgress) {
36590
36625
  // src/core/config-sync.ts
36591
36626
  import { existsSync as existsSync11, readFileSync as readFileSync15 } from "fs";
36592
36627
  import { join as join23 } from "path";
36593
- var SERVER_MANAGED_KEYS = ["physicPath", "sessionSwitchPct", "perPromptTokenLimit"];
36628
+ var SERVER_MANAGED_KEYS = [
36629
+ "physicPath",
36630
+ "sessionSwitchPct",
36631
+ "perPromptTokenLimit",
36632
+ // Project AI-run timeout override mirror (ADR-0243) — the ws_token refreshes it; a Worker-config
36633
+ // edit must not fight it. NOTE: the machine-user's own `aiRunTimeoutSec` is NOT here (operator-editable).
36634
+ "projectAiRunTimeoutSec"
36635
+ ];
36594
36636
  function readConfigText(profileDir2) {
36595
36637
  const path = join23(profileDir2, "config.json");
36596
36638
  if (existsSync11(path)) {
@@ -37015,7 +37057,9 @@ var WsClient = class {
37015
37057
  }
37016
37058
  writeProfileConfig(this.context.profileDir, {
37017
37059
  sessionSwitchPct: token.projectTokens?.sessionSwitchPct ?? 0,
37018
- perPromptTokenLimit: token.projectTokens?.perPromptTokenLimit ?? 0
37060
+ perPromptTokenLimit: token.projectTokens?.perPromptTokenLimit ?? 0,
37061
+ // Project AI-run timeout override (ADR-0243) — 0 ⇒ inherit the machine-user setting.
37062
+ projectAiRunTimeoutSec: token.projectTokens?.aiRunTimeoutSec ?? 0
37019
37063
  });
37020
37064
  this.outboundReviewEnabled = token.outboundReview?.enabled === true;
37021
37065
  this.restrictToFolder = token.aiRestrictToFolder === true;
@@ -37636,6 +37680,10 @@ var WsClient = class {
37636
37680
  */
37637
37681
  async runAiPrompt(prompt, commandId, origin) {
37638
37682
  const config = readProfileConfig(this.context.profileDir);
37683
+ const projectTimeoutSec = config.projectAiRunTimeoutSec ?? 0;
37684
+ const machineTimeoutSec = config.aiRunTimeoutSec ?? 0;
37685
+ const timeoutSec = projectTimeoutSec > 0 ? projectTimeoutSec : machineTimeoutSec;
37686
+ const aiRunTimeoutMs = timeoutSec > 0 ? timeoutSec * 1e3 : 0;
37639
37687
  const perPromptLimit = config.perPromptTokenLimit ?? 0;
37640
37688
  if (perPromptLimit > 0) {
37641
37689
  const estimated = estimateTokens(prompt);
@@ -37754,7 +37802,7 @@ var WsClient = class {
37754
37802
  this.send(WsChannels.COMMAND_OUTPUT, { commandId, seq: seq++, chunk: `${text}
37755
37803
  `, log: true });
37756
37804
  }
37757
- });
37805
+ }, aiRunTimeoutMs);
37758
37806
  this.bus.endBusy(plan.cmd);
37759
37807
  const profileLabel = result.workedDir ? profileDisplayLabel(result.workedDir) : null;
37760
37808
  if (result.workedDir) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@4pm/cli",
3
- "version": "1.4.12",
3
+ "version": "1.4.20",
4
4
  "private": false,
5
5
  "description": "4PM CLI — drives Claude/Codex CLIs on AI worker machines (Node + TypeScript)",
6
6
  "license": "LicenseRef-4PM-Source-Available",