@4pm/cli 1.4.12 → 1.5.0

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 +316 -60
  2. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -28312,14 +28312,45 @@ 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,
28319
+ // Shared AI memory (ADR-0245) — off by default (opt-in; costs a compaction call per turn).
28320
+ aiMemoryEnabled: false,
28321
+ aiMemoryBudgetChars: 6e3
28316
28322
  };
28317
28323
  }
28324
+ function resolveMemoryConfig(config) {
28325
+ const mode = config.projectAiMemoryMode ?? "inherit";
28326
+ const enabled = mode === "on" ? true : mode === "off" ? false : config.aiMemoryEnabled ?? false;
28327
+ const projectBudget = config.projectAiMemoryBudgetChars ?? 0;
28328
+ const budgetChars = projectBudget > 0 ? projectBudget : config.aiMemoryBudgetChars ?? 6e3;
28329
+ return { enabled, budgetChars };
28330
+ }
28331
+ function resolveIdleAutoClearMinutes(config) {
28332
+ const project = config.projectAutoClearIdleMinutes ?? 0;
28333
+ return project > 0 ? project : config.autoClearIdleMinutes ?? 10;
28334
+ }
28318
28335
  function ensureProfileConfig(dir2) {
28319
28336
  if (existsSync(join(dir2, "config.json"))) return false;
28320
28337
  writeProfileConfig(dir2, defaultProfileConfig());
28321
28338
  return true;
28322
28339
  }
28340
+ function backfillProfileConfig(dir2) {
28341
+ if (!existsSync(join(dir2, "config.json"))) return false;
28342
+ const current = readProfileConfig(dir2);
28343
+ const next = { ...current };
28344
+ let changed = false;
28345
+ for (const [key, value] of Object.entries(defaultProfileConfig())) {
28346
+ if (next[key] === void 0) {
28347
+ next[key] = value;
28348
+ changed = true;
28349
+ }
28350
+ }
28351
+ if (changed) overwriteProfileConfig(dir2, next);
28352
+ return changed;
28353
+ }
28323
28354
  function readProfileConfig(dir2) {
28324
28355
  const path = join(dir2, "config.json");
28325
28356
  if (!existsSync(path)) return {};
@@ -28557,7 +28588,7 @@ async function runLink(serverUrl, explicitProfile, token = null) {
28557
28588
  }
28558
28589
 
28559
28590
  // src/commands/start.ts
28560
- import { spawn as spawn9 } from "child_process";
28591
+ import { spawn as spawn10 } from "child_process";
28561
28592
  import { join as join29 } from "path";
28562
28593
 
28563
28594
  // src/core/update.ts
@@ -28615,6 +28646,9 @@ function readInstalledVersion() {
28615
28646
  }
28616
28647
  }
28617
28648
  function updateViaNpm(version) {
28649
+ if (!/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/.test(version)) {
28650
+ throw new Error(`Refusing to update: invalid version string "${version}".`);
28651
+ }
28618
28652
  execSync2(`npm i -g @4pm/cli@${version}`, { stdio: "inherit" });
28619
28653
  }
