@4pm/cli 1.4.11 → 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 +98 -16
  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));
@@ -34992,6 +35024,20 @@ function buildPrompt(docs, question, askerRole) {
34992
35024
  roleNote,
34993
35025
  "Answer concisely in the user's language; reference the relevant doc heading/path when useful.",
34994
35026
  "",
35027
+ // Structured output for inline moderation (ADR-0237): the same run also classifies the question.
35028
+ "Return ONLY a single JSON object (no markdown fences, no prose around it) with these keys:",
35029
+ ' "onTopic": boolean \u2014 false if the question is NOT about how to use the 4PM product,',
35030
+ ' "sensitive": boolean \u2014 true if the question is inappropriate / abusive OR asks how to hack,',
35031
+ " bypass security, gain unauthorized access, take over or steal another",
35032
+ " user's/org's account or data, obtain credentials, or any malicious intent,",
35033
+ ' "reason": string \u2014 a short reason when onTopic is false or sensitive is true, else "",',
35034
+ ` "answer": string \u2014 your reply to the user, in markdown (in the user's language).`,
35035
+ "Refusal policy (ADR-0237): if the question is off-topic (onTopic false) OR sensitive, DO NOT",
35036
+ `answer it. Set "answer" to this EXACT same polite refusal for BOTH cases (in the user's`,
35037
+ 'language, do not reveal which category or hint at the reason): "Sorry, I can only help with',
35038
+ 'questions about how to use 4PM. For anything else, please contact human support." Only answer',
35039
+ "normally when onTopic is true AND sensitive is false.",
35040
+ "",
34995
35041
  "===== DOCUMENTATION =====",
34996
35042
  docs,
34997
35043
  "",
@@ -34999,6 +35045,23 @@ function buildPrompt(docs, question, askerRole) {
34999
35045
  question
35000
35046
  ].join("\n");
35001
35047
  }
35048
+ function parseModeratedAnswer(text) {
35049
+ const start = text.indexOf("{");
35050
+ const end = text.lastIndexOf("}");
35051
+ if (start !== -1 && end > start) {
35052
+ try {
35053
+ const obj = JSON.parse(text.slice(start, end + 1));
35054
+ if (typeof obj.answer === "string" && obj.answer.trim()) {
35055
+ const onTopic = obj.onTopic !== false;
35056
+ const sensitive = obj.sensitive === true;
35057
+ const reason = typeof obj.reason === "string" ? obj.reason : "";
35058
+ return { body: obj.answer.trim(), moderation: { onTopic, sensitive, reason } };
35059
+ }
35060
+ } catch {
35061
+ }
35062
+ }
35063
+ return { body: text.trim() };
35064
+ }
35002
35065
  function runClaudeOnce(cmd, profile, prompt, extraEnv) {
35003
35066
  return new Promise((resolve8, reject) => {
35004
35067
  const isClaude = cmd.includes("claude");
@@ -35044,7 +35107,7 @@ async function runClaudeWithFailover(prompt, ai) {
35044
35107
  if (code === 0 && out.trim()) return { text: out.trim(), usage };
35045
35108
  const combined = err || out;
35046
35109
  lastReason = `exited ${code}: ${combined.slice(0, 500)}`;
35047
- if (!isAuthFailure(combined) && !isSessionLimit(combined) || i === attempts.length - 1) break;
35110
+ if (i === attempts.length - 1) break;
35048
35111
  }
35049
35112
  throw new Error(`claude ${lastReason}`);
35050
35113
  }
@@ -35058,9 +35121,12 @@ async function runSupportAnswer(req, ai, profileDir2) {
35058
35121
  ai
35059
35122
  );
35060
35123
  if (!text) return { body: "", error: "empty answer" };
35061
- const u = usage.tokens > 0 ? usage : { ...NO_USAGE, tokens: estimateTokens(text) };
35124
+ const { body, moderation } = parseModeratedAnswer(text);
35125
+ if (!body) return { body: "", error: "empty answer" };
35126
+ const u = usage.tokens > 0 ? usage : { ...NO_USAGE, tokens: estimateTokens(body) };
35062
35127
  return {
35063
- body: text,
35128
+ body,
35129
+ ...moderation ? { moderation } : {},
35064
35130
  tokens: u.tokens,
35065
35131
  ...usage.tokens > 0 ? {
35066
35132
  tokensBreakdown: {
@@ -35140,7 +35206,7 @@ async function runKnowledgeCompose(req, ai, physicRoot) {
35140
35206
  }
35141
35207
  const combined = err || out;
35142
35208
  lastReason = `exited ${code}: ${combined.slice(0, 500)}`;
35143
- if (!isAuthFailure(combined) && !isSessionLimit(combined) || i === attempts.length - 1) break;
35209
+ if (i === attempts.length - 1) break;
35144
35210
  } catch (e) {
35145
35211
  lastReason = String(e);
35146
35212
  break;
@@ -36143,6 +36209,9 @@ async function ragQuery(root, queryText, k = 8) {
36143
36209
  }
36144
36210
  }
36145
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
+ }
36146
36215
  const py = await detectPython();
36147
36216
  if (!py.found) return { ok: false, started: false, error: "python3 not found on the worker" };
36148
36217
  if (existsSync10(join20(root, RAG_REL, ".installing"))) return { ok: true, started: true };
@@ -36556,7 +36625,14 @@ async function addProject(payload, profileDir2, onProgress) {
36556
36625
  // src/core/config-sync.ts
36557
36626
  import { existsSync as existsSync11, readFileSync as readFileSync15 } from "fs";
36558
36627
  import { join as join23 } from "path";
36559
- 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
+ ];
36560
36636
  function readConfigText(profileDir2) {
36561
36637
  const path = join23(profileDir2, "config.json");
36562
36638
  if (existsSync11(path)) {
@@ -36981,7 +37057,9 @@ var WsClient = class {
36981
37057
  }
36982
37058
  writeProfileConfig(this.context.profileDir, {
36983
37059
  sessionSwitchPct: token.projectTokens?.sessionSwitchPct ?? 0,
36984
- 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
36985
37063
  });
36986
37064
  this.outboundReviewEnabled = token.outboundReview?.enabled === true;
36987
37065
  this.restrictToFolder = token.aiRestrictToFolder === true;
@@ -37602,6 +37680,10 @@ var WsClient = class {
37602
37680
  */
37603
37681
  async runAiPrompt(prompt, commandId, origin) {
37604
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;
37605
37687
  const perPromptLimit = config.perPromptTokenLimit ?? 0;
37606
37688
  if (perPromptLimit > 0) {
37607
37689
  const estimated = estimateTokens(prompt);
@@ -37720,7 +37802,7 @@ var WsClient = class {
37720
37802
  this.send(WsChannels.COMMAND_OUTPUT, { commandId, seq: seq++, chunk: `${text}
37721
37803
  `, log: true });
37722
37804
  }
37723
- });
37805
+ }, aiRunTimeoutMs);
37724
37806
  this.bus.endBusy(plan.cmd);
37725
37807
  const profileLabel = result.workedDir ? profileDisplayLabel(result.workedDir) : null;
37726
37808
  if (result.workedDir) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@4pm/cli",
3
- "version": "1.4.11",
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",