@4pm/cli 1.5.1 → 1.5.2

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 +183 -21
  2. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -32990,6 +32990,7 @@ var CLI_SLASH_COMMANDS = [
32990
32990
  { name: "collapse", usage: "/collapse [N]", description: "Collapse an expanded \u25B8[N] block" },
32991
32991
  { name: "reconnect", usage: "/reconnect", description: "Reconnect to the server now (skip the backoff)" },
32992
32992
  { name: "whoami", usage: "/whoami", description: "Show this machine account + its teams & projects" },
32993
+ { name: "ai-profile", usage: "/ai-profile [list|use <n>|reset]", description: "List AI profiles / switch which one runs" },
32993
32994
  { name: "config", usage: "/config [show|init|set <k> <v>|delete <k>]", description: "View / init / update / delete profile config" },
32994
32995
  { name: "claude-cmd", usage: "/claude-cmd /context", description: "Run an AI-CLI slash command (e.g. /context, /usage)" },
32995
32996
  { name: "clear", usage: "/clear", description: "Clear the transcript" },
@@ -33038,7 +33039,10 @@ var listWorkersQuerySchema = baseRequestSchema.extend({
33038
33039
  /** Only workers whose live CPU/memory/disk usage % ≥ the given threshold (0–100). */
33039
33040
  minCpuPct: external_exports.coerce.number().min(0).max(100).optional(),
33040
33041
  minMemPct: external_exports.coerce.number().min(0).max(100).optional(),
33041
- minDiskPct: external_exports.coerce.number().min(0).max(100).optional()
33042
+ minDiskPct: external_exports.coerce.number().min(0).max(100).optional(),
33043
+ /** Hide workers with no cli attached (clis = 0). The web defaults this on; unchecking the
33044
+ * "show empty CLIs" box drops it so empty workers are listed too. */
33045
+ hideEmptyClis: external_exports.enum(["true", "false"]).transform((v) => v === "true").optional()
33042
33046
  });
33043
33047
  var updateWorkerRequestSchema = external_exports.object({
33044
33048
  name: external_exports.string().min(1).max(100).optional()
@@ -34051,11 +34055,29 @@ import { spawn as spawn2 } from "child_process";
34051
34055
  var KILL_GRACE_MS = 5e3;
34052
34056
  async function runCommand(dispatch, emit, opts) {
34053
34057
  let seq = 0;
34054
- const child = spawn2(dispatch.cmd, dispatch.args ?? [], {
34055
- cwd: dispatch.path,
34056
- env: { ...process.env, ...dispatch.env },
34057
- shell: false
34058
+ let child;
34059
+ try {
34060
+ child = spawn2(dispatch.cmd, dispatch.args ?? [], {
34061
+ cwd: dispatch.path,
34062
+ env: { ...process.env, ...dispatch.env },
34063
+ shell: false
34064
+ });
34065
+ } catch (err) {
34066
+ emit({
34067
+ commandId: dispatch.commandId,
34068
+ seq: seq++,
34069
+ chunk: `spawn error: ${err.message}
34070
+ `,
34071
+ done: true,
34072
+ exitCode: -1
34073
+ });
34074
+ return;
34075
+ }
34076
+ child.stdin?.on("error", () => {
34058
34077
  });
34078
+ if (opts?.stdin != null) {
34079
+ child.stdin?.write(opts.stdin);
34080
+ }
34059
34081
  child.stdin?.end();
34060
34082
  const timeoutMs = opts?.timeoutMs && opts.timeoutMs > 0 ? opts.timeoutMs : 0;
34061
34083
  let timedOut = false;
@@ -34255,12 +34277,16 @@ function isOutOfCredits(text) {
34255
34277
  text
34256
34278
  );
34257
34279
  }
34258
- function runAttempt(commandId, cmd, args, cwd2, env3, onChunk, timeoutMs) {
34280
+ function runAttempt(commandId, cmd, args, stdin, cwd2, env3, onChunk, timeoutMs) {
34259
34281
  return new Promise((resolve8) => {
34260
- void runCommand({ commandId, cmd, args, path: cwd2, env: env3 }, (out) => {
34261
- if (out.chunk) onChunk(out.chunk);
34262
- if (out.done) resolve8(out.exitCode ?? -1);
34263
- }, timeoutMs > 0 ? { timeoutMs } : void 0);
34282
+ runCommand(
34283
+ { commandId, cmd, args, path: cwd2, env: env3 },
34284
+ (out) => {
34285
+ if (out.chunk) onChunk(out.chunk);
34286
+ if (out.done) resolve8(out.exitCode ?? -1);
34287
+ },
34288
+ { stdin, ...timeoutMs > 0 ? { timeoutMs } : {} }
34289
+ ).catch(() => resolve8(-1));
34264
34290
  });
34265
34291
  }
34266
34292
  async function runAiFailover(plan, commandId, cwd2, handlers, timeoutMs = 0) {
@@ -34281,7 +34307,7 @@ async function runAiFailover(plan, commandId, cwd2, handlers, timeoutMs = 0) {
34281
34307
  handlers.onChunk(text);
34282
34308
  }
34283
34309
  };
34284
- finalExit = await runAttempt(commandId, attempt.cmd, attempt.args, cwd2, attempt.env, emit, timeoutMs);
34310
+ finalExit = await runAttempt(commandId, attempt.cmd, attempt.args, attempt.stdin, cwd2, attempt.env, emit, timeoutMs);
34285
34311
  const tail = parser.flush();
34286
34312
  if (tail) {
34287
34313
  captured += tail;
@@ -34358,6 +34384,52 @@ function setWorkingCredential(profileDir2, key) {
34358
34384
  } catch {
34359
34385
  }
34360
34386
  }
34387
+ var PINNED_CREDENTIAL_KEY = "__pinned__";
34388
+ function getPinnedCredential(profileDir2) {
34389
+ return readState(profileDir2)[PINNED_CREDENTIAL_KEY] ?? null;
34390
+ }
34391
+ function setPinnedCredential(profileDir2, key) {
34392
+ const state = readState(profileDir2);
34393
+ if (state[PINNED_CREDENTIAL_KEY] === key) return;
34394
+ state[PINNED_CREDENTIAL_KEY] = key;
34395
+ try {
34396
+ writeFileSync4(statePath(profileDir2), JSON.stringify(state, null, 2), "utf8");
34397
+ } catch {
34398
+ }
34399
+ }
34400
+ function clearPinnedCredential(profileDir2) {
34401
+ const state = readState(profileDir2);
34402
+ if (!(PINNED_CREDENTIAL_KEY in state)) return;
34403
+ delete state[PINNED_CREDENTIAL_KEY];
34404
+ try {
34405
+ writeFileSync4(statePath(profileDir2), JSON.stringify(state, null, 2), "utf8");
34406
+ } catch {
34407
+ }
34408
+ }
34409
+
34410
+ // src/utils/time.ts
34411
+ function formatTimestampInZone(date, timeZone) {
34412
+ const build = (tz) => {
34413
+ const parts = new Intl.DateTimeFormat("en-CA", {
34414
+ ...tz ? { timeZone: tz } : {},
34415
+ hour12: false,
34416
+ year: "numeric",
34417
+ month: "2-digit",
34418
+ day: "2-digit",
34419
+ hour: "2-digit",
34420
+ minute: "2-digit",
34421
+ second: "2-digit"
34422
+ }).formatToParts(date);
34423
+ const get2 = (type) => parts.find((p) => p.type === type)?.value ?? "";
34424
+ const hour = get2("hour") === "24" ? "00" : get2("hour");
34425
+ return `${get2("year")}/${get2("month")}/${get2("day")} ${hour}:${get2("minute")}:${get2("second")}`;
34426
+ };
34427
+ try {
34428
+ return build(timeZone);
34429
+ } catch {
34430
+ return build();
34431
+ }
34432
+ }
34361
34433
 
34362
34434
  // src/utils/ai-cli.ts
34363
34435
  import { readFileSync as readFileSync8 } from "fs";
@@ -34463,7 +34535,7 @@ function folderScopeGuard(folder, prompt) {
34463
34535
  prompt
34464
34536
  ].join("\n");
34465
34537
  }
34466
- function buildRunArgs(profile, cmd, prompt, resumeId, oneShot = false) {
34538
+ function buildRunArgs(profile, cmd, resumeId, oneShot = false) {
34467
34539
  const extras = profile.args ?? [];
34468
34540
  const model = profile.model?.trim();
34469
34541
  const modelArgs = model ? ["--model", model] : [];
@@ -34471,7 +34543,7 @@ function buildRunArgs(profile, cmd, prompt, resumeId, oneShot = false) {
34471
34543
  const turns = profile.maxTurns;
34472
34544
  const perProfileTurns = !oneShot && cmd.includes("claude") && typeof turns === "number" && turns > 0 ? [`--max-turns=${Math.floor(turns)}`] : [];
34473
34545
  const oneShotExtra = oneShot ? oneShotArgs(cmd) : perProfileTurns;
34474
- return [...requiredArgs(cmd), ...resumeArgs, ...extras, ...modelArgs, ...oneShotExtra, prompt];
34546
+ return [...requiredArgs(cmd), ...resumeArgs, ...extras, ...modelArgs, ...oneShotExtra];
34475
34547
  }
34476
34548
  function claudeHomeDirs(config) {
34477
34549
  const dirs = profileDirs(claudeProfilesOf(config));
@@ -34522,7 +34594,7 @@ function planUnifiedRun(prompt, config, workingCredential, resume, oneShot) {
34522
34594
  return {
34523
34595
  cmd: active,
34524
34596
  attempts: [
34525
- { cmd: active, label: "default", dir: null, key: null, args: buildRunArgs({ profile: "" }, active, prompt, void 0, oneShot), env: { ...baseEnv } }
34597
+ { cmd: active, label: "default", dir: null, key: null, args: buildRunArgs({ profile: "" }, active, void 0, oneShot), stdin: prompt, env: { ...baseEnv } }
34526
34598
  ]
34527
34599
  };
34528
34600
  }
@@ -34540,7 +34612,8 @@ function planUnifiedRun(prompt, config, workingCredential, resume, oneShot) {
34540
34612
  label: cred.label?.trim() || profileDisplayLabel(dir2),
34541
34613
  dir: dir2,
34542
34614
  key,
34543
- args: buildRunArgs(cred, cmd, prompt, resume.get(key), oneShot),
34615
+ args: buildRunArgs(cred, cmd, resume.get(key), oneShot),
34616
+ stdin: prompt,
34544
34617
  env: envVar ? { ...baseEnv, [envVar]: dir2 } : { ...baseEnv }
34545
34618
  }));
34546
34619
  return { cmd: attempts[0]?.cmd ?? DEFAULT_AI_CLI, attempts };
@@ -34552,7 +34625,7 @@ function planLegacyRun(prompt, config, workingDir, resume, oneShot) {
34552
34625
  const defaultPlan = {
34553
34626
  cmd,
34554
34627
  attempts: [
34555
- { cmd, label: "default", dir: null, key: null, args: buildRunArgs({ profile: "" }, cmd, prompt, void 0, oneShot), env: { ...baseEnv } }
34628
+ { cmd, label: "default", dir: null, key: null, args: buildRunArgs({ profile: "" }, cmd, void 0, oneShot), stdin: prompt, env: { ...baseEnv } }
34556
34629
  ]
34557
34630
  };
34558
34631
  if (!envVar) return defaultPlan;
@@ -34569,7 +34642,8 @@ function planLegacyRun(prompt, config, workingDir, resume, oneShot) {
34569
34642
  label: profileDisplayLabel(dir2),
34570
34643
  dir: dir2,
34571
34644
  key,
34572
- args: buildRunArgs(profile, cmd, prompt, resume.get(key), oneShot),
34645
+ args: buildRunArgs(profile, cmd, resume.get(key), oneShot),
34646
+ stdin: prompt,
34573
34647
  env: { ...baseEnv, [envVar]: dir2 }
34574
34648
  };
34575
34649
  });
@@ -36948,6 +37022,70 @@ function runWhoami(ctx) {
36948
37022
  ctx.print(`projects: ${w.projects.length ? w.projects.map((p) => p.name).join(", ") : "(none)"}`);
36949
37023
  });
36950
37024
  }
37025
+ function aiProfileLabel(c) {
37026
+ return c.label?.trim() || profileDisplayLabel(resolveHomePath(c.profile));
37027
+ }
37028
+ function runAiProfile(ctx) {
37029
+ const dir2 = ctx.info.profileDir;
37030
+ const config = readProfileConfig(dir2);
37031
+ const list = Array.isArray(config.aiProfiles) ? config.aiProfiles : [];
37032
+ const [sub, ...rest] = ctx.args;
37033
+ const action = (sub ?? "list").toLowerCase();
37034
+ if (action === "list") {
37035
+ if (list.length === 0) {
37036
+ ctx.print("No AI profiles configured (config.aiProfiles is empty).", "warn");
37037
+ ctx.print("This worker may use a legacy per-provider config \u2014 /config show to inspect.");
37038
+ return;
37039
+ }
37040
+ const pinned = getPinnedCredential(dir2);
37041
+ const working = getWorkingCredential(dir2);
37042
+ ctx.print("AI profiles (config order = failover priority) \xB7 /ai-profile use <n> to switch:");
37043
+ list.forEach((c, i) => {
37044
+ const usable = isUsableCredential(c);
37045
+ const key = usable ? credentialKey(c.provider, resolveHomePath(c.profile)) : null;
37046
+ const flags = [];
37047
+ if (key && key === pinned) flags.push("pinned");
37048
+ else if (key && !pinned && key === working) flags.push("active");
37049
+ if (c.enabled === false) flags.push("disabled");
37050
+ const model = c.model?.trim() ? ` \xB7 model=${c.model.trim()}` : "";
37051
+ const flagStr = flags.length ? ` (${flags.join(", ")})` : "";
37052
+ const marker = key && key === pinned ? "*" : " ";
37053
+ ctx.print(` ${marker}[${i + 1}] ${c.provider.padEnd(11)} ${aiProfileLabel(c)}${model}${flagStr}`);
37054
+ });
37055
+ if (pinned) {
37056
+ ctx.print("A profile is pinned \u2014 tried first every prompt; /ai-profile reset returns to auto.");
37057
+ }
37058
+ return;
37059
+ }
37060
+ if (action === "reset" || action === "auto" || action === "clear") {
37061
+ clearPinnedCredential(dir2);
37062
+ ctx.print("\u2714 AI profile pin cleared \u2014 failover is back to automatic (config order / remembered).");
37063
+ const working = getWorkingCredential(dir2);
37064
+ ctx.setActiveProfile(working ? labelFromCredentialKey(working) : null);
37065
+ return;
37066
+ }
37067
+ if (action === "use" || action === "switch") {
37068
+ const n = Number(rest[0]);
37069
+ if (!Number.isInteger(n) || n < 1 || n > list.length) {
37070
+ ctx.print(`usage: /ai-profile use <n> (n = 1-${list.length || "?"} from /ai-profile list)`, "error");
37071
+ return;
37072
+ }
37073
+ const cred = list[n - 1];
37074
+ if (!isUsableCredential(cred)) {
37075
+ const why = cred.enabled === false ? "it is disabled" : "it has no usable profile dir / provider";
37076
+ ctx.print(`Profile [${n}] "${aiProfileLabel(cred)}" can't be used \u2014 ${why}.`, "error");
37077
+ return;
37078
+ }
37079
+ const credDir = resolveHomePath(cred.profile);
37080
+ setPinnedCredential(dir2, credentialKey(cred.provider, credDir));
37081
+ ctx.setActiveProfile(profileDisplayLabel(credDir));
37082
+ ctx.print(
37083
+ `\u2714 switched to ${cred.provider} profile "${aiProfileLabel(cred)}" \u2014 tried first from the next prompt (failover keeps the rest as backup).`
37084
+ );
37085
+ return;
37086
+ }
37087
+ ctx.print(`Unknown /ai-profile subcommand "${sub}". Use: list | use <n> | reset.`, "error");
37088
+ }
36951
37089
  function runFold(ctx, action) {
36952
37090
  if (ctx.maxBlock === 0) {
36953
37091
  ctx.print("No collapsible blocks on screen.", "warn");
@@ -37037,6 +37175,12 @@ var COMMANDS = [
37037
37175
  description: "Show this machine account + its teams & projects",
37038
37176
  run: runWhoami
37039
37177
  },
37178
+ {
37179
+ name: "ai-profile",
37180
+ usage: "/ai-profile [list|use <n>|reset]",
37181
+ description: "List AI profiles / switch which one runs (ADR-0250)",
37182
+ run: runAiProfile
37183
+ },
37040
37184
  {
37041
37185
  name: "config",
37042
37186
  usage: "/config [show|init|set <k> <v>|delete <k>]",
@@ -37417,6 +37561,9 @@ var WsClient = class {
37417
37561
  reconnectEntryId = null;
37418
37562
  /** Org-configured reconnect cap (seconds) from the last ws_token — overrides env. */
37419
37563
  orgReconnectMaxSec = null;
37564
+ /** Org timezone (IANA) from the last ws_token (ADR-0132) — a rented worker follows its renter's
37565
+ * org; used to stamp the AI-run start time on the `aireq` transcript marker (ADR-0249). */
37566
+ orgTimezone = "UTC";
37420
37567
  /** Env override for the reconnect cap (seconds); resolved once at construction. */
37421
37568
  envReconnectMaxSec = envReconnectMaxSec();
37422
37569
  /** Process start (ms) — uptime for a web-dispatched `/status` (ADR-0249). */
@@ -37601,6 +37748,9 @@ var WsClient = class {
37601
37748
  if (typeof token.reconnectMaxBackoffSec === "number") {
37602
37749
  this.orgReconnectMaxSec = token.reconnectMaxBackoffSec;
37603
37750
  }
37751
+ if (typeof token.timezone === "string" && token.timezone) {
37752
+ this.orgTimezone = token.timezone;
37753
+ }
37604
37754
  if (typeof token.autoUpdateDaily === "boolean") {
37605
37755
  this.updateScheduler.configure({
37606
37756
  autoUpdateDaily: token.autoUpdateDaily,
@@ -38288,9 +38438,10 @@ var WsClient = class {
38288
38438
  const unified = isUnifiedConfig(config);
38289
38439
  const mode = config.aiFailoverMode ?? "remember";
38290
38440
  const workingDir = unified ? null : getWorkingProfile(this.context.profileDir, cmd);
38291
- const workingCred = unified && mode === "remember" ? getWorkingCredential(this.context.profileDir) : null;
38441
+ const pinnedCred = unified ? getPinnedCredential(this.context.profileDir) : null;
38442
+ const workingCred = unified ? pinnedCred ?? (mode === "remember" ? getWorkingCredential(this.context.profileDir) : null) : null;
38292
38443
  const startDir = unified ? null : this.profileUnderSessionPressure(config, cmd, workingDir);
38293
- const startCred = unified ? this.credentialUnderSessionPressure(config, workingCred) : null;
38444
+ const startCred = unified && !pinnedCred ? this.credentialUnderSessionPressure(config, workingCred) : null;
38294
38445
  if (startDir && startDir !== workingDir) {
38295
38446
  this.bus.push({
38296
38447
  source: origin,
@@ -38322,7 +38473,8 @@ ${guardedPrompt}`;
38322
38473
  }
38323
38474
  const plan = planAiRun(effectivePrompt, config, hint, resume, oneShot);
38324
38475
  const markerArgs = plan.attempts[0]?.args ?? [];
38325
- this.bus.push({ source: origin, kind: "aireq", text: `${plan.cmd} \u2039 ${prompt}` });
38476
+ const startStamp = formatTimestampInZone(/* @__PURE__ */ new Date(), this.orgTimezone);
38477
+ this.bus.push({ source: origin, kind: "aireq", text: `${startStamp} ${plan.cmd} \u2039 ${prompt}` });
38326
38478
  logger.info("command.ai", { commandId, origin, cmd: plan.cmd, profiles: plan.attempts.length });
38327
38479
  if (origin === "local") {
38328
38480
  this.send(WsChannels.COMMAND_ANNOUNCE, {
@@ -38557,6 +38709,8 @@ ${guardedPrompt}`;
38557
38709
  submitAi: () => {
38558
38710
  },
38559
38711
  // only /claude-cmd uses this, and it is special-cased above
38712
+ // /ai-profile use/reset from the web Console updates the shared header too (ADR-0250).
38713
+ setActiveProfile: (label) => this.bus.setActiveProfile(label),
38560
38714
  reconnect: () => this.reconnectNow(),
38561
38715
  expand: () => emit("Use the web console's fold viewer to expand blocks."),
38562
38716
  collapse: () => emit("Use the web console's fold viewer to collapse blocks."),
@@ -45263,12 +45417,17 @@ function textColor(line) {
45263
45417
  if (line.kind === "exit") return "green";
45264
45418
  return void 0;
45265
45419
  }
45420
+ var START_STAMP_RE = /^(\d{4}\/\d{2}\/\d{2} \d{2}:\d{2}:\d{2} )/;
45266
45421
  function AiMarkerLine({ line }) {
45267
45422
  const arrow = line.kind === "aireq" ? "\u2039" : "\u203A";
45268
45423
  const idx = line.text.indexOf(arrow);
45269
45424
  if (idx < 0) return /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(Text, { children: line.text || " " });
45425
+ const before = line.text.slice(0, idx);
45426
+ const stamp = line.kind === "aireq" ? START_STAMP_RE.exec(before)?.[1] ?? "" : "";
45427
+ const name = stamp ? before.slice(stamp.length) : before;
45270
45428
  return /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)(Text, { children: [
45271
- /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(Text, { color: "cyan", bold: true, children: line.text.slice(0, idx) }),
45429
+ stamp ? /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(Text, { dimColor: true, children: stamp }) : null,
45430
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(Text, { color: "cyan", bold: true, children: name }),
45272
45431
  /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(Text, { dimColor: true, children: arrow }),
45273
45432
  /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(Text, { children: line.text.slice(idx + 1) })
45274
45433
  ] });
@@ -45685,6 +45844,9 @@ function App2({
45685
45844
  setPendingConfirm({ prompt, onYes });
45686
45845
  },
45687
45846
  submitAi: (aiInput) => bus.submitLocal(aiInput),
45847
+ // /ai-profile use/reset updates the header's active-profile label at once (ADR-0250);
45848
+ // the bus event feeds the React state (see the onActiveProfile subscription above).
45849
+ setActiveProfile: (label) => bus.setActiveProfile(label),
45688
45850
  reconnect: () => bus.requestReconnect(),
45689
45851
  expand: expandFold,
45690
45852
  collapse: collapseFold,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@4pm/cli",
3
- "version": "1.5.1",
3
+ "version": "1.5.2",
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",