28620
28654
  async function updateViaDownload(tarballUrl, checksum, signature) {
@@ -33699,7 +33733,11 @@ var WsChannels = {
33699
33733
  SUPPORT_ANSWER: "support.answer",
33700
33734
  // AI knowledge distill (ADR-0190): server → project cli request–reply. The worker runs the AI CLI
33701
33735
  // in the project's working dir with a distillation prompt and replies with the composed markdown.
33702
- KNOWLEDGE_COMPOSE: "knowledge.compose"
33736
+ KNOWLEDGE_COMPOSE: "knowledge.compose",
33737
+ // Shared AI memory (ADR-0245): cli → server one-way write-back of the compacted rolling memory for
33738
+ // the project it serves (persisted per project × machine-user link). The read direction rides
33739
+ // `ws_token` (seeds the cli cache on connect); this channel only carries updates.
33740
+ MEMORY_UPDATE: "memory.update"
33703
33741
  };
33704
33742
 
33705
33743
  // vendor/@4pm/ws/crypto.ts
@@ -33973,7 +34011,8 @@ var UpdateScheduler = class {
33973
34011
 
33974
34012
  // src/core/executor.ts
33975
34013
  import { spawn as spawn2 } from "child_process";
33976
- async function runCommand(dispatch, emit) {
34014
+ var KILL_GRACE_MS = 5e3;
34015
+ async function runCommand(dispatch, emit, opts) {
33977
34016
  let seq = 0;
33978
34017
  const child = spawn2(dispatch.cmd, dispatch.args ?? [], {
33979
34018
  cwd: dispatch.path,
@@ -33981,6 +34020,27 @@ async function runCommand(dispatch, emit) {
33981
34020
  shell: false
33982
34021
  });
33983
34022
  child.stdin?.end();
34023
+ const timeoutMs = opts?.timeoutMs && opts.timeoutMs > 0 ? opts.timeoutMs : 0;
34024
+ let timedOut = false;
34025
+ let hardKillTimer = null;
34026
+ const timeoutTimer = timeoutMs > 0 ? setTimeout(() => {
34027
+ timedOut = true;
34028
+ emit({
34029
+ commandId: dispatch.commandId,
34030
+ seq: seq++,
34031
+ chunk: `
34032
+ [4PM] AI run exceeded the ${Math.round(timeoutMs / 1e3)}s time limit \u2014 terminating.
34033
+ `
34034
+ });
34035
+ child.kill("SIGTERM");
34036
+ hardKillTimer = setTimeout(() => child.kill("SIGKILL"), KILL_GRACE_MS);
34037
+ hardKillTimer.unref?.();
34038
+ }, timeoutMs) : null;
34039
+ timeoutTimer?.unref?.();
34040
+ const clearTimers = () => {
34041
+ if (timeoutTimer) clearTimeout(timeoutTimer);
34042
+ if (hardKillTimer) clearTimeout(hardKillTimer);
34043
+ };
33984
34044
  const batcher = new OutputBatcher({
33985
34045
  onFlush: (chunk, truncated) => {
33986
34046
  emit({ commandId: dispatch.commandId, seq: seq++, chunk, truncated });
@@ -33999,17 +34059,21 @@ async function runCommand(dispatch, emit) {
33999
34059
  child.stderr?.on("data", (data) => batcher.push(data.toString("utf8")));
34000
34060
  await new Promise((resolve8) => {
34001
34061
  child.on("close", (exitCode) => {
34062
+ clearTimers();
34002
34063
  batcher.flush();
34003
34064
  emit({
34004
34065
  commandId: dispatch.commandId,
34005
34066
  seq: seq++,
34006
34067
  chunk: "",
34007
34068
  done: true,
34008
- exitCode: exitCode ?? -1
34069
+ // 124 (the conventional timeout code) when we killed it for exceeding the limit, so the
34070
+ // server settles the command as failed and the web surfaces the note instead of spinning.
34071
+ exitCode: timedOut ? 124 : exitCode ?? -1
34009
34072
  });
34010
34073
  resolve8();
34011
34074
  });
34012
34075
  child.on("error", (err) => {
34076
+ clearTimers();
34013
34077
  emit({
34014
34078
  commandId: dispatch.commandId,
34015
34079
  seq: seq++,
@@ -34052,7 +34116,9 @@ function createAiStreamParser(cli) {
34052
34116
  const isJson = isClaude || isCodex;
34053
34117
  let buffer = "";
34054
34118
  const acc = { tokens: 0, input: 0, output: 0, cacheRead: 0, cacheCreation: 0 };
34119
+ let sessionId = "";
34055
34120
  function handleClaude(ev) {
34121
+ if (typeof ev.session_id === "string" && ev.session_id) sessionId = ev.session_id;
34056
34122
  if (ev.type === "result" && ev.usage) {
34057
34123
  acc.input = ev.usage.input_tokens ?? 0;
34058
34124
  acc.output = ev.usage.output_tokens ?? 0;
@@ -34118,7 +34184,8 @@ function createAiStreamParser(cli) {
34118
34184
  const handled = handleLine(line);
34119
34185
  return handled === null ? line : handled;
34120
34186
  },
34121
- usage: () => ({ ...acc })
34187
+ usage: () => ({ ...acc }),
34188
+ sessionId: () => sessionId
34122
34189
  };
34123
34190
  }
34124
34191
  function estimateTokens(text) {
@@ -34136,15 +34203,15 @@ function isSessionLimit(text) {
34136
34203
  text
34137
34204
  );
34138
34205
  }
34139
- function runAttempt(commandId, cmd, args, cwd2, env3, onChunk) {
34206
+ function runAttempt(commandId, cmd, args, cwd2, env3, onChunk, timeoutMs) {
34140
34207
  return new Promise((resolve8) => {
34141
34208
  void runCommand({ commandId, cmd, args, path: cwd2, env: env3 }, (out) => {
34142
34209
  if (out.chunk) onChunk(out.chunk);
34143
34210
  if (out.done) resolve8(out.exitCode ?? -1);
34144
- });
34211
+ }, timeoutMs > 0 ? { timeoutMs } : void 0);
34145
34212
  });
34146
34213
  }
34147
- async function runAiFailover(plan, commandId, cwd2, handlers) {
34214
+ async function runAiFailover(plan, commandId, cwd2, handlers, timeoutMs = 0) {
34148
34215
  const total = plan.attempts.length;
34149
34216
  let finalExit = -1;
34150
34217
  let lastCmd = plan.attempts[0]?.cmd ?? "claude";
@@ -34162,7 +34229,7 @@ async function runAiFailover(plan, commandId, cwd2, handlers) {
34162
34229
  handlers.onChunk(text);
34163
34230
  }
34164
34231
  };
34165
- finalExit = await runAttempt(commandId, attempt.cmd, attempt.args, cwd2, attempt.env, emit);
34232
+ finalExit = await runAttempt(commandId, attempt.cmd, attempt.args, cwd2, attempt.env, emit, timeoutMs);
34166
34233
  const tail = parser.flush();
34167
34234
  if (tail) {
34168
34235
  captured += tail;
@@ -34173,14 +34240,21 @@ async function runAiFailover(plan, commandId, cwd2, handlers) {
34173
34240
  const usage = parser.usage();
34174
34241
  if (usage.tokens === 0) usage.tokens = estimateTokens(captured);
34175
34242
  reportToolResult(attempt.cmd, true);
34176
- return { exitCode: 0, workedDir: attempt.dir, workedKey: attempt.key, workedCmd: attempt.cmd, usage };
34243
+ return {
34244
+ exitCode: 0,
34245
+ workedDir: attempt.dir,
34246
+ workedKey: attempt.key,
34247
+ workedCmd: attempt.cmd,
34248
+ usage,
34249
+ sessionId: parser.sessionId()
34250
+ };
34177
34251
  }
34178
- const reason = isAuthFailure(captured) ? "auth" : isSessionLimit(captured) ? "limit" : null;
34179
- if (!reason || i === total - 1) break;
34252
+ const reason = isAuthFailure(captured) ? "auth" : isSessionLimit(captured) ? "limit" : "other";
34253
+ if (i === total - 1) break;
34180
34254
  handlers.onAttemptFail(attempt.label, reason);
34181
34255
  }
34182
34256
  reportToolResult(lastCmd, false, summarizeAiFailure(lastCaptured, finalExit));
34183
- return { exitCode: finalExit, workedDir: null, workedKey: null, workedCmd: null, usage: { tokens: 0, input: 0, output: 0, cacheRead: 0, cacheCreation: 0 } };
34257
+ return { exitCode: finalExit, workedDir: null, workedKey: null, workedCmd: null, usage: { tokens: 0, input: 0, output: 0, cacheRead: 0, cacheCreation: 0 }, sessionId: "" };
34184
34258
  }
34185
34259
  function summarizeAiFailure(captured, exitCode) {
34186
34260
  if (isAuthFailure(captured)) return "Not logged in";
@@ -34298,11 +34372,12 @@ function folderScopeGuard(folder, prompt) {
34298
34372
  prompt
34299
34373
  ].join("\n");
34300
34374
  }
34301
- function buildRunArgs(profile, cmd, prompt) {
34375
+ function buildRunArgs(profile, cmd, prompt, resumeId) {
34302
34376
  const extras = profile.args ?? [];
34303
34377
  const model = profile.model?.trim();
34304
34378
  const modelArgs = model ? ["--model", model] : [];
34305
- return [...requiredArgs(cmd), ...extras, ...modelArgs, prompt];
34379
+ const resumeArgs = resumeId && cmd.includes("claude") ? ["--resume", resumeId] : [];
34380
+ return [...requiredArgs(cmd), ...resumeArgs, ...extras, ...modelArgs, prompt];
34306
34381
  }
34307
34382
  function claudeHomeDirs(config) {
34308
34383
  const dirs = profileDirs(claudeProfilesOf(config));
@@ -34341,10 +34416,10 @@ function resolveClaudeProfiles(config, workingDir) {
34341
34416
  if (idx > 0) profiles.unshift(profiles.splice(idx, 1)[0]);
34342
34417
  return profiles;
34343
34418
  }
34344
- function planAiRun(prompt, config, hint = {}) {
34345
- return isUnifiedConfig(config) ? planUnifiedRun(prompt, config, hint.credential ?? null) : planLegacyRun(prompt, config, hint.dir ?? null);
34419
+ function planAiRun(prompt, config, hint = {}, resume = /* @__PURE__ */ new Map()) {
34420
+ return isUnifiedConfig(config) ? planUnifiedRun(prompt, config, hint.credential ?? null, resume) : planLegacyRun(prompt, config, hint.dir ?? null, resume);
34346
34421
  }
34347
- function planUnifiedRun(prompt, config, workingCredential) {
34422
+ function planUnifiedRun(prompt, config, workingCredential, resume) {
34348
34423
  const baseEnv = config.aiEnv ?? {};
34349
34424
  const active = activeProvider(config);
34350
34425
  const usable = config.aiProfiles.filter(isUsableCredential);
@@ -34371,12 +34446,12 @@ function planUnifiedRun(prompt, config, workingCredential) {
34371
34446
  label: cred.label?.trim() || profileDisplayLabel(dir2),
34372
34447
  dir: dir2,
34373
34448
  key,
34374
- args: buildRunArgs(cred, cmd, prompt),
34449
+ args: buildRunArgs(cred, cmd, prompt, resume.get(key)),
34375
34450
  env: envVar ? { ...baseEnv, [envVar]: dir2 } : { ...baseEnv }
34376
34451
  }));
34377
34452
  return { cmd: attempts[0]?.cmd ?? DEFAULT_AI_CLI, attempts };
34378
34453
  }
34379
- function planLegacyRun(prompt, config, workingDir) {
34454
+ function planLegacyRun(prompt, config, workingDir, resume) {
34380
34455
  const cmd = config.aiCli || DEFAULT_AI_CLI;
34381
34456
  const baseEnv = config.aiEnv ?? {};
34382
34457
  const envVar = profileEnvVar(cmd);
@@ -34393,14 +34468,17 @@ function planLegacyRun(prompt, config, workingDir) {
34393
34468
  resolved.find((r) => r.dir === workingDir),
34394
34469
  ...resolved.filter((r) => r.dir !== workingDir)
34395
34470
  ] : resolved;
34396
- const attempts = ordered.map(({ profile, dir: dir2 }) => ({
34397
- cmd,
34398
- label: profileDisplayLabel(dir2),
34399
- dir: dir2,
34400
- key: credentialKey(cmd, dir2),
34401
- args: buildRunArgs(profile, cmd, prompt),
34402
- env: { ...baseEnv, [envVar]: dir2 }
34403
- }));
34471
+ const attempts = ordered.map(({ profile, dir: dir2 }) => {
34472
+ const key = credentialKey(cmd, dir2);
34473
+ return {
34474
+ cmd,
34475
+ label: profileDisplayLabel(dir2),
34476
+ dir: dir2,
34477
+ key,
34478
+ args: buildRunArgs(profile, cmd, prompt, resume.get(key)),
34479
+ env: { ...baseEnv, [envVar]: dir2 }
34480
+ };
34481
+ });
34404
34482
  return { cmd, attempts };
34405
34483
  }
34406
34484
 
@@ -35075,7 +35153,7 @@ async function runClaudeWithFailover(prompt, ai) {
35075
35153
  if (code === 0 && out.trim()) return { text: out.trim(), usage };
35076
35154
  const combined = err || out;
35077
35155
  lastReason = `exited ${code}: ${combined.slice(0, 500)}`;
35078
- if (!isAuthFailure(combined) && !isSessionLimit(combined) || i === attempts.length - 1) break;
35156
+ if (i === attempts.length - 1) break;
35079
35157
  }
35080
35158
  throw new Error(`claude ${lastReason}`);
35081
35159
  }
@@ -35174,7 +35252,7 @@ async function runKnowledgeCompose(req, ai, physicRoot) {
35174
35252
  }
35175
35253
  const combined = err || out;
35176
35254
  lastReason = `exited ${code}: ${combined.slice(0, 500)}`;
35177
- if (!isAuthFailure(combined) && !isSessionLimit(combined) || i === attempts.length - 1) break;
35255
+ if (i === attempts.length - 1) break;
35178
35256
  } catch (e) {
35179
35257
  lastReason = String(e);
35180
35258
  break;
@@ -36177,6 +36255,9 @@ async function ragQuery(root, queryText, k = 8) {
36177
36255
  }
36178
36256
  }
36179
36257
  async function ragInstall(root, model) {
36258
+ if (!/^[A-Za-z0-9._/-]+$/.test(model)) {
36259
+ return { ok: false, started: false, error: "invalid model name" };
36260
+ }
36180
36261
  const py = await detectPython();
36181
36262
  if (!py.found) return { ok: false, started: false, error: "python3 not found on the worker" };
36182
36263
  if (existsSync10(join20(root, RAG_REL, ".installing"))) return { ok: true, started: true };
@@ -36587,10 +36668,93 @@ async function addProject(payload, profileDir2, onProgress) {
36587
36668
  }
36588
36669
  }
36589
36670
 
36671
+ // src/core/memory-compact.ts
36672
+ import { spawn as spawn8 } from "child_process";
36673
+ var COMPACT_TIMEOUT_MS = 6e4;
36674
+ function buildPrompt3(input) {
36675
+ return [
36676
+ "You maintain a COMPACT running memory of an ongoing assistant conversation so it can continue",
36677
+ "across sessions and accounts. Merge the latest exchange into the existing memory and return the",
36678
+ `UPDATED memory. Keep it UNDER ${input.budgetChars} characters. Use terse Markdown bullets grouped`,
36679
+ "as: Decisions, Constraints, Open tasks, Glossary. Keep durable facts; drop chit-chat and anything",
36680
+ "now obsolete. Output ONLY the updated memory text \u2014 no preamble, no code fences.",
36681
+ "",
36682
+ "=== EXISTING MEMORY ===",
36683
+ input.oldMemory || "(empty)",
36684
+ "",
36685
+ "=== LATEST USER PROMPT ===",
36686
+ input.prompt,
36687
+ "",
36688
+ "=== LATEST ASSISTANT ANSWER ===",
36689
+ input.answer
36690
+ ].join("\n");
36691
+ }
36692
+ function runOnce2(cmd, profile, prompt, cwd2, extraEnv) {
36693
+ return new Promise((resolve8, reject) => {
36694
+ const args = ["-p", ...profile?.model ? ["--model", profile.model] : []];
36695
+ const env3 = {
36696
+ ...process.env,
36697
+ ...extraEnv,
36698
+ ...profile ? { CLAUDE_CONFIG_DIR: profile.dir } : {}
36699
+ };
36700
+ const child = spawn8(cmd, args, { stdio: ["pipe", "pipe", "pipe"], cwd: cwd2, env: env3 });
36701
+ let out = "";
36702
+ let err = "";
36703
+ const timer = setTimeout(() => {
36704
+ child.kill("SIGKILL");
36705
+ reject(new Error("memory compaction timed out"));
36706
+ }, COMPACT_TIMEOUT_MS);
36707
+ timer.unref();
36708
+ child.stdout.on("data", (d) => out += d.toString());
36709
+ child.stderr.on("data", (d) => err += d.toString());
36710
+ child.on("error", (e) => {
36711
+ clearTimeout(timer);
36712
+ reject(e);
36713
+ });
36714
+ child.on("close", (code) => {
36715
+ clearTimeout(timer);
36716
+ resolve8({ code: code ?? -1, out, err });
36717
+ });
36718
+ child.stdin.write(prompt);
36719
+ child.stdin.end();
36720
+ });
36721
+ }
36722
+ async function runMemoryCompaction(ai, cwd2, input) {
36723
+ const prompt = buildPrompt3(input);
36724
+ const attempts = ai.profiles.length > 0 ? ai.profiles : [null];
36725
+ for (let i = 0; i < attempts.length; i++) {
36726
+ try {
36727
+ const { code, out } = await runOnce2(ai.cmd, attempts[i], prompt, cwd2, ai.env);
36728
+ if (code === 0 && out.trim()) {
36729
+ reportToolResult(ai.cmd, true);
36730
+ return out.trim().slice(0, input.budgetChars);
36731
+ }
36732
+ if (i === attempts.length - 1) break;
36733
+ } catch {
36734
+ break;
36735
+ }
36736
+ }
36737
+ return "";
36738
+ }
36739
+
36590
36740
  // src/core/config-sync.ts
36591
36741
  import { existsSync as existsSync11, readFileSync as readFileSync15 } from "fs";
36592
36742
  import { join as join23 } from "path";
36593
- var SERVER_MANAGED_KEYS = ["physicPath", "sessionSwitchPct", "perPromptTokenLimit"];
36743
+ var SERVER_MANAGED_KEYS = [
36744
+ "physicPath",
36745
+ "sessionSwitchPct",
36746
+ "perPromptTokenLimit",
36747
+ // Project AI-run timeout override mirror (ADR-0243) — the ws_token refreshes it; a Worker-config
36748
+ // edit must not fight it. NOTE: the machine-user's own `aiRunTimeoutSec` is NOT here (operator-editable).
36749
+ "projectAiRunTimeoutSec",
36750
+ // Project idle auto-clear override mirror (ADR-0244) — same rule; the machine-user's own
36751
+ // `autoClearIdleMinutes` is operator-editable and NOT listed here.
36752
+ "projectAutoClearIdleMinutes",
36753
+ // Project shared-AI-memory override mirrors (ADR-0245) — the machine-user's own `aiMemoryEnabled`
36754
+ // / `aiMemoryBudgetChars` are operator-editable and NOT listed here.
36755
+ "projectAiMemoryMode",
36756
+ "projectAiMemoryBudgetChars"
36757
+ ];
36594
36758
  function readConfigText(profileDir2) {
36595
36759
  const path = join23(profileDir2, "config.json");
36596
36760
  if (existsSync11(path)) {
@@ -36622,7 +36786,7 @@ function applyConfigText(profileDir2, text) {
36622
36786
  }
36623
36787
 
36624
36788
  // src/core/worker-tools.ts
36625
- import { spawn as spawn8 } from "child_process";
36789
+ import { spawn as spawn9 } from "child_process";
36626
36790
  var NPM_NAME = /^(?:@[a-z0-9-~][a-z0-9-._~]*\/)?[a-z0-9-~][a-z0-9-._~]*$/;
36627
36791
  function run10(cmd, args, onLine, timeoutMs = 18e4) {
36628
36792
  return new Promise((resolve8) => {
@@ -36653,7 +36817,7 @@ function run10(cmd, args, onLine, timeoutMs = 18e4) {
36653
36817
  }
36654
36818
  };
36655
36819
  try {
36656
- child = spawn8(cmd, args, { stdio: ["ignore", "pipe", "pipe"] });
36820
+ child = spawn9(cmd, args, { stdio: ["ignore", "pipe", "pipe"] });
36657
36821
  child.stdout?.on("data", feed);
36658
36822
  child.stderr?.on("data", feed);
36659
36823
  child.on("exit", (code) => done(code ?? 0));
@@ -36741,6 +36905,7 @@ async function runWorkerToolOp(op, name, manager, onLine) {
36741
36905
  }
36742
36906
 
36743
36907
  // src/core/ws-client.ts
36908
+ var MEMORY_SEED_HEADER = "CONTEXT MEMORY from earlier in this conversation (may span prior sessions/accounts). Use it as background; do not repeat it back unless relevant:";
36744
36909
  var HEARTBEAT_MS = 3e4;
36745
36910
  var USAGE_POLL_MS = 5 * 6e4;
36746
36911
  var LOG_UPLOAD_MS = 10 * 6e4;
@@ -36780,6 +36945,7 @@ var WsClient = class {
36780
36945
  (entry) => this.emitConsole({ kind: "update", entry: toDtoEntry(entry) })
36781
36946
  );
36782
36947
  context.bus.onClear(() => this.emitConsoleClear());
36948
+ context.bus.onClearSession(() => this.resetMemorySession());
36783
36949
  }
36784
36950
  context;
36785
36951
  socket = null;
@@ -36805,6 +36971,13 @@ var WsClient = class {
36805
36971
  * when true the cli prepends a folder-scope guard to every AI prompt. Fed by each ws_token.
36806
36972
  */
36807
36973
  restrictToFolder = false;
36974
+ /**
36975
+ * Shared AI memory (ADR-0245) cache for the served project: the rolling compacted text (seeded by
36976
+ * `ws_token`, updated after each run) + the last native `session_id` per credential key (for
36977
+ * `--resume` on the same profile). Per (project × link) — this cli instance is one link.
36978
+ */
36979
+ aiMemory = "";
36980
+ sessionIdByKey = /* @__PURE__ */ new Map();
36808
36981
  stopped = false;
36809
36982
  /** Resolver to wake the backoff sleep early (set while waiting) — for /reconnect. */
36810
36983
  wakeReconnect = null;
@@ -37015,8 +37188,16 @@ var WsClient = class {
37015
37188
  }
37016
37189
  writeProfileConfig(this.context.profileDir, {
37017
37190
  sessionSwitchPct: token.projectTokens?.sessionSwitchPct ?? 0,
37018
- perPromptTokenLimit: token.projectTokens?.perPromptTokenLimit ?? 0
37191
+ perPromptTokenLimit: token.projectTokens?.perPromptTokenLimit ?? 0,
37192
+ // Project AI-run timeout override (ADR-0243) — 0 ⇒ inherit the machine-user setting.
37193
+ projectAiRunTimeoutSec: token.projectTokens?.aiRunTimeoutSec ?? 0,
37194
+ // Project idle auto-clear override (ADR-0244) — 0 ⇒ inherit the machine-user setting.
37195
+ projectAutoClearIdleMinutes: token.projectTokens?.autoClearIdleMinutes ?? 0,
37196
+ // Project shared-AI-memory override mirrors (ADR-0245).
37197
+ projectAiMemoryMode: token.aiMemory?.mode ?? "inherit",
37198
+ projectAiMemoryBudgetChars: token.aiMemory?.budgetChars ?? 0
37019
37199
  });
37200
+ if (token.aiMemory && !this.aiMemory) this.aiMemory = token.aiMemory.text;
37020
37201
  this.outboundReviewEnabled = token.outboundReview?.enabled === true;
37021
37202
  this.restrictToFolder = token.aiRestrictToFolder === true;
37022
37203
  applyGitAuth(token.gitAuth ?? null);
@@ -37636,6 +37817,10 @@ var WsClient = class {
37636
37817
  */
37637
37818
  async runAiPrompt(prompt, commandId, origin) {
37638
37819
  const config = readProfileConfig(this.context.profileDir);
37820
+ const projectTimeoutSec = config.projectAiRunTimeoutSec ?? 0;
37821
+ const machineTimeoutSec = config.aiRunTimeoutSec ?? 0;
37822
+ const timeoutSec = projectTimeoutSec > 0 ? projectTimeoutSec : machineTimeoutSec;
37823
+ const aiRunTimeoutMs = timeoutSec > 0 ? timeoutSec * 1e3 : 0;
37639
37824
  const perPromptLimit = config.perPromptTokenLimit ?? 0;
37640
37825
  if (perPromptLimit > 0) {
37641
37826
  const estimated = estimateTokens(prompt);
@@ -37687,12 +37872,22 @@ var WsClient = class {
37687
37872
  text: `session ${this.usageSnapshot?.session.utilizationPct ?? 0}% \u2265 ${config.sessionSwitchPct}% \u2014 switching to Claude profile "${labelFromCredentialKey(startCred)}"`
37688
37873
  });
37689
37874
  }
37690
- const effectivePrompt = this.restrictToFolder && this.physicRoot ? folderScopeGuard(this.physicRoot, prompt) : prompt;
37691
- const plan = planAiRun(
37692
- effectivePrompt,
37693
- config,
37694
- unified ? { credential: startCred ?? workingCred } : { dir: startDir ?? workingDir }
37695
- );
37875
+ const guardedPrompt = this.restrictToFolder && this.physicRoot ? folderScopeGuard(this.physicRoot, prompt) : prompt;
37876
+ const hint = unified ? { credential: startCred ?? workingCred } : { dir: startDir ?? workingDir };
37877
+ const memCfg = resolveMemoryConfig(config);
37878
+ const firstAttempt = planAiRun(guardedPrompt, config, hint).attempts[0];
37879
+ const resumeId = memCfg.enabled && firstAttempt?.key && firstAttempt.cmd === "claude" ? this.sessionIdByKey.get(firstAttempt.key) : void 0;
37880
+ let effectivePrompt = guardedPrompt;
37881
+ let resume = /* @__PURE__ */ new Map();
37882
+ if (resumeId) {
37883
+ resume = this.sessionIdByKey;
37884
+ } else if (memCfg.enabled && this.aiMemory) {
37885
+ effectivePrompt = `${MEMORY_SEED_HEADER}
37886
+ ${this.aiMemory}
37887
+
37888
+ ${guardedPrompt}`;
37889
+ }
37890
+ const plan = planAiRun(effectivePrompt, config, hint, resume);
37696
37891
  const markerArgs = plan.attempts[0]?.args ?? [];
37697
37892
  this.bus.push({ source: origin, kind: "aireq", text: `${plan.cmd} \u2039 ${prompt}` });
37698
37893
  logger.info("command.ai", { commandId, origin, cmd: plan.cmd, profiles: plan.attempts.length });
@@ -37714,12 +37909,14 @@ var WsClient = class {
37714
37909
  let displayMode = "pending";
37715
37910
  let resultId = null;
37716
37911
  let resultBuf = "";
37717
- const result = await runAiFailover(plan, commandId, cwd2, {
37912
+ let answerText = "";
37913
+ const handlers = {
37718
37914
  onChunk: (text) => {
37719
37915
  if (!responseHeaderShown) {
37720
37916
  this.bus.push({ source: origin, kind: "aires", text: `${plan.cmd} \u203A` });
37721
37917
  responseHeaderShown = true;
37722
37918
  }
37919
+ answerText += text;
37723
37920
  appendCommandOutput(commandId, text);
37724
37921
  this.send(WsChannels.COMMAND_OUTPUT, { commandId, seq: seq++, chunk: text });
37725
37922
  if (displayMode === "result") {
@@ -37754,8 +37951,13 @@ var WsClient = class {
37754
37951
  this.send(WsChannels.COMMAND_OUTPUT, { commandId, seq: seq++, chunk: `${text}
37755
37952
  `, log: true });
37756
37953
  }
37757
- });
37758
- this.bus.endBusy(plan.cmd);
37954
+ };
37955
+ let result;
37956
+ try {
37957
+ result = await runAiFailover(plan, commandId, cwd2, handlers, aiRunTimeoutMs);
37958
+ } finally {
37959
+ this.bus.endBusy(plan.cmd);
37960
+ }
37759
37961
  const profileLabel = result.workedDir ? profileDisplayLabel(result.workedDir) : null;
37760
37962
  if (result.workedDir) {
37761
37963
  if (unified && result.workedKey) {
@@ -37819,6 +38021,43 @@ var WsClient = class {
37819
38021
  text: result.exitCode === 0 ? "\u2713 ready \u2014 enter your next prompt" : `\u2717 ${plan.cmd} failed (exit ${result.exitCode}) \u2014 see the error above`,
37820
38022
  level: result.exitCode === 0 ? "info" : "error"
37821
38023
  });
38024
+ if (memCfg.enabled && result.exitCode === 0) {
38025
+ if (result.workedKey && result.sessionId) {
38026
+ this.sessionIdByKey.set(result.workedKey, result.sessionId);
38027
+ }
38028
+ void this.updateSharedMemory(config, prompt, answerText, memCfg.budgetChars).catch(() => {
38029
+ });
38030
+ }
38031
+ }
38032
+ /**
38033
+ * Fold the latest exchange into the shared AI memory (ADR-0245): a background claude compaction to a
38034
+ * budget-bounded summary, cached locally + written back to the server (`memory.update`). Best-effort —
38035
+ * a failed/empty compaction keeps the previous memory (no write). Claude-only (mirrors the design's
38036
+ * native-session focus); a non-claude worker simply never compacts.
38037
+ */
38038
+ async updateSharedMemory(config, userPrompt, answer, budgetChars) {
38039
+ if (!answer.trim()) return;
38040
+ const profiles = resolveClaudeProfiles(config, this.physicRoot);
38041
+ const cwd2 = this.physicRoot ?? process.cwd();
38042
+ const newMemory = await runMemoryCompaction(
38043
+ { cmd: "claude", profiles, env: config.aiEnv },
38044
+ cwd2,
38045
+ { oldMemory: this.aiMemory, prompt: userPrompt, answer, budgetChars }
38046
+ );
38047
+ if (!newMemory) return;
38048
+ this.aiMemory = newMemory;
38049
+ this.send(WsChannels.MEMORY_UPDATE, { text: newMemory });
38050
+ }
38051
+ /**
38052
+ * Reset the shared AI memory + native sessions for a "new conversation" (ADR-0245) — a manual
38053
+ * `/clear`. Drops the cached memory + every remembered `session_id` (so the next run starts a fresh
38054
+ * native session) and clears the server-stored memory. Idle auto-clear does NOT call this
38055
+ * (display-only — ADR-0244).
38056
+ */
38057
+ resetMemorySession() {
38058
+ this.aiMemory = "";
38059
+ this.sessionIdByKey.clear();
38060
+ this.send(WsChannels.MEMORY_UPDATE, { text: "" });
37822
38061
  }
37823
38062
  /**
37824
38063
  * Pick the Claude profile to try first when under session pressure (ADR-0081). Returns
@@ -38379,6 +38618,18 @@ var SessionBus = class {
38379
38618
  this.emitter.on("clear", fn);
38380
38619
  return () => this.emitter.off("clear", fn);
38381
38620
  }
38621
+ /**
38622
+ * Signal a "new conversation" (a MANUAL `/clear`, ADR-0245) — distinct from `clear()`, which the
38623
+ * idle auto-clear (ADR-0244) also calls. Consumers reset the shared AI memory + native session.
38624
+ */
38625
+ clearSession() {
38626
+ this.emitter.emit("clearSession");
38627
+ }
38628
+ /** Subscribe to a manual `/clear` reset (shared-memory + native-session reset — ADR-0245). */
38629
+ onClearSession(fn) {
38630
+ this.emitter.on("clearSession", fn);
38631
+ return () => this.emitter.off("clearSession", fn);
38632
+ }
38382
38633
  /** The current transcript entries (a copy) — the console-sync snapshot source (ADR-0150). */
38383
38634
  snapshot() {
38384
38635
  return [...this.history];
@@ -38684,14 +38935,13 @@ function startControlServer(bus, profileDir2, info) {
38684
38935
 
38685
38936
  // src/core/idle-auto-clear.ts
38686
38937
  function startIdleAutoClear(bus, idleMinutes) {
38687
- const idleMs = Math.max(0, idleMinutes) * 6e4;
38688
- if (idleMs <= 0) return () => {
38689
- };
38690
38938
  let lastActivity = Date.now();
38691
38939
  const off = bus.onTranscript(() => {
38692
38940
  lastActivity = Date.now();
38693
38941
  });
38694
38942
  const timer = setInterval(() => {
38943
+ const idleMs = Math.max(0, idleMinutes()) * 6e4;
38944
+ if (idleMs <= 0) return;
38695
38945
  if (bus.busy !== null || bus.snapshot().length === 0) return;
38696
38946
  if (Date.now() - lastActivity < idleMs) return;
38697
38947
  bus.clear();
@@ -45083,10 +45333,6 @@ function App2({
45083
45333
  const [history, setHistory] = (0, import_react26.useState)(() => loadInputHistory(info.profileDir));
45084
45334
  const [startedAt] = (0, import_react26.useState)(() => Date.now());
45085
45335
  const [pendingConfirm, setPendingConfirm] = (0, import_react26.useState)(null);
45086
- const [autoClearIdleMs] = (0, import_react26.useState)(() => {
45087
- const min = readProfileConfig(info.profileDir).autoClearIdleMinutes ?? 10;
45088
- return min > 0 ? min * 6e4 : 0;
45089
- });
45090
45336
  const lastActivityRef = (0, import_react26.useRef)(startedAt);
45091
45337
  const busyRef = (0, import_react26.useRef)(busy);
45092
45338
  busyRef.current = busy;
@@ -45151,20 +45397,21 @@ function App2({
45151
45397
  };
45152
45398
  }, [bus, flushUpdates]);
45153
45399
  (0, import_react26.useEffect)(() => {
45154
- if (autoClearIdleMs <= 0) return;
45155
45400
  const timer = setInterval(() => {
45401
+ const idleMs = resolveIdleAutoClearMinutes(readProfileConfig(info.profileDir)) * 6e4;
45402
+ if (idleMs <= 0) return;
45156
45403
  if (busyRef.current !== null || pendingConfirmRef.current || entriesLenRef.current === 0) return;
45157
- if (Date.now() - lastActivityRef.current < autoClearIdleMs) return;
45404
+ if (Date.now() - lastActivityRef.current < idleMs) return;
45158
45405
  clearTranscript();
45159
45406
  bus.push({
45160
45407
  source: "system",
45161
45408
  kind: "log",
45162
- text: `Transcript auto-cleared after ${Math.round(autoClearIdleMs / 6e4)} min idle.`
45409
+ text: `Transcript auto-cleared after ${Math.round(idleMs / 6e4)} min idle.`
45163
45410
  });
45164
45411
  lastActivityRef.current = Date.now();
45165
45412
  }, 6e4);
45166
45413
  return () => clearInterval(timer);
45167
- }, [autoClearIdleMs, bus, clearTranscript]);
45414
+ }, [info.profileDir, bus, clearTranscript]);
45168
45415
  const frameRows = Math.max(4, rows - 1);
45169
45416
  const bodyHeight = Math.max(3, frameRows - CHROME_ROWS);
45170
45417
  const textWidth = Math.max(10, cols - 1 - 8);
@@ -45207,7 +45454,12 @@ function App2({
45207
45454
  runSlashCommand(trimmed, {
45208
45455
  info,
45209
45456
  print: (text, level) => bus.push({ source: "local", kind: "log", text, level }),
45210
- clear: clearTranscript,
45457
+ // A manual `/clear` also resets the shared AI memory + native session (ADR-0245); the idle
45458
+ // auto-clear path calls clearTranscript() directly (display-only), so it does NOT reset.
45459
+ clear: () => {
45460
+ clearTranscript();
45461
+ bus.clearSession();
45462
+ },
45211
45463
  quit: () => exit(),
45212
45464
  confirm: (prompt, onYes) => {
45213
45465
  bus.push({ source: "local", kind: "log", text: prompt });
@@ -45285,6 +45537,7 @@ async function runStart(profileDir2, profileName) {
45285
45537
  });
45286
45538
  logger.info("cli.start", { serverUrl: credential.serverUrl });
45287
45539
  ensureProfileConfig(profileDir2);
45540
+ backfillProfileConfig(profileDir2);
45288
45541
  const config = readProfileConfig(profileDir2);
45289
45542
  const autoUpdate = config.autoUpdate !== false && process.env.FOURPM_NO_UPDATE !== "1";
45290
45543
  const result = await checkAndUpdate(credential.serverUrl, autoUpdate);
@@ -45299,7 +45552,7 @@ async function runStart(profileDir2, profileName) {
45299
45552
  if (result.action === "updated") {
45300
45553
  logger.info("update.updated", { version: result.version });
45301
45554
  console.log("Restarting with the new version\u2026");
45302
- const child = spawn9(process.execPath, process.argv.slice(1), {
45555
+ const child = spawn10(process.execPath, process.argv.slice(1), {
45303
45556
  stdio: "inherit",
45304
45557
  env: { ...process.env, FOURPM_NO_UPDATE: "1" }
45305
45558
  });
@@ -45373,7 +45626,10 @@ async function runStart(profileDir2, profileName) {
45373
45626
  await tui;
45374
45627
  } else {
45375
45628
  attachConsoleSink(bus);
45376
- stopIdleClear = startIdleAutoClear(bus, config.autoClearIdleMinutes ?? 10);
45629
+ stopIdleClear = startIdleAutoClear(
45630
+ bus,
45631
+ () => resolveIdleAutoClearMinutes(readProfileConfig(profileDir2))
45632
+ );
45377
45633
  bus.log(`Connecting to ${credential.serverUrl} (profile: ${profileName})\u2026`);
45378
45634
  await runResilient();
45379
45635
  }
@@ -45516,12 +45772,12 @@ async function runAttach(profileDir2, profileName) {
45516
45772
  }
45517
45773
 
45518
45774
  // src/commands/ai-login.ts
45519
- import { spawn as spawn10 } from "child_process";
45775
+ import { spawn as spawn11 } from "child_process";
45520
45776
  import { mkdirSync as mkdirSync6 } from "fs";
45521
45777
  import { basename as basename5 } from "path";
45522
45778
  function runProviderLogin(cmd, envVar, dir2) {
45523
45779
  return new Promise((resolve8) => {
45524
- const child = spawn10(cmd, [], {
45780
+ const child = spawn11(cmd, [], {
45525
45781
  stdio: "inherit",
45526
45782
  env: { ...process.env, [envVar]: dir2 }
45527
45783
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@4pm/cli",
3
- "version": "1.4.12",
3
+ "version": "1.5.0",
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",