@4pm/cli 1.5.0 → 1.5.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.
Files changed (2) hide show
  1. package/dist/index.js +855 -636
  2. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -28316,6 +28316,11 @@ function defaultProfileConfig() {
28316
28316
  // Terminate a single AI run after 5 min by default (ADR-0243) so a hung/looping AI CLI
28317
28317
  // (e.g. a heavy spec review/compose) can't spin the dispatcher forever; 0 = no limit.
28318
28318
  aiRunTimeoutSec: 300,
28319
+ // Web-Console blocked slash commands (ADR-0249): block the two that tamper with / kill the
28320
+ // worker by default — `/quit` (stops the cli — DoS) and `/config` (writes config.json). Every
28321
+ // other command (incl. /reconnect, /logs — non-destructive) is allowed; operators adjust this
28322
+ // list in the Worker config. Empty ⇒ allow all.
28323
+ webBlockedCommands: ["quit", "config"],
28319
28324
  // Shared AI memory (ADR-0245) — off by default (opt-in; costs a compaction call per turn).
28320
28325
  aiMemoryEnabled: false,
28321
28326
  aiMemoryBudgetChars: 6e3
@@ -28332,6 +28337,11 @@ function resolveIdleAutoClearMinutes(config) {
28332
28337
  const project = config.projectAutoClearIdleMinutes ?? 0;
28333
28338
  return project > 0 ? project : config.autoClearIdleMinutes ?? 10;
28334
28339
  }
28340
+ var DEFAULT_WEB_BLOCKED_COMMANDS = ["quit", "config"];
28341
+ function resolveWebBlockedCommands(config) {
28342
+ const list = Array.isArray(config.webBlockedCommands) ? config.webBlockedCommands : DEFAULT_WEB_BLOCKED_COMMANDS;
28343
+ return new Set(list.map((c) => String(c).trim().toLowerCase()).filter(Boolean));
28344
+ }
28335
28345
  function ensureProfileConfig(dir2) {
28336
28346
  if (existsSync(join(dir2, "config.json"))) return false;
28337
28347
  writeProfileConfig(dir2, defaultProfileConfig());
@@ -28765,7 +28775,7 @@ async function checkAndUpdate(serverUrl, autoUpdate) {
28765
28775
  // src/core/ws-client.ts
28766
28776
  init_wrapper();
28767
28777
  import { randomUUID } from "crypto";
28768
- import { basename as basename4, join as join24 } from "path";
28778
+ import { basename as basename4, join as join25 } from "path";
28769
28779
 
28770
28780
  // vendor/@4pm/utils/helpers.ts
28771
28781
  function backoffJitterMs(attempt, baseMs = 500, maxMs = 1e4) {
@@ -32932,7 +32942,15 @@ var dispatchCommandRequestSchema = external_exports.object({
32932
32942
  * `project.git_write` (`write`) instead of `command.execute`, and validates `command`
32933
32943
  * against the git allowlist (`isGitCommandAllowed`). Absent ⇒ normal console dispatch.
32934
32944
  */
32935
- gitOp: external_exports.enum(["read", "write"]).optional()
32945
+ gitOp: external_exports.enum(["read", "write"]).optional(),
32946
+ /**
32947
+ * One-shot AI mode (ADR-0249): the prompt is a text-in → text-out task (spec review /
32948
+ * compose / suggest / generators) that must NOT trigger the AI CLI's agentic tool loop.
32949
+ * The cli caps such a run (`--max-turns 1` + disallowed agentic tools) so it can't wander
32950
+ * the repo / edit files / loop forever. Only meaningful with `ai:true`; absent ⇒ a full
32951
+ * agent run (the Console tab + Git merge, which legitimately use tools).
32952
+ */
32953
+ aiOneShot: external_exports.boolean().optional()
32936
32954
  }).superRefine((v, ctx) => {
32937
32955
  if (v.machineLinkId == null === (v.pick == null)) {
32938
32956
  ctx.addIssue({
@@ -32955,10 +32973,29 @@ var dispatchCommandRequestSchema = external_exports.object({
32955
32973
  var listCommandsQuerySchema = baseRequestSchema.extend({
32956
32974
  projectId: external_exports.string().uuid(),
32957
32975
  // Optional search filters (command-0005): `search` (from BaseRequest) matches the command
32958
- // text/status; `from`/`to` are an inclusive `YYYY-MM-DD` date range over `startedAt`.
32976
+ // text/status; `from`/`to` are an inclusive `YYYY-MM-DD` date range over `startedAt`;
32977
+ // `machineLinkId` (ADR-0249) filters to one machine-user's commands (absent ⇒ all).
32959
32978
  from: external_exports.string().optional(),
32960
- to: external_exports.string().optional()
32979
+ to: external_exports.string().optional(),
32980
+ machineLinkId: external_exports.string().uuid().optional()
32961
32981
  });
32982
+ var CLI_SLASH_COMMANDS = [
32983
+ { name: "help", usage: "/help", description: "List slash commands" },
32984
+ { name: "version", usage: "/version", description: "Show the cli version" },
32985
+ { name: "status", usage: "/status", description: "Show connection + profile + session status" },
32986
+ { name: "history", usage: "/history [N]", description: "List the last N executed commands (+ time)" },
32987
+ { name: "output", usage: "/output <n>", description: "Replay a command's output (n from /history)" },
32988
+ { name: "logs", usage: "/logs [N]", description: "Tail the profile's structured log" },
32989
+ { name: "expand", usage: "/expand [N]", description: "Expand a collapsed \u25B8[N] block" },
32990
+ { name: "collapse", usage: "/collapse [N]", description: "Collapse an expanded \u25B8[N] block" },
32991
+ { name: "reconnect", usage: "/reconnect", description: "Reconnect to the server now (skip the backoff)" },
32992
+ { name: "whoami", usage: "/whoami", description: "Show this machine account + its teams & projects" },
32993
+ { name: "config", usage: "/config [show|init|set <k> <v>|delete <k>]", description: "View / init / update / delete profile config" },
32994
+ { name: "claude-cmd", usage: "/claude-cmd /context", description: "Run an AI-CLI slash command (e.g. /context, /usage)" },
32995
+ { name: "clear", usage: "/clear", description: "Clear the transcript" },
32996
+ { name: "quit", usage: "/quit", description: "Quit the cli (also /exit)" }
32997
+ ];
32998
+ var CLI_SLASH_COMMAND_NAMES = CLI_SLASH_COMMANDS.map((c) => c.name);
32962
32999
 
32963
33000
  // vendor/@4pm/validation/schemas.ts
32964
33001
  var passwordSchema = external_exports.string().min(8, "password: at least 8 characters").max(32, "password: at most 32 characters").regex(/[a-zA-Z]/, "password: needs at least 1 letter").regex(/[0-9]/, "password: needs at least 1 digit");
@@ -33792,7 +33829,7 @@ function decryptPayload(key, payloadBase64, nonceBase64) {
33792
33829
  }
33793
33830
 
33794
33831
  // src/core/ws-client.ts
33795
- import { existsSync as existsSync12, mkdirSync as mkdirSync5, rmSync as rmSync4 } from "fs";
33832
+ import { existsSync as existsSync13, mkdirSync as mkdirSync5, rmSync as rmSync4 } from "fs";
33796
33833
  import { randomUUID as randomCommandId } from "crypto";
33797
33834
 
33798
33835
  // src/core/update-scheduler.ts
@@ -34117,15 +34154,24 @@ function createAiStreamParser(cli) {
34117
34154
  let buffer = "";
34118
34155
  const acc = { tokens: 0, input: 0, output: 0, cacheRead: 0, cacheCreation: 0 };
34119
34156
  let sessionId = "";
34157
+ let apiError = { isError: false, status: null };
34120
34158
  function handleClaude(ev) {
34121
34159
  if (typeof ev.session_id === "string" && ev.session_id) sessionId = ev.session_id;
34122
- if (ev.type === "result" && ev.usage) {
34123
- acc.input = ev.usage.input_tokens ?? 0;
34124
- acc.output = ev.usage.output_tokens ?? 0;
34125
- acc.cacheRead = ev.usage.cache_read_input_tokens ?? 0;
34126
- acc.cacheCreation = ev.usage.cache_creation_input_tokens ?? 0;
34127
- acc.tokens = acc.input + acc.output;
34128
- if (typeof ev.total_cost_usd === "number") acc.costUsd = ev.total_cost_usd;
34160
+ if (ev.type === "result") {
34161
+ if (ev.is_error === true) {
34162
+ apiError = {
34163
+ isError: true,
34164
+ status: typeof ev.api_error_status === "number" ? ev.api_error_status : null
34165
+ };
34166
+ }
34167
+ if (ev.usage) {
34168
+ acc.input = ev.usage.input_tokens ?? 0;
34169
+ acc.output = ev.usage.output_tokens ?? 0;
34170
+ acc.cacheRead = ev.usage.cache_read_input_tokens ?? 0;
34171
+ acc.cacheCreation = ev.usage.cache_creation_input_tokens ?? 0;
34172
+ acc.tokens = acc.input + acc.output;
34173
+ if (typeof ev.total_cost_usd === "number") acc.costUsd = ev.total_cost_usd;
34174
+ }
34129
34175
  return "";
34130
34176
  }
34131
34177
  if (ev.type === "assistant") {
@@ -34185,7 +34231,8 @@ function createAiStreamParser(cli) {
34185
34231
  return handled === null ? line : handled;
34186
34232
  },
34187
34233
  usage: () => ({ ...acc }),
34188
- sessionId: () => sessionId
34234
+ sessionId: () => sessionId,
34235
+ apiError: () => ({ ...apiError })
34189
34236
  };
34190
34237
  }
34191
34238
  function estimateTokens(text) {
@@ -34203,6 +34250,11 @@ function isSessionLimit(text) {
34203
34250
  text
34204
34251
  );
34205
34252
  }
34253
+ function isOutOfCredits(text) {
34254
+ return /out of (?:usage )?credits|credits[_ ]required|out[_ ]of[_ ]credits|insufficient credits/i.test(
34255
+ text
34256
+ );
34257
+ }
34206
34258
  function runAttempt(commandId, cmd, args, cwd2, env3, onChunk, timeoutMs) {
34207
34259
  return new Promise((resolve8) => {
34208
34260
  void runCommand({ commandId, cmd, args, path: cwd2, env: env3 }, (out) => {
@@ -34236,7 +34288,8 @@ async function runAiFailover(plan, commandId, cwd2, handlers, timeoutMs = 0) {
34236
34288
  handlers.onChunk(tail);
34237
34289
  }
34238
34290
  lastCaptured = captured;
34239
- if (finalExit === 0) {
34291
+ const apiErr = parser.apiError();
34292
+ if (finalExit === 0 && !apiErr.isError) {
34240
34293
  const usage = parser.usage();
34241
34294
  if (usage.tokens === 0) usage.tokens = estimateTokens(captured);
34242
34295
  reportToolResult(attempt.cmd, true);
@@ -34249,7 +34302,8 @@ async function runAiFailover(plan, commandId, cwd2, handlers, timeoutMs = 0) {
34249
34302
  sessionId: parser.sessionId()
34250
34303
  };
34251
34304
  }
34252
- const reason = isAuthFailure(captured) ? "auth" : isSessionLimit(captured) ? "limit" : "other";
34305
+ if (finalExit === 0 && apiErr.isError) finalExit = apiErr.status && apiErr.status > 0 ? apiErr.status : 1;
34306
+ const reason = isAuthFailure(captured) ? "auth" : apiErr.status === 429 || isOutOfCredits(captured) ? "credits" : isSessionLimit(captured) ? "limit" : "other";
34253
34307
  if (i === total - 1) break;
34254
34308
  handlers.onAttemptFail(attempt.label, reason);
34255
34309
  }
@@ -34258,6 +34312,7 @@ async function runAiFailover(plan, commandId, cwd2, handlers, timeoutMs = 0) {
34258
34312
  }
34259
34313
  function summarizeAiFailure(captured, exitCode) {
34260
34314
  if (isAuthFailure(captured)) return "Not logged in";
34315
+ if (isOutOfCredits(captured)) return "Out of usage credits";
34261
34316
  if (isSessionLimit(captured)) return "Usage limit reached";
34262
34317
  const firstLine = captured.split("\n").map((l) => l.trim()).find((l) => l.length > 0);
34263
34318
  return firstLine || `exited ${exitCode}`;
@@ -34313,6 +34368,42 @@ var REQUIRED_AI_ARGS = {
34313
34368
  codex: ["exec", "--json"],
34314
34369
  antigravity: []
34315
34370
  };
34371
+ var ONE_SHOT_DISALLOWED_CLAUDE_TOOLS = [
34372
+ "Bash",
34373
+ "Edit",
34374
+ "Write",
34375
+ "Read",
34376
+ "Glob",
34377
+ "Grep",
34378
+ "NotebookEdit",
34379
+ "Task",
34380
+ "WebSearch",
34381
+ "WebFetch",
34382
+ "Skill",
34383
+ "SlashCommand",
34384
+ "ToolSearch",
34385
+ "TaskCreate",
34386
+ "TaskGet",
34387
+ "TaskList",
34388
+ "TaskOutput",
34389
+ "TaskStop",
34390
+ "TaskUpdate",
34391
+ "Monitor",
34392
+ "DesignSync",
34393
+ "CronCreate",
34394
+ "CronDelete",
34395
+ "CronList",
34396
+ "EnterWorktree",
34397
+ "ExitWorktree",
34398
+ "RemoteTrigger",
34399
+ "ScheduleWakeup",
34400
+ "SendMessage",
34401
+ "PushNotification"
34402
+ ].join(",");
34403
+ function oneShotArgs(cmd) {
34404
+ if (!cmd.includes("claude")) return [];
34405
+ return ["--max-turns=1", "--disallowedTools", ONE_SHOT_DISALLOWED_CLAUDE_TOOLS, "--permission-mode", "default"];
34406
+ }
34316
34407
  function requiredArgs(cmd) {
34317
34408
  if (cmd.includes("claude")) return REQUIRED_AI_ARGS.claude;
34318
34409
  if (cmd.includes("codex")) return REQUIRED_AI_ARGS.codex;
@@ -34372,12 +34463,15 @@ function folderScopeGuard(folder, prompt) {
34372
34463
  prompt
34373
34464
  ].join("\n");
34374
34465
  }
34375
- function buildRunArgs(profile, cmd, prompt, resumeId) {
34466
+ function buildRunArgs(profile, cmd, prompt, resumeId, oneShot = false) {
34376
34467
  const extras = profile.args ?? [];
34377
34468
  const model = profile.model?.trim();
34378
34469
  const modelArgs = model ? ["--model", model] : [];
34379
34470
  const resumeArgs = resumeId && cmd.includes("claude") ? ["--resume", resumeId] : [];
34380
- return [...requiredArgs(cmd), ...resumeArgs, ...extras, ...modelArgs, prompt];
34471
+ const turns = profile.maxTurns;
34472
+ const perProfileTurns = !oneShot && cmd.includes("claude") && typeof turns === "number" && turns > 0 ? [`--max-turns=${Math.floor(turns)}`] : [];
34473
+ const oneShotExtra = oneShot ? oneShotArgs(cmd) : perProfileTurns;
34474
+ return [...requiredArgs(cmd), ...resumeArgs, ...extras, ...modelArgs, ...oneShotExtra, prompt];
34381
34475
  }
34382
34476
  function claudeHomeDirs(config) {
34383
34477
  const dirs = profileDirs(claudeProfilesOf(config));
@@ -34416,10 +34510,10 @@ function resolveClaudeProfiles(config, workingDir) {
34416
34510
  if (idx > 0) profiles.unshift(profiles.splice(idx, 1)[0]);
34417
34511
  return profiles;
34418
34512
  }
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);
34513
+ function planAiRun(prompt, config, hint = {}, resume = /* @__PURE__ */ new Map(), oneShot = false) {
34514
+ return isUnifiedConfig(config) ? planUnifiedRun(prompt, config, hint.credential ?? null, resume, oneShot) : planLegacyRun(prompt, config, hint.dir ?? null, resume, oneShot);
34421
34515
  }
34422
- function planUnifiedRun(prompt, config, workingCredential, resume) {
34516
+ function planUnifiedRun(prompt, config, workingCredential, resume, oneShot) {
34423
34517
  const baseEnv = config.aiEnv ?? {};
34424
34518
  const active = activeProvider(config);
34425
34519
  const usable = config.aiProfiles.filter(isUsableCredential);
@@ -34428,7 +34522,7 @@ function planUnifiedRun(prompt, config, workingCredential, resume) {
34428
34522
  return {
34429
34523
  cmd: active,
34430
34524
  attempts: [
34431
- { cmd: active, label: "default", dir: null, key: null, args: buildRunArgs({ profile: "" }, active, prompt), env: { ...baseEnv } }
34525
+ { cmd: active, label: "default", dir: null, key: null, args: buildRunArgs({ profile: "" }, active, prompt, void 0, oneShot), env: { ...baseEnv } }
34432
34526
  ]
34433
34527
  };
34434
34528
  }
@@ -34446,19 +34540,19 @@ function planUnifiedRun(prompt, config, workingCredential, resume) {
34446
34540
  label: cred.label?.trim() || profileDisplayLabel(dir2),
34447
34541
  dir: dir2,
34448
34542
  key,
34449
- args: buildRunArgs(cred, cmd, prompt, resume.get(key)),
34543
+ args: buildRunArgs(cred, cmd, prompt, resume.get(key), oneShot),
34450
34544
  env: envVar ? { ...baseEnv, [envVar]: dir2 } : { ...baseEnv }
34451
34545
  }));
34452
34546
  return { cmd: attempts[0]?.cmd ?? DEFAULT_AI_CLI, attempts };
34453
34547
  }
34454
- function planLegacyRun(prompt, config, workingDir, resume) {
34548
+ function planLegacyRun(prompt, config, workingDir, resume, oneShot) {
34455
34549
  const cmd = config.aiCli || DEFAULT_AI_CLI;
34456
34550
  const baseEnv = config.aiEnv ?? {};
34457
34551
  const envVar = profileEnvVar(cmd);
34458
34552
  const defaultPlan = {
34459
34553
  cmd,
34460
34554
  attempts: [
34461
- { cmd, label: "default", dir: null, key: null, args: buildRunArgs({ profile: "" }, cmd, prompt), env: { ...baseEnv } }
34555
+ { cmd, label: "default", dir: null, key: null, args: buildRunArgs({ profile: "" }, cmd, prompt, void 0, oneShot), env: { ...baseEnv } }
34462
34556
  ]
34463
34557
  };
34464
34558
  if (!envVar) return defaultPlan;
@@ -34475,7 +34569,7 @@ function planLegacyRun(prompt, config, workingDir, resume) {
34475
34569
  label: profileDisplayLabel(dir2),
34476
34570
  dir: dir2,
34477
34571
  key,
34478
- args: buildRunArgs(profile, cmd, prompt, resume.get(key)),
34572
+ args: buildRunArgs(profile, cmd, prompt, resume.get(key), oneShot),
34479
34573
  env: { ...baseEnv, [envVar]: dir2 }
34480
34574
  };
34481
34575
  });
@@ -36668,188 +36762,510 @@ async function addProject(payload, profileDir2, onProgress) {
36668
36762
  }
36669
36763
  }
36670
36764
 
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
-
36740
- // src/core/config-sync.ts
36741
- import { existsSync as existsSync11, readFileSync as readFileSync15 } from "fs";
36765
+ // src/ui/slash-commands.ts
36766
+ import { existsSync as existsSync11 } from "fs";
36742
36767
  import { join as join23 } from "path";
36743
- var SERVER_MANAGED_KEYS = [
36768
+ var CONFIG_KEYS = [
36769
+ "aiCli",
36770
+ "claudeHome",
36771
+ "codexHome",
36772
+ "antigravityHome",
36744
36773
  "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"
36774
+ "commandHistoryUploadMinutes",
36775
+ "autoUpdate",
36776
+ "aiEnv"
36757
36777
  ];
36758
- function readConfigText(profileDir2) {
36759
- const path = join23(profileDir2, "config.json");
36760
- if (existsSync11(path)) {
36761
- try {
36762
- return readFileSync15(path, "utf8");
36763
- } catch {
36778
+ function configPath(profileDir2) {
36779
+ return join23(profileDir2, "config.json");
36780
+ }
36781
+ function parseConfigAssignment(key, valueTokens) {
36782
+ const value = valueTokens.join(" ").trim();
36783
+ switch (key) {
36784
+ case "aiCli":
36785
+ case "physicPath":
36786
+ if (!value) return { error: `Missing value for "${key}".` };
36787
+ return { patch: { [key]: value } };
36788
+ case "claudeHome":
36789
+ case "codexHome":
36790
+ case "antigravityHome": {
36791
+ const dirs = valueTokens.filter((t) => t.trim());
36792
+ if (dirs.length === 0) return { error: `Missing value for "${key}".` };
36793
+ return { patch: { [key]: dirs.map((profile) => ({ profile, model: "" })) } };
36794
+ }
36795
+ case "commandHistoryUploadMinutes": {
36796
+ const n = Number(value);
36797
+ if (!Number.isFinite(n) || n <= 0) return { error: `"${value}" is not a positive number.` };
36798
+ return { patch: { commandHistoryUploadMinutes: n } };
36764
36799
  }
36800
+ case "autoUpdate":
36801
+ if (value !== "true" && value !== "false") return { error: `Use true|false, not "${value}".` };
36802
+ return { patch: { autoUpdate: value === "true" } };
36803
+ case "aiEnv":
36804
+ try {
36805
+ return { patch: { aiEnv: JSON.parse(value) } };
36806
+ } catch {
36807
+ return { error: `aiEnv must be JSON, e.g. {"KEY":"val"}.` };
36808
+ }
36809
+ default:
36810
+ return { error: `Unknown key "${key}". Allowed: ${CONFIG_KEYS.join(", ")}.` };
36765
36811
  }
36766
- return JSON.stringify(defaultProfileConfig(), null, 2);
36767
36812
  }
36768
- function applyConfigText(profileDir2, text) {
36769
- let parsed;
36770
- try {
36771
- parsed = JSON.parse(text);
36772
- } catch (err) {
36773
- return { ok: false, error: `Invalid JSON: ${err.message}` };
36774
- }
36775
- if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
36776
- return { ok: false, error: "config.json must be a JSON object." };
36777
- }
36778
- const next = { ...parsed };
36779
- const current = readProfileConfig(profileDir2);
36780
- for (const key of SERVER_MANAGED_KEYS) {
36781
- if (current[key] !== void 0) next[key] = current[key];
36782
- else delete next[key];
36813
+ function runConfig(ctx) {
36814
+ const [sub, ...rest] = ctx.args;
36815
+ const dir2 = ctx.info.profileDir;
36816
+ const path = configPath(dir2);
36817
+ if (!sub || sub === "show") {
36818
+ ctx.print(`config: ${path}`);
36819
+ const json = JSON.stringify(readProfileConfig(dir2), null, 2);
36820
+ for (const line of json.split("\n")) ctx.print(line);
36821
+ return;
36783
36822
  }
36784
- overwriteProfileConfig(profileDir2, next);
36785
- return { ok: true };
36786
- }
36787
-
36788
- // src/core/worker-tools.ts
36789
- import { spawn as spawn9 } from "child_process";
36790
- var NPM_NAME = /^(?:@[a-z0-9-~][a-z0-9-._~]*\/)?[a-z0-9-~][a-z0-9-._~]*$/;
36791
- function run10(cmd, args, onLine, timeoutMs = 18e4) {
36792
- return new Promise((resolve8) => {
36793
- let out = "";
36794
- let buf = "";
36795
- let child = null;
36796
- const done = (code) => {
36797
- clearTimeout(timer);
36798
- if (onLine && buf.trim()) onLine(buf.trim());
36799
- resolve8({ code, out });
36800
- };
36801
- const timer = setTimeout(() => {
36802
- child?.kill();
36803
- if (onLine) onLine(`timed out after ${Math.round(timeoutMs / 1e3)}s`);
36804
- done(124);
36805
- }, timeoutMs);
36806
- timer.unref?.();
36807
- const feed = (d) => {
36808
- const text = d.toString("utf8");
36809
- out += text;
36810
- if (!onLine) return;
36811
- buf += text;
36812
- let nl;
36813
- while ((nl = buf.indexOf("\n")) >= 0) {
36814
- const line = buf.slice(0, nl).replace(/\r$/, "");
36815
- buf = buf.slice(nl + 1);
36816
- onLine(line);
36817
- }
36823
+ if (sub === "init") {
36824
+ const writeDefaults = (verb) => {
36825
+ overwriteProfileConfig(dir2, defaultProfileConfig());
36826
+ ctx.print(`\u2714 ${verb} default config: ${path}`);
36827
+ ctx.print("add claude profiles: /config set claudeHome .claude .claude-1 (tried in order)");
36818
36828
  };
36819
- try {
36820
- child = spawn9(cmd, args, { stdio: ["ignore", "pipe", "pipe"] });
36821
- child.stdout?.on("data", feed);
36822
- child.stderr?.on("data", feed);
36823
- child.on("exit", (code) => done(code ?? 0));
36824
- child.on("error", () => done(127));
36825
- } catch {
36826
- done(127);
36829
+ if (existsSync11(path)) {
36830
+ ctx.confirm(
36831
+ `config exists: ${path} \u2014 replace it with defaults? (y/N)`,
36832
+ () => writeDefaults("replaced with")
36833
+ );
36834
+ return;
36827
36835
  }
36828
- });
36829
- }
36830
- async function detectOne(cmd, versionArg) {
36831
- const { code, out } = await run10(cmd, [versionArg], void 0, 15e3);
36832
- if (code !== 0) return null;
36833
- const first = out.split("\n").map((l) => l.trim()).find(Boolean);
36834
- return first ?? "";
36835
- }
36836
- async function listExtras() {
36837
- const { out } = await run10("npm", ["ls", "-g", "--depth=0", "--json"], void 0, 2e4);
36838
- if (!out.trim()) return [];
36839
- let deps = {};
36840
- try {
36841
- deps = JSON.parse(out).dependencies ?? {};
36842
- } catch {
36843
- return [];
36836
+ writeDefaults("wrote");
36837
+ return;
36844
36838
  }
36845
- const catalogPkgs = new Set(WORKER_TOOL_BUNDLED_GLOBALS);
36846
- for (const t of WORKER_TOOL_CATALOG) {
36847
- catalogPkgs.add(t.id);
36848
- if (t.installPackage) catalogPkgs.add(t.installPackage);
36839
+ if (sub === "set") {
36840
+ const [key, ...valueTokens] = rest;
36841
+ if (!key) {
36842
+ ctx.print("usage: /config set <key> <value>", "error");
36843
+ return;
36844
+ }
36845
+ const result = parseConfigAssignment(key, valueTokens);
36846
+ if ("error" in result) {
36847
+ ctx.print(result.error, "error");
36848
+ return;
36849
+ }
36850
+ const merged = writeProfileConfig(dir2, result.patch);
36851
+ ctx.print(`\u2714 ${key} = ${JSON.stringify(merged[key])} (applies to the next prompt)`);
36852
+ return;
36849
36853
  }
36850
- return Object.entries(deps).filter(([name]) => !catalogPkgs.has(name)).map(([name, info]) => ({
36851
- id: name,
36852
- label: name,
36854
+ if (sub === "delete" || sub === "unset") {
36855
+ const key = rest[0];
36856
+ if (!key) {
36857
+ ctx.print("usage: /config delete <key>", "error");
36858
+ return;
36859
+ }
36860
+ if (!CONFIG_KEYS.includes(key)) {
36861
+ ctx.print(`Unknown key "${key}". Allowed: ${CONFIG_KEYS.join(", ")}.`, "error");
36862
+ return;
36863
+ }
36864
+ deleteProfileConfigKey(dir2, key);
36865
+ ctx.print(`\u2714 deleted ${key}`);
36866
+ return;
36867
+ }
36868
+ ctx.print(`Unknown /config subcommand "${sub}". Use: show | init | set | delete.`, "error");
36869
+ }
36870
+ function fmtDuration(totalSec) {
36871
+ const h = Math.floor(totalSec / 3600);
36872
+ const m = Math.floor(totalSec % 3600 / 60);
36873
+ const s = totalSec % 60;
36874
+ const pad = (n) => String(n).padStart(2, "0");
36875
+ return h > 0 ? `${h}h ${pad(m)}m ${pad(s)}s` : `${m}m ${pad(s)}s`;
36876
+ }
36877
+ function commandLine(cmd, args) {
36878
+ return `${cmd} ${args.join(" ")}`.trim();
36879
+ }
36880
+ function runHistory(ctx) {
36881
+ const n = Math.max(1, Math.min(50, Number(ctx.args[0]) || 15));
36882
+ const all = getCommandHistory();
36883
+ if (all.length === 0) {
36884
+ ctx.print("No commands run yet.");
36885
+ return;
36886
+ }
36887
+ const recent = all.slice(-n).reverse();
36888
+ ctx.print(`Last ${recent.length} of ${all.length} commands (newest first) \xB7 /output <n> to view:`);
36889
+ recent.forEach((e, i) => {
36890
+ const time = new Date(e.startedAt).toLocaleTimeString();
36891
+ const state = e.status === "done" ? "\u2713" : e.status === "failed" ? `\u2717 exit ${e.exitCode}` : "\xB7 running";
36892
+ ctx.print(` [${i + 1}] ${time} ${commandLine(e.cmd, e.args)} \xB7 ${state}`);
36893
+ });
36894
+ }
36895
+ function runOutput(ctx) {
36896
+ const all = getCommandHistory();
36897
+ const n = Number(ctx.args[0]);
36898
+ if (!Number.isInteger(n) || n < 1 || n > all.length) {
36899
+ ctx.print("usage: /output <n> (n = index from /history)", "error");
36900
+ return;
36901
+ }
36902
+ const entry = all.slice().reverse()[n - 1];
36903
+ const state = entry.exitCode != null ? ` (exit ${entry.exitCode})` : "";
36904
+ ctx.print(`\u2500\u2500 output [${n}] ${commandLine(entry.cmd, entry.args)} \xB7 ${entry.status}${state} \u2500\u2500`);
36905
+ const out = readCommandOutput(entry.commandId);
36906
+ if (out == null) {
36907
+ ctx.print("(no output stored \u2014 it ran in a previous session or produced none)", "warn");
36908
+ return;
36909
+ }
36910
+ for (const line of out.replace(/\n$/, "").split("\n")) ctx.print(line);
36911
+ }
36912
+ function runLogs(ctx) {
36913
+ const n = Math.max(1, Math.min(200, Number(ctx.args[0]) || 20));
36914
+ const dir2 = join23(ctx.info.profileDir, "logs");
36915
+ const lines = readRecentLogLines(dir2, n);
36916
+ if (lines.length === 0) {
36917
+ ctx.print("No log lines yet.");
36918
+ return;
36919
+ }
36920
+ ctx.print(`Last ${lines.length} log lines (${dir2}):`);
36921
+ for (const raw of lines) {
36922
+ try {
36923
+ const j = JSON.parse(raw);
36924
+ const time = String(j.timestamp ?? "").slice(11, 19);
36925
+ const level = String(j.level ?? "").padEnd(5);
36926
+ const { timestamp, level: _l, version, profile, scope, event, ...fields } = j;
36927
+ void timestamp;
36928
+ void _l;
36929
+ void version;
36930
+ void profile;
36931
+ void scope;
36932
+ const extra = Object.keys(fields).length ? " " + JSON.stringify(fields) : "";
36933
+ ctx.print(` ${time} ${level} ${String(event ?? "")}${extra}`);
36934
+ } catch {
36935
+ ctx.print(` ${raw}`);
36936
+ }
36937
+ }
36938
+ }
36939
+ function runWhoami(ctx) {
36940
+ ctx.print("whoami: fetching from the server\u2026");
36941
+ void ctx.info.whoami().then((w) => {
36942
+ if (!w) {
36943
+ ctx.print("whoami: unavailable (server unreachable or link invalid)", "warn");
36944
+ return;
36945
+ }
36946
+ ctx.print(`account: ${w.username} \xB7 scope: ${w.scope} \xB7 roles: ${w.roles.join(", ") || "\u2014"}`);
36947
+ ctx.print(`teams: ${w.teams.length ? w.teams.map((t) => t.name).join(", ") : "(none)"}`);
36948
+ ctx.print(`projects: ${w.projects.length ? w.projects.map((p) => p.name).join(", ") : "(none)"}`);
36949
+ });
36950
+ }
36951
+ function runFold(ctx, action) {
36952
+ if (ctx.maxBlock === 0) {
36953
+ ctx.print("No collapsible blocks on screen.", "warn");
36954
+ return;
36955
+ }
36956
+ const n = ctx.args[0] != null ? Number(ctx.args[0]) : ctx.maxBlock;
36957
+ if (!Number.isInteger(n) || n < 1 || n > ctx.maxBlock) {
36958
+ ctx.print(`usage: /${action} [1-${ctx.maxBlock}] (no number \u21D2 the newest block)`, "error");
36959
+ return;
36960
+ }
36961
+ if (action === "expand") ctx.expand(n);
36962
+ else ctx.collapse(n);
36963
+ }
36964
+ var COMMANDS = [
36965
+ {
36966
+ name: "help",
36967
+ usage: "/help",
36968
+ description: "List slash commands",
36969
+ run: (ctx) => {
36970
+ ctx.print("Slash commands:");
36971
+ for (const cmd of COMMANDS) ctx.print(` ${cmd.usage.padEnd(28)} ${cmd.description}`);
36972
+ }
36973
+ },
36974
+ {
36975
+ name: "version",
36976
+ usage: "/version",
36977
+ description: "Show the cli version",
36978
+ run: (ctx) => ctx.print(`4PM CLI v${ctx.info.version} \xB7 profile ${ctx.info.profile}`)
36979
+ },
36980
+ {
36981
+ name: "status",
36982
+ usage: "/status",
36983
+ description: "Show connection + profile + session status",
36984
+ run: (ctx) => {
36985
+ const s = ctx.live;
36986
+ const uptime = fmtDuration(Math.max(0, Math.floor((Date.now() - s.startedAt) / 1e3)));
36987
+ ctx.print(`connection: ${s.status}`);
36988
+ ctx.print(`scope: ${s.scope}`);
36989
+ ctx.print(`worker: ${s.worker ?? "(unknown)"}`);
36990
+ if (s.project) ctx.print(`project: ${s.project}`);
36991
+ ctx.print(`profile: ${ctx.info.profile}`);
36992
+ ctx.print(`server: ${ctx.info.serverUrl}`);
36993
+ ctx.print(`aiCli: ${ctx.info.aiCli}${s.activeProfile ? ` \xB7 profile ${s.activeProfile}` : ""}`);
36994
+ ctx.print(`serving: ${ctx.info.physicPath ?? "(none)"}`);
36995
+ ctx.print(`uptime: ${uptime} \xB7 commands: ${getCommandHistory().length}`);
36996
+ }
36997
+ },
36998
+ {
36999
+ name: "history",
37000
+ usage: "/history [N]",
37001
+ description: "List the last N executed commands (+ time)",
37002
+ run: runHistory
37003
+ },
37004
+ {
37005
+ name: "output",
37006
+ usage: "/output <n>",
37007
+ description: "Replay a command's output (n from /history)",
37008
+ run: runOutput
37009
+ },
37010
+ {
37011
+ name: "logs",
37012
+ usage: "/logs [N]",
37013
+ description: "Tail the profile's structured log",
37014
+ run: runLogs
37015
+ },
37016
+ {
37017
+ name: "expand",
37018
+ usage: "/expand [N]",
37019
+ description: "Expand a collapsed \u25B8[N] block (default: newest); pretty-prints json/code",
37020
+ run: (ctx) => runFold(ctx, "expand")
37021
+ },
37022
+ {
37023
+ name: "collapse",
37024
+ usage: "/collapse [N]",
37025
+ description: "Collapse an expanded \u25B8[N] block (default: newest)",
37026
+ run: (ctx) => runFold(ctx, "collapse")
37027
+ },
37028
+ {
37029
+ name: "reconnect",
37030
+ usage: "/reconnect",
37031
+ description: "Reconnect to the server now (skip the backoff)",
37032
+ run: (ctx) => ctx.reconnect()
37033
+ },
37034
+ {
37035
+ name: "whoami",
37036
+ usage: "/whoami",
37037
+ description: "Show this machine account + its teams & projects",
37038
+ run: runWhoami
37039
+ },
37040
+ {
37041
+ name: "config",
37042
+ usage: "/config [show|init|set <k> <v>|delete <k>]",
37043
+ description: "View / init / update / delete profile config",
37044
+ run: runConfig
37045
+ },
37046
+ {
37047
+ name: "claude-cmd",
37048
+ usage: "/claude-cmd /context",
37049
+ description: "Run an AI-CLI slash command (e.g. /context, /usage, /status)",
37050
+ run: (ctx) => {
37051
+ const rest = ctx.args.join(" ").trim();
37052
+ if (!rest) {
37053
+ ctx.print("usage: /claude-cmd /context (forwards to the AI CLI)", "error");
37054
+ return;
37055
+ }
37056
+ ctx.submitAi(rest);
37057
+ }
37058
+ },
37059
+ {
37060
+ name: "clear",
37061
+ usage: "/clear",
37062
+ description: "Clear the transcript",
37063
+ run: (ctx) => ctx.clear()
37064
+ },
37065
+ {
37066
+ name: "quit",
37067
+ usage: "/quit",
37068
+ description: "Quit the cli (also /exit, Ctrl+C)",
37069
+ run: (ctx) => ctx.quit()
37070
+ }
37071
+ ];
37072
+ var ALIASES = { exit: "quit" };
37073
+ var SLASH_COMMANDS = COMMANDS.map((c) => ({ name: c.name, usage: c.usage, description: c.description }));
37074
+ function runSlashCommand(line, base) {
37075
+ const tokens = line.replace(/^\//, "").trim().split(/\s+/);
37076
+ const rawName = tokens[0] ?? "";
37077
+ const name = ALIASES[rawName] ?? rawName;
37078
+ const command = COMMANDS.find((c) => c.name === name);
37079
+ const ctx = { ...base, args: tokens.slice(1) };
37080
+ if (!command) {
37081
+ ctx.print(`Unknown command: /${rawName} \u2014 try /help`, "error");
37082
+ return;
37083
+ }
37084
+ command.run(ctx);
37085
+ }
37086
+
37087
+ // src/core/memory-compact.ts
37088
+ import { spawn as spawn8 } from "child_process";
37089
+ var COMPACT_TIMEOUT_MS = 6e4;
37090
+ function buildPrompt3(input) {
37091
+ return [
37092
+ "You maintain a COMPACT running memory of an ongoing assistant conversation so it can continue",
37093
+ "across sessions and accounts. Merge the latest exchange into the existing memory and return the",
37094
+ `UPDATED memory. Keep it UNDER ${input.budgetChars} characters. Use terse Markdown bullets grouped`,
37095
+ "as: Decisions, Constraints, Open tasks, Glossary. Keep durable facts; drop chit-chat and anything",
37096
+ "now obsolete. Output ONLY the updated memory text \u2014 no preamble, no code fences.",
37097
+ "",
37098
+ "=== EXISTING MEMORY ===",
37099
+ input.oldMemory || "(empty)",
37100
+ "",
37101
+ "=== LATEST USER PROMPT ===",
37102
+ input.prompt,
37103
+ "",
37104
+ "=== LATEST ASSISTANT ANSWER ===",
37105
+ input.answer
37106
+ ].join("\n");
37107
+ }
37108
+ function runOnce2(cmd, profile, prompt, cwd2, extraEnv) {
37109
+ return new Promise((resolve8, reject) => {
37110
+ const args = ["-p", ...profile?.model ? ["--model", profile.model] : []];
37111
+ const env3 = {
37112
+ ...process.env,
37113
+ ...extraEnv,
37114
+ ...profile ? { CLAUDE_CONFIG_DIR: profile.dir } : {}
37115
+ };
37116
+ const child = spawn8(cmd, args, { stdio: ["pipe", "pipe", "pipe"], cwd: cwd2, env: env3 });
37117
+ let out = "";
37118
+ let err = "";
37119
+ const timer = setTimeout(() => {
37120
+ child.kill("SIGKILL");
37121
+ reject(new Error("memory compaction timed out"));
37122
+ }, COMPACT_TIMEOUT_MS);
37123
+ timer.unref();
37124
+ child.stdout.on("data", (d) => out += d.toString());
37125
+ child.stderr.on("data", (d) => err += d.toString());
37126
+ child.on("error", (e) => {
37127
+ clearTimeout(timer);
37128
+ reject(e);
37129
+ });
37130
+ child.on("close", (code) => {
37131
+ clearTimeout(timer);
37132
+ resolve8({ code: code ?? -1, out, err });
37133
+ });
37134
+ child.stdin.write(prompt);
37135
+ child.stdin.end();
37136
+ });
37137
+ }
37138
+ async function runMemoryCompaction(ai, cwd2, input) {
37139
+ const prompt = buildPrompt3(input);
37140
+ const attempts = ai.profiles.length > 0 ? ai.profiles : [null];
37141
+ for (let i = 0; i < attempts.length; i++) {
37142
+ try {
37143
+ const { code, out } = await runOnce2(ai.cmd, attempts[i], prompt, cwd2, ai.env);
37144
+ if (code === 0 && out.trim()) {
37145
+ reportToolResult(ai.cmd, true);
37146
+ return out.trim().slice(0, input.budgetChars);
37147
+ }
37148
+ if (i === attempts.length - 1) break;
37149
+ } catch {
37150
+ break;
37151
+ }
37152
+ }
37153
+ return "";
37154
+ }
37155
+
37156
+ // src/core/config-sync.ts
37157
+ import { existsSync as existsSync12, readFileSync as readFileSync15 } from "fs";
37158
+ import { join as join24 } from "path";
37159
+ var SERVER_MANAGED_KEYS = [
37160
+ "physicPath",
37161
+ "sessionSwitchPct",
37162
+ "perPromptTokenLimit",
37163
+ // Project AI-run timeout override mirror (ADR-0243) — the ws_token refreshes it; a Worker-config
37164
+ // edit must not fight it. NOTE: the machine-user's own `aiRunTimeoutSec` is NOT here (operator-editable).
37165
+ "projectAiRunTimeoutSec",
37166
+ // Project idle auto-clear override mirror (ADR-0244) — same rule; the machine-user's own
37167
+ // `autoClearIdleMinutes` is operator-editable and NOT listed here.
37168
+ "projectAutoClearIdleMinutes",
37169
+ // Project shared-AI-memory override mirrors (ADR-0245) — the machine-user's own `aiMemoryEnabled`
37170
+ // / `aiMemoryBudgetChars` are operator-editable and NOT listed here.
37171
+ "projectAiMemoryMode",
37172
+ "projectAiMemoryBudgetChars"
37173
+ ];
37174
+ function readConfigText(profileDir2) {
37175
+ const path = join24(profileDir2, "config.json");
37176
+ if (existsSync12(path)) {
37177
+ try {
37178
+ return readFileSync15(path, "utf8");
37179
+ } catch {
37180
+ }
37181
+ }
37182
+ return JSON.stringify(defaultProfileConfig(), null, 2);
37183
+ }
37184
+ function applyConfigText(profileDir2, text) {
37185
+ let parsed;
37186
+ try {
37187
+ parsed = JSON.parse(text);
37188
+ } catch (err) {
37189
+ return { ok: false, error: `Invalid JSON: ${err.message}` };
37190
+ }
37191
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
37192
+ return { ok: false, error: "config.json must be a JSON object." };
37193
+ }
37194
+ const next = { ...parsed };
37195
+ const current = readProfileConfig(profileDir2);
37196
+ for (const key of SERVER_MANAGED_KEYS) {
37197
+ if (current[key] !== void 0) next[key] = current[key];
37198
+ else delete next[key];
37199
+ }
37200
+ overwriteProfileConfig(profileDir2, next);
37201
+ return { ok: true };
37202
+ }
37203
+
37204
+ // src/core/worker-tools.ts
37205
+ import { spawn as spawn9 } from "child_process";
37206
+ var NPM_NAME = /^(?:@[a-z0-9-~][a-z0-9-._~]*\/)?[a-z0-9-~][a-z0-9-._~]*$/;
37207
+ function run10(cmd, args, onLine, timeoutMs = 18e4) {
37208
+ return new Promise((resolve8) => {
37209
+ let out = "";
37210
+ let buf = "";
37211
+ let child = null;
37212
+ const done = (code) => {
37213
+ clearTimeout(timer);
37214
+ if (onLine && buf.trim()) onLine(buf.trim());
37215
+ resolve8({ code, out });
37216
+ };
37217
+ const timer = setTimeout(() => {
37218
+ child?.kill();
37219
+ if (onLine) onLine(`timed out after ${Math.round(timeoutMs / 1e3)}s`);
37220
+ done(124);
37221
+ }, timeoutMs);
37222
+ timer.unref?.();
37223
+ const feed = (d) => {
37224
+ const text = d.toString("utf8");
37225
+ out += text;
37226
+ if (!onLine) return;
37227
+ buf += text;
37228
+ let nl;
37229
+ while ((nl = buf.indexOf("\n")) >= 0) {
37230
+ const line = buf.slice(0, nl).replace(/\r$/, "");
37231
+ buf = buf.slice(nl + 1);
37232
+ onLine(line);
37233
+ }
37234
+ };
37235
+ try {
37236
+ child = spawn9(cmd, args, { stdio: ["ignore", "pipe", "pipe"] });
37237
+ child.stdout?.on("data", feed);
37238
+ child.stderr?.on("data", feed);
37239
+ child.on("exit", (code) => done(code ?? 0));
37240
+ child.on("error", () => done(127));
37241
+ } catch {
37242
+ done(127);
37243
+ }
37244
+ });
37245
+ }
37246
+ async function detectOne(cmd, versionArg) {
37247
+ const { code, out } = await run10(cmd, [versionArg], void 0, 15e3);
37248
+ if (code !== 0) return null;
37249
+ const first = out.split("\n").map((l) => l.trim()).find(Boolean);
37250
+ return first ?? "";
37251
+ }
37252
+ async function listExtras() {
37253
+ const { out } = await run10("npm", ["ls", "-g", "--depth=0", "--json"], void 0, 2e4);
37254
+ if (!out.trim()) return [];
37255
+ let deps = {};
37256
+ try {
37257
+ deps = JSON.parse(out).dependencies ?? {};
37258
+ } catch {
37259
+ return [];
37260
+ }
37261
+ const catalogPkgs = new Set(WORKER_TOOL_BUNDLED_GLOBALS);
37262
+ for (const t of WORKER_TOOL_CATALOG) {
37263
+ catalogPkgs.add(t.id);
37264
+ if (t.installPackage) catalogPkgs.add(t.installPackage);
37265
+ }
37266
+ return Object.entries(deps).filter(([name]) => !catalogPkgs.has(name)).map(([name, info]) => ({
37267
+ id: name,
37268
+ label: name,
36853
37269
  category: "runtime",
36854
37270
  installed: true,
36855
37271
  version: info.version ?? null,
@@ -36927,7 +37343,16 @@ var CONSOLE_WATCH_LEASE_MS = 45e3;
36927
37343
  var METRICS_SAMPLE_INTERVAL_MS = 5e3;
36928
37344
  var METRICS_WATCH_LEASE_MS = 45e3;
36929
37345
  function toDtoEntry(e) {
36930
- return { id: e.id, source: e.source, kind: e.kind, text: e.text, level: e.level, resultKind: e.resultKind };
37346
+ return {
37347
+ id: e.id,
37348
+ source: e.source,
37349
+ kind: e.kind,
37350
+ text: e.text,
37351
+ level: e.level,
37352
+ resultKind: e.resultKind,
37353
+ // Processing time on a terminal `exit` entry (ADR-0249) — the web renders it inline.
37354
+ ...e.durationMs != null ? { durationMs: e.durationMs } : {}
37355
+ };
36931
37356
  }
36932
37357
  var WsClient = class {
36933
37358
  constructor(context) {
@@ -36994,6 +37419,8 @@ var WsClient = class {
36994
37419
  orgReconnectMaxSec = null;
36995
37420
  /** Env override for the reconnect cap (seconds); resolved once at construction. */
36996
37421
  envReconnectMaxSec = envReconnectMaxSec();
37422
+ /** Process start (ms) — uptime for a web-dispatched `/status` (ADR-0249). */
37423
+ startedAtMs = Date.now();
36997
37424
  credential;
36998
37425
  /** The physic project folder root this cli serves (null = no project) — fs.list is
36999
37426
  * scoped to it so the web FsPicker can only browse inward, never out. */
@@ -37351,8 +37778,8 @@ var WsClient = class {
37351
37778
  const oldRoot = this.physicFolderPath(sync.oldName);
37352
37779
  if (oldRoot) void uninstallCron(oldRoot).catch(() => void 0);
37353
37780
  }
37354
- if (sync.oldName) rmSync4(join24(this.context.profileDir, sync.oldName), { recursive: true, force: true });
37355
- mkdirSync5(join24(this.context.profileDir, sync.newName), { recursive: true });
37781
+ if (sync.oldName) rmSync4(join25(this.context.profileDir, sync.oldName), { recursive: true, force: true });
37782
+ mkdirSync5(join25(this.context.profileDir, sync.newName), { recursive: true });
37356
37783
  this.physicRoot = this.physicFolderPath(sync.newName);
37357
37784
  this.bus.setProject(sync.newName);
37358
37785
  this.bus.log(`Physic project folder synced \u2192 ${sync.newName}`);
@@ -37363,7 +37790,7 @@ var WsClient = class {
37363
37790
  if (del.name) {
37364
37791
  const delRoot = this.physicFolderPath(del.name);
37365
37792
  if (delRoot) void uninstallCron(delRoot).catch(() => void 0);
37366
- rmSync4(join24(this.context.profileDir, del.name), { recursive: true, force: true });
37793
+ rmSync4(join25(this.context.profileDir, del.name), { recursive: true, force: true });
37367
37794
  this.bus.log(`Physic project folder deleted (project removed) \u2192 ${del.name}`);
37368
37795
  }
37369
37796
  void manageSshKey("delete", this.context.profileDir).catch(() => void 0);
@@ -37384,7 +37811,11 @@ var WsClient = class {
37384
37811
  ai: dispatch.ai ?? false
37385
37812
  });
37386
37813
  if (dispatch.ai) {
37387
- void this.runAiPrompt(dispatch.cmd, dispatch.commandId, "server");
37814
+ if (dispatch.cmd.trimStart().startsWith("/")) {
37815
+ void this.runWebSlashCommand(dispatch.cmd, dispatch.commandId);
37816
+ } else {
37817
+ void this.runAiPrompt(dispatch.cmd, dispatch.commandId, "server", dispatch.aiOneShot ?? false);
37818
+ }
37388
37819
  break;
37389
37820
  }
37390
37821
  recordCommand({
@@ -37399,6 +37830,7 @@ var WsClient = class {
37399
37830
  text: `$ ${dispatch.cmd} ${dispatch.args.join(" ")}`.trimEnd()
37400
37831
  });
37401
37832
  this.bus.startBusy(dispatch.cmd);
37833
+ const cmdStartMs = Date.now();
37402
37834
  void runCommand(dispatch, (out) => {
37403
37835
  if (out.chunk) {
37404
37836
  this.bus.push({ source: "server", kind: "out", text: out.chunk });
@@ -37413,7 +37845,8 @@ var WsClient = class {
37413
37845
  source: "server",
37414
37846
  kind: "exit",
37415
37847
  text: code === 0 ? "\u2713 done" : `\u2717 failed (exit ${code})`,
37416
- level: code === 0 ? "info" : "error"
37848
+ level: code === 0 ? "info" : "error",
37849
+ durationMs: Date.now() - cmdStartMs
37417
37850
  });
37418
37851
  }
37419
37852
  });
@@ -37736,7 +38169,7 @@ var WsClient = class {
37736
38169
  }
37737
38170
  case WsChannels.LOG_READ: {
37738
38171
  const limit = Math.min(Math.max(payload.limit ?? 200, 1), 2e3);
37739
- const lines = readRecentLogLines(join24(this.context.profileDir, "logs"), limit);
38172
+ const lines = readRecentLogLines(join25(this.context.profileDir, "logs"), limit);
37740
38173
  this.send(WsChannels.LOG_READ, { lines }, message.id);
37741
38174
  break;
37742
38175
  }
@@ -37815,7 +38248,7 @@ var WsClient = class {
37815
38248
  * exactly like typing the prompt in the cli (tries profiles, meters real tokens) instead
37816
38249
  * of spawning the text as a raw command. Streams output to the transcript AND the server.
37817
38250
  */
37818
- async runAiPrompt(prompt, commandId, origin) {
38251
+ async runAiPrompt(prompt, commandId, origin, oneShot = false) {
37819
38252
  const config = readProfileConfig(this.context.profileDir);
37820
38253
  const projectTimeoutSec = config.projectAiRunTimeoutSec ?? 0;
37821
38254
  const machineTimeoutSec = config.aiRunTimeoutSec ?? 0;
@@ -37887,7 +38320,7 @@ ${this.aiMemory}
37887
38320
 
37888
38321
  ${guardedPrompt}`;
37889
38322
  }
37890
- const plan = planAiRun(effectivePrompt, config, hint, resume);
38323
+ const plan = planAiRun(effectivePrompt, config, hint, resume, oneShot);
37891
38324
  const markerArgs = plan.attempts[0]?.args ?? [];
37892
38325
  this.bus.push({ source: origin, kind: "aireq", text: `${plan.cmd} \u2039 ${prompt}` });
37893
38326
  logger.info("command.ai", { commandId, origin, cmd: plan.cmd, profiles: plan.attempts.length });
@@ -37903,6 +38336,7 @@ ${guardedPrompt}`;
37903
38336
  }
37904
38337
  recordCommand({ commandId, cmd: plan.cmd, args: markerArgs });
37905
38338
  const startedAt = (/* @__PURE__ */ new Date()).toISOString();
38339
+ const aiStartedMs = Date.now();
37906
38340
  this.bus.startBusy(plan.cmd);
37907
38341
  let seq = 0;
37908
38342
  let responseHeaderShown = false;
@@ -37946,7 +38380,7 @@ ${guardedPrompt}`;
37946
38380
  `, log: true });
37947
38381
  },
37948
38382
  onAttemptFail: (label, reason) => {
37949
- const text = reason === "limit" ? `profile "${label}" hit its session limit \u2014 trying next` : `profile "${label}" failed to authenticate \u2014 trying next`;
38383
+ const text = reason === "limit" ? `profile "${label}" hit its session limit \u2014 trying next` : reason === "credits" ? `profile "${label}" is out of usage credits \u2014 trying next` : `profile "${label}" failed to authenticate \u2014 trying next`;
37950
38384
  this.bus.push({ source: origin, kind: "log", text, level: "warn" });
37951
38385
  this.send(WsChannels.COMMAND_OUTPUT, { commandId, seq: seq++, chunk: `${text}
37952
38386
  `, log: true });
@@ -38019,7 +38453,8 @@ ${guardedPrompt}`;
38019
38453
  source: origin,
38020
38454
  kind: "exit",
38021
38455
  text: result.exitCode === 0 ? "\u2713 ready \u2014 enter your next prompt" : `\u2717 ${plan.cmd} failed (exit ${result.exitCode}) \u2014 see the error above`,
38022
- level: result.exitCode === 0 ? "info" : "error"
38456
+ level: result.exitCode === 0 ? "info" : "error",
38457
+ durationMs: Date.now() - aiStartedMs
38023
38458
  });
38024
38459
  if (memCfg.enabled && result.exitCode === 0) {
38025
38460
  if (result.workedKey && result.sessionId) {
@@ -38029,6 +38464,114 @@ ${guardedPrompt}`;
38029
38464
  });
38030
38465
  }
38031
38466
  }
38467
+ /**
38468
+ * Run a `/…` 4pm-cli slash command dispatched from the web Console (ADR-0249). Mirrors the TUI's
38469
+ * `runSlashCommand` with a **server-origin** context that streams `print` output back over
38470
+ * command.output + console.sync, so a web user gets the same commands as an operator at the
38471
+ * machine — EXCEPT any the operator disabled via `webBlockedCommands`. `/claude-cmd <x>` forwards
38472
+ * `x` to the AI CLI (a real AI run). Fold ops (`/expand`/`/collapse`) are TUI-only — the web has
38473
+ * its own fold viewer — so they just print a hint. Interactive `confirm` (e.g. `/config init`) is
38474
+ * not supported from the web.
38475
+ */
38476
+ async runWebSlashCommand(line, commandId) {
38477
+ const origin = "server";
38478
+ const config = readProfileConfig(this.context.profileDir);
38479
+ const trimmed = line.trim();
38480
+ const startedMs = Date.now();
38481
+ this.bus.push({ source: origin, kind: "cmd", text: trimmed });
38482
+ recordCommand({ commandId, cmd: trimmed, args: [] });
38483
+ let seq = 0;
38484
+ const emit = (text) => {
38485
+ appendCommandOutput(commandId, `${text}
38486
+ `);
38487
+ this.send(WsChannels.COMMAND_OUTPUT, { commandId, seq: seq++, chunk: `${text}
38488
+ ` });
38489
+ };
38490
+ const settle = (exitCode) => {
38491
+ this.send(WsChannels.COMMAND_OUTPUT, { commandId, seq: seq++, chunk: "", done: true, exitCode });
38492
+ finishCommand(commandId, exitCode);
38493
+ this.send(WsChannels.COMMAND_HISTORY, {
38494
+ commandId,
38495
+ cmd: trimmed,
38496
+ args: [],
38497
+ status: exitCode === 0 ? "done" : "failed",
38498
+ exitCode
38499
+ });
38500
+ this.bus.push({
38501
+ source: origin,
38502
+ kind: "exit",
38503
+ text: exitCode === 0 ? "\u2713 done" : `\u2717 failed (exit ${exitCode})`,
38504
+ level: exitCode === 0 ? "info" : "error",
38505
+ durationMs: Date.now() - startedMs
38506
+ });
38507
+ };
38508
+ const rawName = trimmed.replace(/^\//, "").trim().split(/\s+/)[0]?.toLowerCase() ?? "";
38509
+ const name = rawName === "exit" ? "quit" : rawName;
38510
+ const blocked = resolveWebBlockedCommands(config);
38511
+ if (blocked.has(name) || blocked.has(rawName)) {
38512
+ const msg = `Command /${rawName} is disabled on this worker (blocked by the machine config).`;
38513
+ this.bus.push({ source: origin, kind: "log", text: msg, level: "error" });
38514
+ emit(msg);
38515
+ settle(1);
38516
+ return;
38517
+ }
38518
+ if (name === "claude-cmd") {
38519
+ const rest = trimmed.replace(/^\/claude-cmd\s*/i, "").trim();
38520
+ if (!rest) {
38521
+ emit("usage: /claude-cmd /context (forwards to the AI CLI)");
38522
+ settle(1);
38523
+ return;
38524
+ }
38525
+ await this.runAiPrompt(rest, commandId, origin, false);
38526
+ return;
38527
+ }
38528
+ const info = {
38529
+ version: CLI_VERSION,
38530
+ scope: this.bus.scope ?? "project",
38531
+ profile: basename4(this.context.profileDir),
38532
+ profileDir: this.context.profileDir,
38533
+ serverUrl: this.context.credential.serverUrl,
38534
+ physicPath: this.physicRoot ?? config.physicPath ?? null,
38535
+ aiCli: config.aiCli || "claude",
38536
+ whoami: async () => null
38537
+ };
38538
+ runSlashCommand(trimmed, {
38539
+ info,
38540
+ print: (text, level) => {
38541
+ this.bus.push({ source: origin, kind: "log", text, level });
38542
+ emit(text);
38543
+ },
38544
+ clear: () => {
38545
+ this.bus.clear();
38546
+ this.bus.clearSession();
38547
+ },
38548
+ quit: () => {
38549
+ emit("Quit requested from the web console \u2014 the worker cli is shutting down.");
38550
+ setTimeout(() => process.exit(0), 200);
38551
+ },
38552
+ confirm: (prompt) => {
38553
+ const note = `${prompt} \u2014 interactive prompts aren't supported from the web console; run it on the machine.`;
38554
+ this.bus.push({ source: origin, kind: "log", text: note, level: "warn" });
38555
+ emit(note);
38556
+ },
38557
+ submitAi: () => {
38558
+ },
38559
+ // only /claude-cmd uses this, and it is special-cased above
38560
+ reconnect: () => this.reconnectNow(),
38561
+ expand: () => emit("Use the web console's fold viewer to expand blocks."),
38562
+ collapse: () => emit("Use the web console's fold viewer to collapse blocks."),
38563
+ maxBlock: 0,
38564
+ live: {
38565
+ status: this.bus.status,
38566
+ scope: this.bus.scope ?? "",
38567
+ worker: this.bus.worker,
38568
+ project: this.bus.project,
38569
+ activeProfile: this.bus.activeProfile,
38570
+ startedAt: this.startedAtMs
38571
+ }
38572
+ });
38573
+ settle(0);
38574
+ }
38032
38575
  /**
38033
38576
  * Fold the latest exchange into the shared AI memory (ADR-0245): a background claude compaction to a
38034
38577
  * budget-bounded summary, cached locally + written back to the server (`memory.update`). Best-effort —
@@ -38380,7 +38923,7 @@ ${guardedPrompt}`;
38380
38923
  /** Create the physic folder at an absolute path if missing (best-effort). */
38381
38924
  ensurePhysicFolderPath(folder) {
38382
38925
  try {
38383
- if (!existsSync12(folder)) {
38926
+ if (!existsSync13(folder)) {
38384
38927
  mkdirSync5(folder, { recursive: true });
38385
38928
  this.bus.log(`Created physic project folder: ${folder}`);
38386
38929
  }
@@ -38395,7 +38938,7 @@ ${guardedPrompt}`;
38395
38938
  */
38396
38939
  physicFolderPath(projectName) {
38397
38940
  const safe = projectName.replace(/[/\\]/g, "_").replace(/\.\./g, "_").trim();
38398
- return safe ? join24(this.context.profileDir, safe) : null;
38941
+ return safe ? join25(this.context.profileDir, safe) : null;
38399
38942
  }
38400
38943
  /**
38401
38944
  * Report machine.status: version + the state of the physic project being served
@@ -38409,7 +38952,7 @@ ${guardedPrompt}`;
38409
38952
  projects: [],
38410
38953
  version: CLI_VERSION,
38411
38954
  physicPath,
38412
- physicPathExists: physicPath ? existsSync12(physicPath) : void 0,
38955
+ physicPathExists: physicPath ? existsSync13(physicPath) : void 0,
38413
38956
  // Real coarse state (ADR-0152): !paused and a recent tick (was hard-coded false).
38414
38957
  autonomousRunning: physicPath ? isAutonomousRunning(physicPath) : false
38415
38958
  });
@@ -38499,7 +39042,7 @@ ${guardedPrompt}`;
38499
39042
  */
38500
39043
  uploadLog() {
38501
39044
  try {
38502
- const upload = readLogUpload(join24(this.context.profileDir, "logs"));
39045
+ const upload = readLogUpload(join25(this.context.profileDir, "logs"));
38503
39046
  if (!upload) return;
38504
39047
  const sig = `${upload.fileName}:${upload.totalBytes}`;
38505
39048
  if (sig === this.lastLogSig) return;
@@ -38528,9 +39071,9 @@ ${guardedPrompt}`;
38528
39071
 
38529
39072
  // src/core/instance-lock.ts
38530
39073
  import { readFileSync as readFileSync16, rmSync as rmSync5, writeFileSync as writeFileSync7 } from "fs";
38531
- import { join as join25 } from "path";
39074
+ import { join as join26 } from "path";
38532
39075
  function lockPath(profileDir2) {
38533
- return join25(profileDir2, "start.lock");
39076
+ return join26(profileDir2, "start.lock");
38534
39077
  }
38535
39078
  function isAlive(pid) {
38536
39079
  try {
@@ -38830,7 +39373,7 @@ var SessionBus = class {
38830
39373
  // src/core/control-server.ts
38831
39374
  import { createServer } from "net";
38832
39375
  import { rmSync as rmSync6 } from "fs";
38833
- import { join as join26 } from "path";
39376
+ import { join as join27 } from "path";
38834
39377
 
38835
39378
  // src/core/control-protocol.ts
38836
39379
  var CONTROL_SOCKET_FILE = "control.sock";
@@ -38859,7 +39402,7 @@ function createFrameParser() {
38859
39402
 
38860
39403
  // src/core/control-server.ts
38861
39404
  function startControlServer(bus, profileDir2, info) {
38862
- const socketPath = join26(profileDir2, CONTROL_SOCKET_FILE);
39405
+ const socketPath = join27(profileDir2, CONTROL_SOCKET_FILE);
38863
39406
  try {
38864
39407
  rmSync6(socketPath, { force: true });
38865
39408
  } catch {
@@ -44590,15 +45133,15 @@ var import_react21 = __toESM(require_react(), 1);
44590
45133
  var import_react26 = __toESM(require_react(), 1);
44591
45134
 
44592
45135
  // src/core/input-history.ts
44593
- import { existsSync as existsSync14, readFileSync as readFileSync18, writeFileSync as writeFileSync8 } from "fs";
44594
- import { join as join27 } from "path";
45136
+ import { existsSync as existsSync15, readFileSync as readFileSync18, writeFileSync as writeFileSync8 } from "fs";
45137
+ import { join as join28 } from "path";
44595
45138
  var MAX_HISTORY = 200;
44596
45139
  function historyPath(profileDir2) {
44597
- return join27(profileDir2, "input-history.json");
45140
+ return join28(profileDir2, "input-history.json");
44598
45141
  }
44599
45142
  function loadInputHistory(profileDir2) {
44600
45143
  const path = historyPath(profileDir2);
44601
- if (!existsSync14(path)) return [];
45144
+ if (!existsSync15(path)) return [];
44602
45145
  try {
44603
45146
  const data = JSON.parse(readFileSync18(path, "utf8"));
44604
45147
  return Array.isArray(data) ? data.filter((x) => typeof x === "string") : [];
@@ -44750,438 +45293,114 @@ function wrapToWidth(text, width) {
44750
45293
  line = word.trimStart();
44751
45294
  } else {
44752
45295
  line += word;
44753
- }
44754
- }
44755
- if (line) rows.push(line.trimEnd());
44756
- return rows.length > 0 ? rows : [""];
44757
- }
44758
- var DETAIL_RE = /<detail\s+title="([^"]*)">([\s\S]*?)<\/detail>/g;
44759
- function toSegments(raw) {
44760
- if (!raw.includes("<detail")) {
44761
- return raw.split("\n").map((text) => ({ text, collapsed: false }));
44762
- }
44763
- const out = [];
44764
- const re = new RegExp(DETAIL_RE);
44765
- let last = 0;
44766
- let m;
44767
- while ((m = re.exec(raw)) !== null) {
44768
- const before = raw.slice(last, m.index).replace(/\n$/, "");
44769
- if (before.length > 0) for (const p of before.split("\n")) out.push({ text: p, collapsed: false });
44770
- out.push({ text: m[1] || "details", collapsed: true, body: m[2] ?? "" });
44771
- last = m.index + m[0].length;
44772
- if (raw[last] === "\n") last += 1;
44773
- }
44774
- const rest = raw.slice(last);
44775
- if (rest.length > 0) for (const p of rest.split("\n")) out.push({ text: p, collapsed: false });
44776
- if (out.length === 0) out.push({ text: "", collapsed: false });
44777
- return out;
44778
- }
44779
- function pushCollapsible(lines, entry, opts) {
44780
- const { blockNo, title, body, expanded, firstRow, textWidth, keyBase } = opts;
44781
- const base = { source: entry.source, level: entry.level, kind: entry.kind };
44782
- lines.push({
44783
- ...base,
44784
- key: `${keyBase}:m`,
44785
- text: `${expanded ? "\u25BE" : "\u25B8"}[${blockNo}] ${title}`,
44786
- showTag: firstRow,
44787
- collapsed: true,
44788
- spacer: false,
44789
- blockIndex: blockNo
44790
- });
44791
- if (!expanded) return;
44792
- prettyResult(body).split("\n").forEach(
44793
- (seg, i) => wrapToWidth(seg, textWidth).forEach(
44794
- (row, j) => lines.push({ ...base, key: `${keyBase}:b:${i}:${j}`, text: row, showTag: false, collapsed: false, spacer: false })
44795
- )
44796
- );
44797
- }
44798
- function flattenEntries(entries2, textWidth, expanded = /* @__PURE__ */ new Set()) {
44799
- const lines = [];
44800
- let prevKey = null;
44801
- let blockNo = 0;
44802
- for (const entry of entries2) {
44803
- const blockKey = `${entry.source}|${entry.kind}`;
44804
- const newBlock = blockKey !== prevKey;
44805
- if (newBlock && lines.length > 0) {
44806
- lines.push({
44807
- key: `sp:${entry.id}`,
44808
- source: entry.source,
44809
- level: entry.level,
44810
- kind: entry.kind,
44811
- text: "",
44812
- showTag: false,
44813
- collapsed: false,
44814
- spacer: true
44815
- });
44816
- }
44817
- prevKey = blockKey;
44818
- let firstRow = newBlock;
44819
- if (entry.kind === "result") {
44820
- blockNo += 1;
44821
- const count = prettyResult(entry.text).split("\n").length;
44822
- const title = `result \xB7 ${entry.resultKind ?? "json"} \xB7 ${count} ${count === 1 ? "line" : "lines"}`;
44823
- pushCollapsible(lines, entry, { blockNo, title, body: entry.text, expanded: expanded.has(blockNo), firstRow, textWidth, keyBase: entry.id });
44824
- continue;
44825
- }
44826
- const raw = entry.kind === "out" ? entry.text.replace(/\n$/, "") : entry.text;
44827
- toSegments(raw).forEach((seg, i) => {
44828
- if (seg.collapsed) {
44829
- blockNo += 1;
44830
- pushCollapsible(lines, entry, { blockNo, title: seg.text, body: seg.body ?? "", expanded: expanded.has(blockNo), firstRow, textWidth, keyBase: `${entry.id}:${i}` });
44831
- firstRow = false;
44832
- return;
44833
- }
44834
- wrapToWidth(seg.text, textWidth).forEach((row, j) => {
44835
- lines.push({
44836
- key: `${entry.id}:${i}:${j}`,
44837
- source: entry.source,
44838
- level: entry.level,
44839
- kind: entry.kind,
44840
- text: row,
44841
- showTag: firstRow,
44842
- collapsed: false,
44843
- spacer: false
44844
- });
44845
- firstRow = false;
44846
- });
44847
- });
44848
- }
44849
- return lines;
44850
- }
44851
- function TranscriptLine({ line }) {
44852
- const tag = sourceTag(line.source);
44853
- return /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)(Box_default, { flexDirection: "row", children: [
44854
- /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(Box_default, { width: 7, marginRight: 1, children: line.showTag ? /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(Text, { color: tag.color, dimColor: line.source === "system", children: tag.text }) : null }),
44855
- /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(Box_default, { flexGrow: 1, children: line.spacer ? /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(Text, { children: " " }) : line.collapsed ? /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(Text, { dimColor: true, wrap: "truncate-end", children: line.text || " " }) : line.kind === "aireq" || line.kind === "aires" ? /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(AiMarkerLine, { line }) : /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(Text, { color: textColor(line), wrap: "truncate-end", children: line.text || " " }) })
44856
- ] });
44857
- }
44858
-
44859
- // src/ui/command-input.tsx
44860
- var import_react24 = __toESM(require_react(), 1);
44861
-
44862
- // src/ui/slash-commands.ts
44863
- import { existsSync as existsSync15 } from "fs";
44864
- import { join as join28 } from "path";
44865
- var CONFIG_KEYS = [
44866
- "aiCli",
44867
- "claudeHome",
44868
- "codexHome",
44869
- "antigravityHome",
44870
- "physicPath",
44871
- "commandHistoryUploadMinutes",
44872
- "autoUpdate",
44873
- "aiEnv"
44874
- ];
44875
- function configPath(profileDir2) {
44876
- return join28(profileDir2, "config.json");
44877
- }
44878
- function parseConfigAssignment(key, valueTokens) {
44879
- const value = valueTokens.join(" ").trim();
44880
- switch (key) {
44881
- case "aiCli":
44882
- case "physicPath":
44883
- if (!value) return { error: `Missing value for "${key}".` };
44884
- return { patch: { [key]: value } };
44885
- case "claudeHome":
44886
- case "codexHome":
44887
- case "antigravityHome": {
44888
- const dirs = valueTokens.filter((t) => t.trim());
44889
- if (dirs.length === 0) return { error: `Missing value for "${key}".` };
44890
- return { patch: { [key]: dirs.map((profile) => ({ profile, model: "" })) } };
44891
- }
44892
- case "commandHistoryUploadMinutes": {
44893
- const n = Number(value);
44894
- if (!Number.isFinite(n) || n <= 0) return { error: `"${value}" is not a positive number.` };
44895
- return { patch: { commandHistoryUploadMinutes: n } };
44896
- }
44897
- case "autoUpdate":
44898
- if (value !== "true" && value !== "false") return { error: `Use true|false, not "${value}".` };
44899
- return { patch: { autoUpdate: value === "true" } };
44900
- case "aiEnv":
44901
- try {
44902
- return { patch: { aiEnv: JSON.parse(value) } };
44903
- } catch {
44904
- return { error: `aiEnv must be JSON, e.g. {"KEY":"val"}.` };
44905
- }
44906
- default:
44907
- return { error: `Unknown key "${key}". Allowed: ${CONFIG_KEYS.join(", ")}.` };
44908
- }
44909
- }
44910
- function runConfig(ctx) {
44911
- const [sub, ...rest] = ctx.args;
44912
- const dir2 = ctx.info.profileDir;
44913
- const path = configPath(dir2);
44914
- if (!sub || sub === "show") {
44915
- ctx.print(`config: ${path}`);
44916
- const json = JSON.stringify(readProfileConfig(dir2), null, 2);
44917
- for (const line of json.split("\n")) ctx.print(line);
44918
- return;
44919
- }
44920
- if (sub === "init") {
44921
- const writeDefaults = (verb) => {
44922
- overwriteProfileConfig(dir2, defaultProfileConfig());
44923
- ctx.print(`\u2714 ${verb} default config: ${path}`);
44924
- ctx.print("add claude profiles: /config set claudeHome .claude .claude-1 (tried in order)");
44925
- };
44926
- if (existsSync15(path)) {
44927
- ctx.confirm(
44928
- `config exists: ${path} \u2014 replace it with defaults? (y/N)`,
44929
- () => writeDefaults("replaced with")
44930
- );
44931
- return;
44932
- }
44933
- writeDefaults("wrote");
44934
- return;
44935
- }
44936
- if (sub === "set") {
44937
- const [key, ...valueTokens] = rest;
44938
- if (!key) {
44939
- ctx.print("usage: /config set <key> <value>", "error");
44940
- return;
44941
- }
44942
- const result = parseConfigAssignment(key, valueTokens);
44943
- if ("error" in result) {
44944
- ctx.print(result.error, "error");
44945
- return;
44946
- }
44947
- const merged = writeProfileConfig(dir2, result.patch);
44948
- ctx.print(`\u2714 ${key} = ${JSON.stringify(merged[key])} (applies to the next prompt)`);
44949
- return;
44950
- }
44951
- if (sub === "delete" || sub === "unset") {
44952
- const key = rest[0];
44953
- if (!key) {
44954
- ctx.print("usage: /config delete <key>", "error");
44955
- return;
44956
- }
44957
- if (!CONFIG_KEYS.includes(key)) {
44958
- ctx.print(`Unknown key "${key}". Allowed: ${CONFIG_KEYS.join(", ")}.`, "error");
44959
- return;
44960
- }
44961
- deleteProfileConfigKey(dir2, key);
44962
- ctx.print(`\u2714 deleted ${key}`);
44963
- return;
44964
- }
44965
- ctx.print(`Unknown /config subcommand "${sub}". Use: show | init | set | delete.`, "error");
44966
- }
44967
- function fmtDuration(totalSec) {
44968
- const h = Math.floor(totalSec / 3600);
44969
- const m = Math.floor(totalSec % 3600 / 60);
44970
- const s = totalSec % 60;
44971
- const pad = (n) => String(n).padStart(2, "0");
44972
- return h > 0 ? `${h}h ${pad(m)}m ${pad(s)}s` : `${m}m ${pad(s)}s`;
44973
- }
44974
- function commandLine(cmd, args) {
44975
- return `${cmd} ${args.join(" ")}`.trim();
44976
- }
44977
- function runHistory(ctx) {
44978
- const n = Math.max(1, Math.min(50, Number(ctx.args[0]) || 15));
44979
- const all = getCommandHistory();
44980
- if (all.length === 0) {
44981
- ctx.print("No commands run yet.");
44982
- return;
44983
- }
44984
- const recent = all.slice(-n).reverse();
44985
- ctx.print(`Last ${recent.length} of ${all.length} commands (newest first) \xB7 /output <n> to view:`);
44986
- recent.forEach((e, i) => {
44987
- const time = new Date(e.startedAt).toLocaleTimeString();
44988
- const state = e.status === "done" ? "\u2713" : e.status === "failed" ? `\u2717 exit ${e.exitCode}` : "\xB7 running";
44989
- ctx.print(` [${i + 1}] ${time} ${commandLine(e.cmd, e.args)} \xB7 ${state}`);
44990
- });
44991
- }
44992
- function runOutput(ctx) {
44993
- const all = getCommandHistory();
44994
- const n = Number(ctx.args[0]);
44995
- if (!Number.isInteger(n) || n < 1 || n > all.length) {
44996
- ctx.print("usage: /output <n> (n = index from /history)", "error");
44997
- return;
44998
- }
44999
- const entry = all.slice().reverse()[n - 1];
45000
- const state = entry.exitCode != null ? ` (exit ${entry.exitCode})` : "";
45001
- ctx.print(`\u2500\u2500 output [${n}] ${commandLine(entry.cmd, entry.args)} \xB7 ${entry.status}${state} \u2500\u2500`);
45002
- const out = readCommandOutput(entry.commandId);
45003
- if (out == null) {
45004
- ctx.print("(no output stored \u2014 it ran in a previous session or produced none)", "warn");
45005
- return;
45296
+ }
45006
45297
  }
45007
- for (const line of out.replace(/\n$/, "").split("\n")) ctx.print(line);
45298
+ if (line) rows.push(line.trimEnd());
45299
+ return rows.length > 0 ? rows : [""];
45008
45300
  }
45009
- function runLogs(ctx) {
45010
- const n = Math.max(1, Math.min(200, Number(ctx.args[0]) || 20));
45011
- const dir2 = join28(ctx.info.profileDir, "logs");
45012
- const lines = readRecentLogLines(dir2, n);
45013
- if (lines.length === 0) {
45014
- ctx.print("No log lines yet.");
45015
- return;
45301
+ var DETAIL_RE = /<detail\s+title="([^"]*)">([\s\S]*?)<\/detail>/g;
45302
+ function toSegments(raw) {
45303
+ if (!raw.includes("<detail")) {
45304
+ return raw.split("\n").map((text) => ({ text, collapsed: false }));
45016
45305
  }
45017
- ctx.print(`Last ${lines.length} log lines (${dir2}):`);
45018
- for (const raw of lines) {
45019
- try {
45020
- const j = JSON.parse(raw);
45021
- const time = String(j.timestamp ?? "").slice(11, 19);
45022
- const level = String(j.level ?? "").padEnd(5);
45023
- const { timestamp, level: _l, version, profile, scope, event, ...fields } = j;
45024
- void timestamp;
45025
- void _l;
45026
- void version;
45027
- void profile;
45028
- void scope;
45029
- const extra = Object.keys(fields).length ? " " + JSON.stringify(fields) : "";
45030
- ctx.print(` ${time} ${level} ${String(event ?? "")}${extra}`);
45031
- } catch {
45032
- ctx.print(` ${raw}`);
45033
- }
45306
+ const out = [];
45307
+ const re = new RegExp(DETAIL_RE);
45308
+ let last = 0;
45309
+ let m;
45310
+ while ((m = re.exec(raw)) !== null) {
45311
+ const before = raw.slice(last, m.index).replace(/\n$/, "");
45312
+ if (before.length > 0) for (const p of before.split("\n")) out.push({ text: p, collapsed: false });
45313
+ out.push({ text: m[1] || "details", collapsed: true, body: m[2] ?? "" });
45314
+ last = m.index + m[0].length;
45315
+ if (raw[last] === "\n") last += 1;
45034
45316
  }
45317
+ const rest = raw.slice(last);
45318
+ if (rest.length > 0) for (const p of rest.split("\n")) out.push({ text: p, collapsed: false });
45319
+ if (out.length === 0) out.push({ text: "", collapsed: false });
45320
+ return out;
45035
45321
  }
45036
- function runWhoami(ctx) {
45037
- ctx.print("whoami: fetching from the server\u2026");
45038
- void ctx.info.whoami().then((w) => {
45039
- if (!w) {
45040
- ctx.print("whoami: unavailable (server unreachable or link invalid)", "warn");
45041
- return;
45042
- }
45043
- ctx.print(`account: ${w.username} \xB7 scope: ${w.scope} \xB7 roles: ${w.roles.join(", ") || "\u2014"}`);
45044
- ctx.print(`teams: ${w.teams.length ? w.teams.map((t) => t.name).join(", ") : "(none)"}`);
45045
- ctx.print(`projects: ${w.projects.length ? w.projects.map((p) => p.name).join(", ") : "(none)"}`);
45322
+ function pushCollapsible(lines, entry, opts) {
45323
+ const { blockNo, title, body, expanded, firstRow, textWidth, keyBase } = opts;
45324
+ const base = { source: entry.source, level: entry.level, kind: entry.kind };
45325
+ lines.push({
45326
+ ...base,
45327
+ key: `${keyBase}:m`,
45328
+ text: `${expanded ? "\u25BE" : "\u25B8"}[${blockNo}] ${title}`,
45329
+ showTag: firstRow,
45330
+ collapsed: true,
45331
+ spacer: false,
45332
+ blockIndex: blockNo
45046
45333
  });
45334
+ if (!expanded) return;
45335
+ prettyResult(body).split("\n").forEach(
45336
+ (seg, i) => wrapToWidth(seg, textWidth).forEach(
45337
+ (row, j) => lines.push({ ...base, key: `${keyBase}:b:${i}:${j}`, text: row, showTag: false, collapsed: false, spacer: false })
45338
+ )
45339
+ );
45047
45340
  }
45048
- function runFold(ctx, action) {
45049
- if (ctx.maxBlock === 0) {
45050
- ctx.print("No collapsible blocks on screen.", "warn");
45051
- return;
45052
- }
45053
- const n = ctx.args[0] != null ? Number(ctx.args[0]) : ctx.maxBlock;
45054
- if (!Number.isInteger(n) || n < 1 || n > ctx.maxBlock) {
45055
- ctx.print(`usage: /${action} [1-${ctx.maxBlock}] (no number \u21D2 the newest block)`, "error");
45056
- return;
45057
- }
45058
- if (action === "expand") ctx.expand(n);
45059
- else ctx.collapse(n);
45060
- }
45061
- var COMMANDS = [
45062
- {
45063
- name: "help",
45064
- usage: "/help",
45065
- description: "List slash commands",
45066
- run: (ctx) => {
45067
- ctx.print("Slash commands:");
45068
- for (const cmd of COMMANDS) ctx.print(` ${cmd.usage.padEnd(28)} ${cmd.description}`);
45341
+ function flattenEntries(entries2, textWidth, expanded = /* @__PURE__ */ new Set()) {
45342
+ const lines = [];
45343
+ let prevKey = null;
45344
+ let blockNo = 0;
45345
+ for (const entry of entries2) {
45346
+ const blockKey = `${entry.source}|${entry.kind}`;
45347
+ const newBlock = blockKey !== prevKey;
45348
+ if (newBlock && lines.length > 0) {
45349
+ lines.push({
45350
+ key: `sp:${entry.id}`,
45351
+ source: entry.source,
45352
+ level: entry.level,
45353
+ kind: entry.kind,
45354
+ text: "",
45355
+ showTag: false,
45356
+ collapsed: false,
45357
+ spacer: true
45358
+ });
45069
45359
  }
45070
- },
45071
- {
45072
- name: "version",
45073
- usage: "/version",
45074
- description: "Show the cli version",
45075
- run: (ctx) => ctx.print(`4PM CLI v${ctx.info.version} \xB7 profile ${ctx.info.profile}`)
45076
- },
45077
- {
45078
- name: "status",
45079
- usage: "/status",
45080
- description: "Show connection + profile + session status",
45081
- run: (ctx) => {
45082
- const s = ctx.live;
45083
- const uptime = fmtDuration(Math.max(0, Math.floor((Date.now() - s.startedAt) / 1e3)));
45084
- ctx.print(`connection: ${s.status}`);
45085
- ctx.print(`scope: ${s.scope}`);
45086
- ctx.print(`worker: ${s.worker ?? "(unknown)"}`);
45087
- if (s.project) ctx.print(`project: ${s.project}`);
45088
- ctx.print(`profile: ${ctx.info.profile}`);
45089
- ctx.print(`server: ${ctx.info.serverUrl}`);
45090
- ctx.print(`aiCli: ${ctx.info.aiCli}${s.activeProfile ? ` \xB7 profile ${s.activeProfile}` : ""}`);
45091
- ctx.print(`serving: ${ctx.info.physicPath ?? "(none)"}`);
45092
- ctx.print(`uptime: ${uptime} \xB7 commands: ${getCommandHistory().length}`);
45360
+ prevKey = blockKey;
45361
+ let firstRow = newBlock;
45362
+ if (entry.kind === "result") {
45363
+ blockNo += 1;
45364
+ const count = prettyResult(entry.text).split("\n").length;
45365
+ const title = `result \xB7 ${entry.resultKind ?? "json"} \xB7 ${count} ${count === 1 ? "line" : "lines"}`;
45366
+ pushCollapsible(lines, entry, { blockNo, title, body: entry.text, expanded: expanded.has(blockNo), firstRow, textWidth, keyBase: entry.id });
45367
+ continue;
45093
45368
  }
45094
- },
45095
- {
45096
- name: "history",
45097
- usage: "/history [N]",
45098
- description: "List the last N executed commands (+ time)",
45099
- run: runHistory
45100
- },
45101
- {
45102
- name: "output",
45103
- usage: "/output <n>",
45104
- description: "Replay a command's output (n from /history)",
45105
- run: runOutput
45106
- },
45107
- {
45108
- name: "logs",
45109
- usage: "/logs [N]",
45110
- description: "Tail the profile's structured log",
45111
- run: runLogs
45112
- },
45113
- {
45114
- name: "expand",
45115
- usage: "/expand [N]",
45116
- description: "Expand a collapsed \u25B8[N] block (default: newest); pretty-prints json/code",
45117
- run: (ctx) => runFold(ctx, "expand")
45118
- },
45119
- {
45120
- name: "collapse",
45121
- usage: "/collapse [N]",
45122
- description: "Collapse an expanded \u25B8[N] block (default: newest)",
45123
- run: (ctx) => runFold(ctx, "collapse")
45124
- },
45125
- {
45126
- name: "reconnect",
45127
- usage: "/reconnect",
45128
- description: "Reconnect to the server now (skip the backoff)",
45129
- run: (ctx) => ctx.reconnect()
45130
- },
45131
- {
45132
- name: "whoami",
45133
- usage: "/whoami",
45134
- description: "Show this machine account + its teams & projects",
45135
- run: runWhoami
45136
- },
45137
- {
45138
- name: "config",
45139
- usage: "/config [show|init|set <k> <v>|delete <k>]",
45140
- description: "View / init / update / delete profile config",
45141
- run: runConfig
45142
- },
45143
- {
45144
- name: "claude-cmd",
45145
- usage: "/claude-cmd /context",
45146
- description: "Run an AI-CLI slash command (e.g. /context, /usage, /status)",
45147
- run: (ctx) => {
45148
- const rest = ctx.args.join(" ").trim();
45149
- if (!rest) {
45150
- ctx.print("usage: /claude-cmd /context (forwards to the AI CLI)", "error");
45369
+ const raw = entry.kind === "out" ? entry.text.replace(/\n$/, "") : entry.text;
45370
+ toSegments(raw).forEach((seg, i) => {
45371
+ if (seg.collapsed) {
45372
+ blockNo += 1;
45373
+ pushCollapsible(lines, entry, { blockNo, title: seg.text, body: seg.body ?? "", expanded: expanded.has(blockNo), firstRow, textWidth, keyBase: `${entry.id}:${i}` });
45374
+ firstRow = false;
45151
45375
  return;
45152
45376
  }
45153
- ctx.submitAi(rest);
45154
- }
45155
- },
45156
- {
45157
- name: "clear",
45158
- usage: "/clear",
45159
- description: "Clear the transcript",
45160
- run: (ctx) => ctx.clear()
45161
- },
45162
- {
45163
- name: "quit",
45164
- usage: "/quit",
45165
- description: "Quit the cli (also /exit, Ctrl+C)",
45166
- run: (ctx) => ctx.quit()
45167
- }
45168
- ];
45169
- var ALIASES = { exit: "quit" };
45170
- var SLASH_COMMANDS = COMMANDS.map((c) => ({ name: c.name, usage: c.usage, description: c.description }));
45171
- function runSlashCommand(line, base) {
45172
- const tokens = line.replace(/^\//, "").trim().split(/\s+/);
45173
- const rawName = tokens[0] ?? "";
45174
- const name = ALIASES[rawName] ?? rawName;
45175
- const command = COMMANDS.find((c) => c.name === name);
45176
- const ctx = { ...base, args: tokens.slice(1) };
45177
- if (!command) {
45178
- ctx.print(`Unknown command: /${rawName} \u2014 try /help`, "error");
45179
- return;
45377
+ wrapToWidth(seg.text, textWidth).forEach((row, j) => {
45378
+ lines.push({
45379
+ key: `${entry.id}:${i}:${j}`,
45380
+ source: entry.source,
45381
+ level: entry.level,
45382
+ kind: entry.kind,
45383
+ text: row,
45384
+ showTag: firstRow,
45385
+ collapsed: false,
45386
+ spacer: false
45387
+ });
45388
+ firstRow = false;
45389
+ });
45390
+ });
45180
45391
  }
45181
- command.run(ctx);
45392
+ return lines;
45393
+ }
45394
+ function TranscriptLine({ line }) {
45395
+ const tag = sourceTag(line.source);
45396
+ return /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)(Box_default, { flexDirection: "row", children: [
45397
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(Box_default, { width: 7, marginRight: 1, children: line.showTag ? /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(Text, { color: tag.color, dimColor: line.source === "system", children: tag.text }) : null }),
45398
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(Box_default, { flexGrow: 1, children: line.spacer ? /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(Text, { children: " " }) : line.collapsed ? /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(Text, { dimColor: true, wrap: "truncate-end", children: line.text || " " }) : line.kind === "aireq" || line.kind === "aires" ? /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(AiMarkerLine, { line }) : /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(Text, { color: textColor(line), wrap: "truncate-end", children: line.text || " " }) })
45399
+ ] });
45182
45400
  }
45183
45401
 
45184
45402
  // src/ui/command-input.tsx
45403
+ var import_react24 = __toESM(require_react(), 1);
45185
45404
  var import_jsx_runtime3 = __toESM(require_jsx_runtime(), 1);
45186
45405
  function Hint() {
45187
45406
  return /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(Text, { dimColor: true, children: " /help \xB7 Tab complete \xB7 \u2191\u2193 history \xB7 PgUp/PgDn scroll \xB7 Ctrl+C quit" });