@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.
- package/dist/index.js +98 -16
- 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
|
-
|
|
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
|
-
|
|
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" :
|
|
34179
|
-
if (
|
|
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 (
|
|
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
|
|
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
|
|
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 (
|
|
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 = [
|
|
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) {
|