@4pm/cli 0.6.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +7 -7
  2. package/dist/index.js +347 -77
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -1,16 +1,16 @@
1
1
  # @4pm/cli
2
2
 
3
- **Agent chạy trên máy worker** — nhận lệnh từ `@4pm/server` qua WebSocket và điều phối
4
- các CLI AI (`claude` / `codex`) cùng công cụ khác (`git`, `gh`, `glab`, `shell`).
3
+ **An agent running on the worker machine** — receives commands from `@4pm/server` over WebSocket and orchestrates
4
+ the AI CLIs (`claude` / `codex`) plus other tools (`git`, `gh`, `glab`, `shell`).
5
5
  Node ESM + TypeScript.
6
6
 
7
- Vòng đời: **pairing** với server bằng hashcode (file `.cre`) → xin `ws_token` → mở
8
- WebSocket TLS → nhận lệnh → **spawn tiến trình con**, gom output (backpressure) trả về
9
- server. Nhiều instance trên một máy dùng profile riêng, server gom về một **worker**.
7
+ Lifecycle: **pairing** with the server via a hashcode (the `.cre` file) → request a `ws_token` → open a
8
+ TLS WebSocket → receive commands → **spawn child processes**, gather the output (backpressure) back to the
9
+ server. Multiple instances on one machine use separate profiles, and the server groups them into one **worker**.
10
10
 
11
11
  ```bash
12
12
  pnpm --filter @4pm/cli dev
13
13
  ```
14
14
 
15
- > Tổ chức mã, quy ước & phần còn thiếu: [`CLAUDE.md`](./CLAUDE.md).
16
- > Đặc tả hành vi cli: [`11-docs/61-cli/`](../../11-docs/61-cli/README.md).
15
+ > Code organization, conventions & what's missing: [`CLAUDE.md`](./CLAUDE.md).
16
+ > The cli behavior spec: [`11-docs/61-cli/`](../../11-docs/61-cli/README.md).
package/dist/index.js CHANGED
@@ -24469,6 +24469,27 @@ function profileDir(name) {
24469
24469
  mkdirSync(dir2, { recursive: true });
24470
24470
  return dir2;
24471
24471
  }
24472
+ function defaultProfileConfig() {
24473
+ return {
24474
+ aiCli: "claude",
24475
+ // Lists so failover across profiles is discoverable (ADR-0057) — add more objects.
24476
+ // `args` are the full per-CLI defaults (claude stream-json for metering — ADR-0072).
24477
+ // `model` = the CLI's `--model` flag; default "sonnet" — the balanced mid-price tier
24478
+ // (cheaper than opus, more capable than haiku). "" ⇒ the CLI's own default model.
24479
+ claudeHome: [
24480
+ { profile: ".claude", args: ["-p", "--output-format", "stream-json", "--verbose"], model: "sonnet" }
24481
+ ],
24482
+ codexHome: [{ profile: ".codex", args: ["exec"], model: "" }],
24483
+ antigravityHome: [],
24484
+ autoUpdate: true,
24485
+ commandHistoryUploadMinutes: 10
24486
+ };
24487
+ }
24488
+ function ensureProfileConfig(dir2) {
24489
+ if (existsSync(join(dir2, "config.json"))) return false;
24490
+ writeProfileConfig(dir2, defaultProfileConfig());
24491
+ return true;
24492
+ }
24472
24493
  function readProfileConfig(dir2) {
24473
24494
  const path = join(dir2, "config.json");
24474
24495
  if (!existsSync(path)) return {};
@@ -24483,6 +24504,10 @@ function writeProfileConfig(dir2, patch) {
24483
24504
  writeFileSync(join(dir2, "config.json"), JSON.stringify(merged, null, 2), "utf8");
24484
24505
  return merged;
24485
24506
  }
24507
+ function overwriteProfileConfig(dir2, config) {
24508
+ writeFileSync(join(dir2, "config.json"), JSON.stringify(config, null, 2), "utf8");
24509
+ return config;
24510
+ }
24486
24511
  function deleteProfileConfigKey(dir2, key) {
24487
24512
  const config = readProfileConfig(dir2);
24488
24513
  delete config[key];
@@ -24638,15 +24663,17 @@ async function runLink(serverUrl, explicitProfile) {
24638
24663
  );
24639
24664
  const profileName = explicitProfile ?? sanitizeProfileName(username);
24640
24665
  const existed = readCredential(profileDir(profileName), profileName) != null;
24641
- writeCredential(profileDir(profileName), {
24666
+ const dir2 = profileDir(profileName);
24667
+ writeCredential(dir2, {
24642
24668
  serverUrl,
24643
24669
  hashcode3,
24644
24670
  scope,
24645
24671
  // decided by the server from the pairing user (ADR-0010); shown in the TUI
24646
24672
  pairedAt: (/* @__PURE__ */ new Date()).toISOString()
24647
24673
  });
24674
+ ensureProfileConfig(dir2);
24648
24675
  if (projectName && scope === "project") {
24649
- const folder = join3(profileDir(profileName), projectName);
24676
+ const folder = join3(dir2, projectName);
24650
24677
  mkdirSync2(folder, { recursive: true });
24651
24678
  console.log(`\u2714 Created physic project folder: ${folder}`);
24652
24679
  }
@@ -24963,6 +24990,9 @@ var PermissionCode = {
24963
24990
  // billing
24964
24991
  BILLING_READ: "billing.read",
24965
24992
  BILLING_MANAGE: "billing.manage",
24993
+ // org template files (ADR-0113) — ADMIN + PM manage the org's template library
24994
+ TEMPLATE_READ: "template.read",
24995
+ TEMPLATE_WRITE: "template.write",
24966
24996
  // integration (community-server — ADR-0058; served by @4pm/community)
24967
24997
  INTEGRATION_READ: "integration.read",
24968
24998
  INTEGRATION_CONNECT: "integration.connect",
@@ -25006,6 +25036,8 @@ var ROLE_DEFAULT_PERMISSIONS = {
25006
25036
  P.USER_INVITE,
25007
25037
  P.QUOTA_READ,
25008
25038
  P.QUOTA_MANAGE,
25039
+ P.TEMPLATE_READ,
25040
+ P.TEMPLATE_WRITE,
25009
25041
  P.TEAM_LIST,
25010
25042
  P.TEAM_CREATE,
25011
25043
  P.TEAM_READ,
@@ -29351,16 +29383,30 @@ var resetPasswordRequestSchema = external_exports.object({
29351
29383
 
29352
29384
  // ../../22-packages/11-shared/31-dto/src/org.ts
29353
29385
  var CLI_SETTINGS_BOUNDS = {
29354
- reconnectMaxBackoffSec: { min: 5, max: 600 },
29386
+ reconnectMaxBackoffSec: { min: 5, max: 60 },
29355
29387
  autoUpdateHour: { min: 0, max: 23 }
29356
29388
  };
29389
+ var orgAliasSchema = external_exports.string().regex(/^([a-z0-9-]{3,40})?$/, "alias must be 3\u201340 chars of a\u2013z, 0\u20139, -");
29357
29390
  var updateOrgRequestSchema = external_exports.object({
29358
29391
  name: external_exports.string().min(1).max(100).optional(),
29392
+ // ADMIN-only login alias (ADR-0111); empty string clears it.
29393
+ alias: orgAliasSchema.optional(),
29359
29394
  settings: external_exports.record(external_exports.unknown()).optional()
29360
29395
  });
29361
29396
 
29362
29397
  // ../../22-packages/11-shared/31-dto/src/user.ts
29363
29398
  var AVATAR_MAX_BYTES = 2 * 1024 * 1024;
29399
+ var USAGE_ALERT_METRICS = ["session5h", "weekly", "monthlyTokens"];
29400
+ var usageAlertRuleSchema = external_exports.object({
29401
+ id: external_exports.string().min(1).max(64),
29402
+ metric: external_exports.enum(USAGE_ALERT_METRICS),
29403
+ threshold: external_exports.number().int().min(1),
29404
+ email: external_exports.union([emailSchema, external_exports.literal("")]).optional(),
29405
+ enabled: external_exports.boolean().default(true)
29406
+ });
29407
+ var userAlertsSchema = external_exports.object({
29408
+ rules: external_exports.array(usageAlertRuleSchema).max(20).default([])
29409
+ });
29364
29410
  var rolesSchema = external_exports.array(external_exports.enum(ALL_ROLES)).min(1).refine((roles) => isValidRoleSet(roles), {
29365
29411
  message: "ADMIN/MACHINE cannot be combined with other roles"
29366
29412
  });
@@ -29382,7 +29428,11 @@ var updateUserRequestSchema = external_exports.object({
29382
29428
  email: emailSchema.optional(),
29383
29429
  phone: phoneSchema.nullable().optional(),
29384
29430
  roles: rolesSchema.optional(),
29385
- ipAllowlist: ipAllowlistSchema.optional()
29431
+ ipAllowlist: ipAllowlistSchema.optional(),
29432
+ // Usage-alert rules (ADR-0110) — manager-only, like the IP allowlist.
29433
+ alerts: userAlertsSchema.optional(),
29434
+ // cli data retention days (ADR-0115) — manager-only; capped at the plan's retentionDays.
29435
+ cliRetentionDays: external_exports.number().int().min(0).max(3650).optional()
29386
29436
  });
29387
29437
  var setUserPasswordRequestSchema = external_exports.object({
29388
29438
  password: passwordSchema
@@ -29421,6 +29471,9 @@ var attachTeamUserRequestSchema = external_exports.object({
29421
29471
  isLead: external_exports.boolean().optional()
29422
29472
  });
29423
29473
 
29474
+ // ../../22-packages/11-shared/31-dto/src/template.ts
29475
+ var TEMPLATE_KINDS = ["report", "est", "wbs", "proposal", "feasibility"];
29476
+
29424
29477
  // ../../22-packages/11-shared/31-dto/src/project.ts
29425
29478
  var listProjectsQuerySchema = baseRequestSchema.extend({
29426
29479
  pinned: pinnedFilterSchema,
@@ -29485,6 +29538,17 @@ var putSpecDraftRequestSchema = external_exports.object({
29485
29538
  spec: external_exports.record(external_exports.unknown()),
29486
29539
  specVersion: external_exports.number().int().positive().optional()
29487
29540
  });
29541
+ var putProjectSpecRequestSchema = external_exports.object({
29542
+ spec: external_exports.record(external_exports.unknown())
29543
+ });
29544
+ var ARTIFACT_MAX_BYTES = 512 * 1024;
29545
+ var listArtifactsQuerySchema = external_exports.object({
29546
+ kind: external_exports.enum(TEMPLATE_KINDS).optional()
29547
+ });
29548
+ var createArtifactRequestSchema = external_exports.object({
29549
+ kind: external_exports.enum(TEMPLATE_KINDS),
29550
+ content: external_exports.string().min(1).max(ARTIFACT_MAX_BYTES)
29551
+ });
29488
29552
  var repoSpecSchema = external_exports.object({
29489
29553
  /** Role label (e.g. "docs" / "web" / "server") — descriptive, not constrained. */
29490
29554
  role: external_exports.string().max(60).optional().default(""),
@@ -29556,10 +29620,26 @@ var createFromSpecRequestSchema = external_exports.object({
29556
29620
  });
29557
29621
  var addRepoRequestSchema = external_exports.object({
29558
29622
  projectId: external_exports.string().uuid(),
29559
- path: external_exports.string().min(1).max(500),
29560
- /** Repo to clone; empty ⇒ `git init` in place. */
29561
- repoUrl: external_exports.string().url().max(500).optional(),
29562
- provider: external_exports.enum(["gh", "glab"]).optional()
29623
+ repos: external_exports.array(repoSpecSchema).min(1)
29624
+ }).superRefine((v, ctx) => {
29625
+ const primaries = v.repos.filter((r) => r.primary).length;
29626
+ if (primaries !== 1) {
29627
+ ctx.addIssue({
29628
+ code: external_exports.ZodIssueCode.custom,
29629
+ path: ["repos"],
29630
+ message: "Exactly one repo must be primary"
29631
+ });
29632
+ }
29633
+ for (let i = 0; i < v.repos.length; i++) {
29634
+ const r = v.repos[i];
29635
+ if (r && r.op === "existing" && !r.url) {
29636
+ ctx.addIssue({
29637
+ code: external_exports.ZodIssueCode.custom,
29638
+ path: ["repos", i, "url"],
29639
+ message: "An existing repo requires a url"
29640
+ });
29641
+ }
29642
+ }
29563
29643
  });
29564
29644
 
29565
29645
  // ../../22-packages/11-shared/31-dto/src/activity.ts
@@ -29659,7 +29739,11 @@ var dispatchCommandRequestSchema = external_exports.object({
29659
29739
  }
29660
29740
  });
29661
29741
  var listCommandsQuerySchema = baseRequestSchema.extend({
29662
- projectId: external_exports.string().uuid()
29742
+ projectId: external_exports.string().uuid(),
29743
+ // Optional search filters (command-0005): `search` (from BaseRequest) matches the command
29744
+ // text/status; `from`/`to` are an inclusive `YYYY-MM-DD` date range over `startedAt`.
29745
+ from: external_exports.string().optional(),
29746
+ to: external_exports.string().optional()
29663
29747
  });
29664
29748
 
29665
29749
  // ../../22-packages/11-shared/31-dto/src/invitation.ts
@@ -29757,8 +29841,33 @@ var planEntitlementsSchema = external_exports.object({
29757
29841
  /** Allowed to use Community/4rum. */
29758
29842
  community: external_exports.boolean().default(false),
29759
29843
  /** Retention cap (days) for log/command/chat; null = unlimited. */
29760
- retentionDays: external_exports.number().int().min(1).nullable().default(null)
29844
+ retentionDays: external_exports.number().int().min(1).nullable().default(null),
29845
+ /**
29846
+ * Total 4PM-hosted storage per org (bytes). Originally the template-file cap (ADR-0113),
29847
+ * broadened by ADR-0122 to the org's WHOLE hosted storage: messages + command history
29848
+ * (cli input/output/log) + files (templates/attachments/artifacts). `null` = unlimited.
29849
+ */
29850
+ templateStorageBytes: external_exports.number().int().min(0).nullable().default(null),
29851
+ /** Per-file cap for a template upload (bytes — ADR-0113); null = unlimited. */
29852
+ templateFileMaxBytes: external_exports.number().int().min(0).nullable().default(null),
29853
+ /**
29854
+ * How many 4PM-hosted machine-users the org may rent (ADR-0126). Base plans grant `0`;
29855
+ * raised only by the `rented_machine` add-on (ADR-0123/0124). Never `null` (not "unlimited").
29856
+ */
29857
+ rentedMachineSeats: external_exports.number().int().min(0).default(0)
29761
29858
  });
29859
+ var DEFAULT_ENTITLEMENTS = {
29860
+ maxUsers: 5,
29861
+ maxProjects: 1,
29862
+ teams: false,
29863
+ community: false,
29864
+ retentionDays: 30,
29865
+ // Free tier: 100 MB total, 10 MB per file (ADR-0113).
29866
+ templateStorageBytes: 100 * 1024 * 1024,
29867
+ templateFileMaxBytes: 10 * 1024 * 1024,
29868
+ // No rented machine-user seats by default — bought via the `rented_machine` add-on (ADR-0126).
29869
+ rentedMachineSeats: 0
29870
+ };
29762
29871
  var checkoutRequestSchema = external_exports.object({
29763
29872
  planCode: external_exports.string().min(1).max(50),
29764
29873
  provider: external_exports.enum([PaymentProvider.STRIPE, PaymentProvider.PAYPAL]).optional()
@@ -29774,6 +29883,16 @@ var assignPlanRequestSchema = external_exports.object({
29774
29883
  /** Cycle length in days (defaults to 30). */
29775
29884
  periodDays: external_exports.number().int().min(1).max(366).default(30)
29776
29885
  });
29886
+ var addonBuyRequestSchema = external_exports.object({
29887
+ code: external_exports.string().min(1).max(50),
29888
+ quantity: external_exports.number().int().min(1).max(1e3),
29889
+ /** Card path only; ignored when the org pays by credit (ADR-0104/0123). */
29890
+ provider: external_exports.enum([PaymentProvider.STRIPE, PaymentProvider.PAYPAL]).optional()
29891
+ });
29892
+ var addonChangeRequestSchema = external_exports.object({
29893
+ quantity: external_exports.number().int().min(0).max(1e3),
29894
+ provider: external_exports.enum([PaymentProvider.STRIPE, PaymentProvider.PAYPAL]).optional()
29895
+ });
29777
29896
 
29778
29897
  // ../../22-packages/11-shared/31-dto/src/search.ts
29779
29898
  var SearchHitType = {
@@ -29787,11 +29906,11 @@ var SearchHitType = {
29787
29906
  MILESTONE: "milestone",
29788
29907
  /** Page/wiki (confluence + project docs — community). */
29789
29908
  PAGE: "page",
29790
- /** Message trong channel (Teams channel — community). */
29909
+ /** A message in a channel (Teams channel — community). */
29791
29910
  MESSAGE: "message",
29792
- /** Message direct/group (lịch sử trò chuyện — community). */
29911
+ /** A direct/group message (chat history — community). */
29793
29912
  CHAT: "chat",
29794
- /** Bài 4rum (community news/forum). */
29913
+ /** A forum post (community news/forum). */
29795
29914
  POST: "post"
29796
29915
  };
29797
29916
  var searchQuerySchema = external_exports.object({
@@ -29857,8 +29976,14 @@ var WsChannels = {
29857
29976
  MACHINE_USAGE: "machine.usage",
29858
29977
  // cli → server: durable per-command history record on finish (ADR-0072)
29859
29978
  COMMAND_HISTORY: "command.history",
29979
+ // cli → server: periodic upload of the cli's own JSONL log file (ADR-0122) — stored
29980
+ // server-side + counted in the machine user's storage footprint.
29981
+ MACHINE_LOG: "machine.log",
29860
29982
  // server → cli (reply): tail the cli's own JSONL logs on demand (ADR-0072)
29861
29983
  LOG_READ: "log.read",
29984
+ // server → cli (reply): read a finished command's captured output on demand (ADR-0115) —
29985
+ // cli stores command-output/{commandId}.log; returns null when pruned/unavailable.
29986
+ COMMAND_OUTPUT_READ: "command.output-read",
29862
29987
  // outbound review (ADR-0082): machine→server request, server→outbound evaluate, result back
29863
29988
  REVIEW_REQUEST: "review.request",
29864
29989
  REVIEW_EVALUATE: "review.evaluate",
@@ -29987,7 +30112,7 @@ import { spawn } from "child_process";
29987
30112
 
29988
30113
  // src/common/logger/logger.ts
29989
30114
  import { appendFile, mkdir, readdir, unlink } from "fs/promises";
29990
- import { readdirSync as readdirSync2, readFileSync as readFileSync6 } from "fs";
30115
+ import { readdirSync as readdirSync2, readFileSync as readFileSync6, statSync } from "fs";
29991
30116
  import { join as join6 } from "path";
29992
30117
  var LEVEL_ORDER = { error: 0, warn: 1, info: 2, debug: 3 };
29993
30118
  var DAY_MS = 24 * 60 * 60 * 1e3;
@@ -30006,6 +30131,13 @@ var CliLogger = class {
30006
30131
  this.retentionDays = opts.retentionDays ?? DEFAULT_RETENTION_DAYS;
30007
30132
  void this.cleanupOldLogs();
30008
30133
  }
30134
+ /** Update the retention window at runtime + prune now (ADR-0115). `days<=0` keeps current. */
30135
+ setRetentionDays(days) {
30136
+ if (days > 0) {
30137
+ this.retentionDays = days;
30138
+ void this.cleanupOldLogs();
30139
+ }
30140
+ }
30009
30141
  /** Log an error-level event. */
30010
30142
  error(event, fields) {
30011
30143
  this.write("error", event, fields);
@@ -30062,6 +30194,24 @@ function readRecentLogLines(dir2, limit) {
30062
30194
  return [];
30063
30195
  }
30064
30196
  }
30197
+ var LOG_UPLOAD_CONTENT_MAX = 512 * 1024;
30198
+ function readLogUpload(dir2) {
30199
+ try {
30200
+ const files = readdirSync2(dir2).filter((f) => /^cli-\d{4}-\d{2}-\d{2}\.jsonl$/.test(f)).sort();
30201
+ const latest = files[files.length - 1];
30202
+ if (!latest) return null;
30203
+ let totalBytes = 0;
30204
+ for (const f of files) totalBytes += statSync(join6(dir2, f)).size;
30205
+ let content = readFileSync6(join6(dir2, latest), "utf8");
30206
+ if (content.length > LOG_UPLOAD_CONTENT_MAX) {
30207
+ content = `\u2026[truncated]
30208
+ ${content.slice(-LOG_UPLOAD_CONTENT_MAX)}`;
30209
+ }
30210
+ return { fileName: latest, content, totalBytes };
30211
+ } catch {
30212
+ return null;
30213
+ }
30214
+ }
30065
30215
 
30066
30216
  // src/core/update-scheduler.ts
30067
30217
  var TICK_MS = 6e4;
@@ -30320,7 +30470,7 @@ async function runAiFailover(plan, commandId, cwd2, handlers) {
30320
30470
  handlers.onChunk(text);
30321
30471
  }
30322
30472
  };
30323
- finalExit = await runAttempt(commandId, plan.cmd, plan.args, cwd2, attempt.env, emit);
30473
+ finalExit = await runAttempt(commandId, plan.cmd, attempt.args, cwd2, attempt.env, emit);
30324
30474
  const tail = parser.flush();
30325
30475
  if (tail) {
30326
30476
  captured += tail;
@@ -30380,13 +30530,18 @@ function resolveHomePath(value) {
30380
30530
  if (isAbsolute(value)) return value;
30381
30531
  return join8(homedir3(), value);
30382
30532
  }
30383
- function resolveDirs(value) {
30384
- if (!value) return [];
30385
- const list = Array.isArray(value) ? value : [value];
30386
- return list.filter((v) => v && v.trim()).map(resolveHomePath);
30533
+ function profileDirs(profiles) {
30534
+ if (!profiles) return [];
30535
+ return profiles.map((p) => p.profile).filter((v) => v && v.trim()).map(resolveHomePath);
30536
+ }
30537
+ function buildRunArgs(profile, cmd, prompt) {
30538
+ const pre = profile.args ?? DEFAULT_AI_ARGS[cmd] ?? [];
30539
+ const model = profile.model?.trim();
30540
+ const modelArgs = model ? ["--model", model] : [];
30541
+ return [...pre, ...modelArgs, prompt];
30387
30542
  }
30388
- function claudeConfigDirs(config) {
30389
- const dirs = resolveDirs(config.claudeConfigDir);
30543
+ function claudeHomeDirs(config) {
30544
+ const dirs = profileDirs(config.claudeHome);
30390
30545
  return dirs.length > 0 ? dirs : [join8(homedir3(), ".claude")];
30391
30546
  }
30392
30547
  function profileDisplayLabel(dir2) {
@@ -30403,28 +30558,40 @@ function profileEnvVar(cmd) {
30403
30558
  if (cmd.includes("codex")) return "CODEX_HOME";
30404
30559
  return null;
30405
30560
  }
30561
+ function profilesFor(config, cmd) {
30562
+ const envVar = profileEnvVar(cmd);
30563
+ if (envVar === "CLAUDE_CONFIG_DIR") return config.claudeHome ?? [];
30564
+ if (envVar === "CODEX_HOME") return config.codexHome ?? [];
30565
+ return [];
30566
+ }
30406
30567
  function profileLabels(config) {
30407
30568
  const cmd = config.aiCli ?? DEFAULT_AI_CLI;
30408
- const envVar = profileEnvVar(cmd);
30409
- const dirs = envVar === "CLAUDE_CONFIG_DIR" ? resolveDirs(config.claudeConfigDir) : envVar === "CODEX_HOME" ? resolveDirs(config.codexHome) : [];
30410
- return dirs.map((dir2) => profileDisplayLabel(dir2));
30569
+ return profileDirs(profilesFor(config, cmd)).map((dir2) => profileDisplayLabel(dir2));
30411
30570
  }
30412
30571
  function planAiRun(prompt, config, workingDir) {
30413
30572
  const cmd = config.aiCli ?? DEFAULT_AI_CLI;
30414
- const args = [...config.aiArgs ?? DEFAULT_AI_ARGS[cmd] ?? [], prompt];
30415
30573
  const baseEnv = config.aiEnv ?? {};
30416
30574
  const envVar = profileEnvVar(cmd);
30417
- const dirs = envVar ? envVar === "CLAUDE_CONFIG_DIR" ? resolveDirs(config.claudeConfigDir) : resolveDirs(config.codexHome) : [];
30418
- if (!envVar || dirs.length === 0) {
30419
- return { cmd, args, attempts: [{ label: "default", dir: null, env: { ...baseEnv } }] };
30420
- }
30421
- const ordered = workingDir && dirs.includes(workingDir) ? [workingDir, ...dirs.filter((d) => d !== workingDir)] : dirs;
30422
- const attempts = ordered.map((dir2) => ({
30575
+ const defaultPlan = {
30576
+ cmd,
30577
+ attempts: [
30578
+ { label: "default", dir: null, args: buildRunArgs({ profile: "" }, cmd, prompt), env: { ...baseEnv } }
30579
+ ]
30580
+ };
30581
+ if (!envVar) return defaultPlan;
30582
+ const resolved = profilesFor(config, cmd).filter((p) => p.profile && p.profile.trim()).map((p) => ({ profile: p, dir: resolveHomePath(p.profile) }));
30583
+ if (resolved.length === 0) return defaultPlan;
30584
+ const ordered = workingDir && resolved.some((r) => r.dir === workingDir) ? [
30585
+ resolved.find((r) => r.dir === workingDir),
30586
+ ...resolved.filter((r) => r.dir !== workingDir)
30587
+ ] : resolved;
30588
+ const attempts = ordered.map(({ profile, dir: dir2 }) => ({
30423
30589
  label: profileDisplayLabel(dir2),
30424
30590
  dir: dir2,
30591
+ args: buildRunArgs(profile, cmd, prompt),
30425
30592
  env: { ...baseEnv, [envVar]: dir2 }
30426
30593
  }));
30427
- return { cmd, args, attempts };
30594
+ return { cmd, attempts };
30428
30595
  }
30429
30596
 
30430
30597
  // src/core/claude-usage.ts
@@ -30779,6 +30946,13 @@ function recordCommand(entry) {
30779
30946
  function getCommandHistory() {
30780
30947
  return entries;
30781
30948
  }
30949
+ function pruneCommandHistoryByAge(days) {
30950
+ if (days <= 0) return;
30951
+ const cutoff = Date.now() - days * 24 * 60 * 60 * 1e3;
30952
+ const before = entries.length;
30953
+ entries = entries.filter((e) => new Date(e.startedAt).getTime() >= cutoff);
30954
+ if (entries.length !== before) persist();
30955
+ }
30782
30956
  function finishCommand(commandId, exitCode, usage) {
30783
30957
  const entry = entries.find((e) => e.commandId === commandId && e.status === "running");
30784
30958
  if (!entry) return;
@@ -30806,7 +30980,7 @@ import {
30806
30980
  readFileSync as readFileSync11,
30807
30981
  readdirSync as readdirSync3,
30808
30982
  rmSync as rmSync3,
30809
- statSync
30983
+ statSync as statSync2
30810
30984
  } from "fs";
30811
30985
  import { join as join11 } from "path";
30812
30986
  var KEEP_FILES = 200;
@@ -30839,11 +31013,22 @@ function readCommandOutput(commandId) {
30839
31013
  function prune() {
30840
31014
  if (!dir) return;
30841
31015
  try {
30842
- const files = readdirSync3(dir).filter((f) => f.endsWith(".log")).map((f) => ({ f, t: statSync(join11(dir, f)).mtimeMs })).sort((a, b) => b.t - a.t);
31016
+ const files = readdirSync3(dir).filter((f) => f.endsWith(".log")).map((f) => ({ f, t: statSync2(join11(dir, f)).mtimeMs })).sort((a, b) => b.t - a.t);
30843
31017
  for (const { f } of files.slice(KEEP_FILES)) rmSync3(join11(dir, f), { force: true });
30844
31018
  } catch {
30845
31019
  }
30846
31020
  }
31021
+ function pruneCommandOutputByAge(days) {
31022
+ if (!dir || days <= 0) return;
31023
+ const cutoff = Date.now() - days * 24 * 60 * 60 * 1e3;
31024
+ try {
31025
+ for (const f of readdirSync3(dir).filter((n) => n.endsWith(".log"))) {
31026
+ const p = join11(dir, f);
31027
+ if (statSync2(p).mtimeMs < cutoff) rmSync3(p, { force: true });
31028
+ }
31029
+ } catch {
31030
+ }
31031
+ }
30847
31032
 
30848
31033
  // src/core/fs-browse.ts
30849
31034
  import { readdir as readdir2 } from "fs/promises";
@@ -31087,9 +31272,14 @@ function subDirName(repo) {
31087
31272
  const raw = (repo.subdir || repo.role || repo.name || "repo").trim();
31088
31273
  return raw.replace(/[^\w.-]+/g, "-").replace(/^-+|-+$/g, "") || "repo";
31089
31274
  }
31090
- async function provisionRepos(target, repos, emit) {
31275
+ async function provisionRepos(target, repos, emit, opts = {}) {
31091
31276
  for (const repo of repos) {
31092
31277
  if (repo.primary) {
31278
+ if (opts.clonePrimary && repo.op === "existing" && repo.url) {
31279
+ emit("git", `Cloning ${repo.url}\u2026`);
31280
+ await run6("git", ["clone", repo.url, target], { timeout: 12e4 });
31281
+ continue;
31282
+ }
31093
31283
  emit("git", "Initializing the primary repo\u2026");
31094
31284
  await run6("git", ["init"], { cwd: target, timeout: 2e4 });
31095
31285
  if (repo.op === "existing" && repo.url) {
@@ -31176,19 +31366,15 @@ async function generateFile(path, prompt) {
31176
31366
  } catch {
31177
31367
  }
31178
31368
  }
31179
- async function addProject(payload, onProgress) {
31369
+ async function addProject(payload, profileDir2, onProgress) {
31180
31370
  const emit = (step, message) => onProgress?.({ projectId: payload.projectId, step, message });
31181
31371
  try {
31182
- const target = resolve5(payload.path);
31372
+ const root = process.env.SCAFFOLD_ROOT ? resolve5(process.env.SCAFFOLD_ROOT) : resolve5(profileDir2);
31373
+ const target = join12(root, payload.projectName);
31183
31374
  await mkdir2(target, { recursive: true });
31184
- if (payload.repoUrl) {
31185
- emit("clone", `Cloning ${payload.repoUrl}\u2026`);
31186
- await run6("git", ["clone", payload.repoUrl, target], { timeout: 12e4 });
31187
- } else {
31188
- emit("init", "git init\u2026");
31189
- await run6("git", ["init"], { cwd: target, timeout: 2e4 });
31190
- }
31191
- onProgress?.({ projectId: payload.projectId, step: "done", message: "Repo ready.", done: true });
31375
+ const repos = payload.repos ?? [];
31376
+ if (repos.length > 0) await provisionRepos(target, repos, emit, { clonePrimary: true });
31377
+ onProgress?.({ projectId: payload.projectId, step: "done", message: "Project added.", done: true });
31192
31378
  return { ok: true, path: target };
31193
31379
  } catch (err) {
31194
31380
  return { ok: false, error: err instanceof Error ? err.message : String(err) };
@@ -31198,9 +31384,10 @@ async function addProject(payload, onProgress) {
31198
31384
  // src/core/ws-client.ts
31199
31385
  var HEARTBEAT_MS = 3e4;
31200
31386
  var USAGE_POLL_MS = 5 * 6e4;
31387
+ var LOG_UPLOAD_MS = 10 * 6e4;
31201
31388
  var RECONNECT_BACKOFF_BASE_MS = 1e3;
31202
- var RECONNECT_MAX_BACKOFF_DEFAULT_SEC = 600;
31203
- var RECONNECT_BACKOFF_MAX_ATTEMPT = 10;
31389
+ var RECONNECT_MAX_BACKOFF_DEFAULT_SEC = 60;
31390
+ var RECONNECT_BACKOFF_MAX_ATTEMPT = 6;
31204
31391
  function envReconnectMaxSec() {
31205
31392
  const raw = Number(process.env.FOURPM_RECONNECT_MAX_BACKOFF_SEC);
31206
31393
  if (!Number.isFinite(raw)) return null;
@@ -31225,10 +31412,17 @@ var WsClient = class {
31225
31412
  context;
31226
31413
  socket = null;
31227
31414
  sessionKey = null;
31415
+ // True once the current connectOnce() reached hello_ack — a *real* session. Gates the
31416
+ // backoff reset so a socket that opens then closes before the handshake keeps backing off
31417
+ // instead of hammering the server every ~1s.
31418
+ sessionEstablished = false;
31228
31419
  ecdh = null;
31229
31420
  wsToken = "";
31230
31421
  heartbeat = null;
31231
31422
  usageTimer = null;
31423
+ logUploadTimer = null;
31424
+ /** Signature (fileName + totalBytes) of the last log upload — skip re-sending if unchanged. */
31425
+ lastLogSig = null;
31232
31426
  usageSnapshot = null;
31233
31427
  /** Whether the serving project requires outbound review before each AI spawn (ADR-0082). */
31234
31428
  outboundReviewEnabled = false;
@@ -31253,6 +31447,12 @@ var WsClient = class {
31253
31447
  connectWaiters = [];
31254
31448
  /** Output buffered while disconnected (flushed on reconnect). */
31255
31449
  offlineBuffer = [];
31450
+ /**
31451
+ * Command ids already accepted (insertion-ordered, bounded). WS delivery is at-least-once —
31452
+ * a redelivered `COMMAND_DISPATCH` (reconnect/blip during a long AI run) must NOT run again:
31453
+ * re-running an AI prompt would waste tokens and echo the prompt a second time in the console.
31454
+ */
31455
+ handledCommands = /* @__PURE__ */ new Set();
31256
31456
  offlineBufferBytes = 0;
31257
31457
  /** cli → server requests awaiting a reply (quota.check — ADR-0020). */
31258
31458
  pending = /* @__PURE__ */ new Map();
@@ -31301,12 +31501,20 @@ var WsClient = class {
31301
31501
  }
31302
31502
  /**
31303
31503
  * Resolve the reconnect-backoff cap (ms): the org value (from ws_token) over the
31304
- * env override over the default. Each is already clamped to the 10-min ceiling.
31504
+ * env override over the default. Each is already clamped to the 1-min ceiling.
31305
31505
  */
31306
31506
  reconnectMaxMs() {
31307
31507
  const sec = this.orgReconnectMaxSec ?? this.envReconnectMaxSec ?? RECONNECT_MAX_BACKOFF_DEFAULT_SEC;
31308
31508
  return sec * 1e3;
31309
31509
  }
31510
+ /** Mark a commandId as accepted for dedup, keeping the set bounded (drop the oldest). */
31511
+ markCommandHandled(commandId) {
31512
+ this.handledCommands.add(commandId);
31513
+ if (this.handledCommands.size > 1e3) {
31514
+ const oldest = this.handledCommands.values().next().value;
31515
+ if (oldest !== void 0) this.handledCommands.delete(oldest);
31516
+ }
31517
+ }
31310
31518
  /** Backoff sleep that can be woken early by reconnectNow(). */
31311
31519
  interruptibleSleep(ms) {
31312
31520
  if (this.forceReconnect) {
@@ -31334,8 +31542,10 @@ var WsClient = class {
31334
31542
  for (let attempt = 0; !this.stopped; attempt++) {
31335
31543
  try {
31336
31544
  await this.connectOnce();
31337
- attempt = 0;
31338
- hashcodeInvalidStreak = 0;
31545
+ if (this.sessionEstablished) {
31546
+ attempt = 0;
31547
+ hashcodeInvalidStreak = 0;
31548
+ }
31339
31549
  } catch (err) {
31340
31550
  if (err instanceof CliApiError) {
31341
31551
  if (["HASHCODE_EXPIRED", "LINK_REVOKED"].includes(err.errorCode)) {
@@ -31378,6 +31588,7 @@ var WsClient = class {
31378
31588
  connectOnce() {
31379
31589
  return new Promise((resolve6, reject) => {
31380
31590
  void (async () => {
31591
+ this.sessionEstablished = false;
31381
31592
  this.bus.setStatus("connecting");
31382
31593
  const token = await requestWsToken(this.credential.serverUrl, this.credential.hashcode3);
31383
31594
  this.wsToken = token.wsToken;
@@ -31391,6 +31602,11 @@ var WsClient = class {
31391
31602
  timezone: token.timezone
31392
31603
  });
31393
31604
  }
31605
+ if (typeof token.cliRetentionDays === "number" && token.cliRetentionDays > 0) {
31606
+ logger.setRetentionDays(token.cliRetentionDays);
31607
+ pruneCommandHistoryByAge(token.cliRetentionDays);
31608
+ pruneCommandOutputByAge(token.cliRetentionDays);
31609
+ }
31394
31610
  writeProfileConfig(this.context.profileDir, {
31395
31611
  sessionSwitchPct: token.projectTokens?.sessionSwitchPct ?? 0,
31396
31612
  perPromptTokenLimit: token.projectTokens?.perPromptTokenLimit ?? 0
@@ -31404,7 +31620,12 @@ var WsClient = class {
31404
31620
  } else if (token.scope) {
31405
31621
  this.bus.setScope(token.scope);
31406
31622
  }
31407
- const wsUrl = this.credential.serverUrl.replace(/^http/, "ws") + "/ws";
31623
+ if (token.wsUrl !== void 0 && token.wsUrl !== this.credential.wsUrl) {
31624
+ this.credential.wsUrl = token.wsUrl || void 0;
31625
+ writeCredential(this.context.profileDir, this.credential);
31626
+ }
31627
+ const wsBase = this.credential.wsUrl ?? this.credential.serverUrl;
31628
+ const wsUrl = wsBase.replace(/^http/, "ws") + "/ws";
31408
31629
  const socket = new wrapper_default(wsUrl);
31409
31630
  this.socket = socket;
31410
31631
  socket.on("open", () => {
@@ -31450,6 +31671,7 @@ var WsClient = class {
31450
31671
  if ("type" in message) {
31451
31672
  if (message.type === "hello_ack" && this.ecdh) {
31452
31673
  this.sessionKey = deriveSessionKey(this.ecdh, message.pubkey);
31674
+ this.sessionEstablished = true;
31453
31675
  if (message.workerName) this.bus.setWorker(message.workerName);
31454
31676
  if (message.projectName) {
31455
31677
  this.bus.setProject(message.projectName);
@@ -31466,6 +31688,7 @@ var WsClient = class {
31466
31688
  this.flushOfflineBuffer();
31467
31689
  this.startHeartbeat();
31468
31690
  this.startUsagePolling();
31691
+ this.startLogUpload();
31469
31692
  this.connectWaiters.splice(0).forEach((fn) => fn());
31470
31693
  return;
31471
31694
  }
@@ -31542,6 +31765,11 @@ var WsClient = class {
31542
31765
  }
31543
31766
  case WsChannels.COMMAND_DISPATCH: {
31544
31767
  const dispatch = payload;
31768
+ if (this.handledCommands.has(dispatch.commandId)) {
31769
+ logger.info("command.dispatch.duplicate", { commandId: dispatch.commandId });
31770
+ break;
31771
+ }
31772
+ this.markCommandHandled(dispatch.commandId);
31545
31773
  logger.info("command.dispatch", {
31546
31774
  commandId: dispatch.commandId,
31547
31775
  cmd: dispatch.cmd,
@@ -31635,6 +31863,11 @@ var WsClient = class {
31635
31863
  this.send(WsChannels.LOG_READ, { lines }, message.id);
31636
31864
  break;
31637
31865
  }
31866
+ case WsChannels.COMMAND_OUTPUT_READ: {
31867
+ const output = readCommandOutput(payload.commandId);
31868
+ this.send(WsChannels.COMMAND_OUTPUT_READ, { output }, message.id);
31869
+ break;
31870
+ }
31638
31871
  case WsChannels.PROJECT_CREATE:
31639
31872
  void scaffoldProject(
31640
31873
  payload,
@@ -31645,6 +31878,7 @@ var WsClient = class {
31645
31878
  case WsChannels.PROJECT_ADD:
31646
31879
  void addProject(
31647
31880
  payload,
31881
+ this.context.profileDir,
31648
31882
  (p) => this.send(WsChannels.PROJECT_PROGRESS, p)
31649
31883
  ).then((reply) => this.send(WsChannels.PROJECT_ADD, reply, message.id));
31650
31884
  break;
@@ -31736,17 +31970,18 @@ var WsClient = class {
31736
31970
  });
31737
31971
  }
31738
31972
  const plan = planAiRun(prompt, config, startDir ?? workingDir);
31973
+ const markerArgs = plan.attempts[0]?.args ?? [];
31739
31974
  this.bus.push({ source: origin, kind: "aireq", text: `${plan.cmd} \u2039 ${prompt}` });
31740
31975
  logger.info("command.ai", { commandId, origin, cmd: plan.cmd, profiles: plan.attempts.length });
31741
31976
  if (origin === "local") {
31742
31977
  this.send(WsChannels.COMMAND_ANNOUNCE, {
31743
31978
  commandId,
31744
31979
  cmd: plan.cmd,
31745
- args: plan.args,
31980
+ args: markerArgs,
31746
31981
  origin: "local"
31747
31982
  });
31748
31983
  }
31749
- recordCommand({ commandId, cmd: plan.cmd, args: plan.args });
31984
+ recordCommand({ commandId, cmd: plan.cmd, args: markerArgs });
31750
31985
  const startedAt = (/* @__PURE__ */ new Date()).toISOString();
31751
31986
  this.bus.startBusy(plan.cmd);
31752
31987
  let seq = 0;
@@ -31811,7 +32046,7 @@ var WsClient = class {
31811
32046
  this.send(WsChannels.COMMAND_HISTORY, {
31812
32047
  commandId,
31813
32048
  cmd: plan.cmd,
31814
- args: plan.args,
32049
+ args: markerArgs,
31815
32050
  status: result.exitCode === 0 ? "done" : "failed",
31816
32051
  exitCode: result.exitCode,
31817
32052
  tokens: result.usage.tokens,
@@ -31847,7 +32082,7 @@ var WsClient = class {
31847
32082
  if (threshold <= 0 || cmd !== "claude") return null;
31848
32083
  const util2 = this.usageSnapshot?.session.utilizationPct ?? 0;
31849
32084
  if (util2 < threshold) return null;
31850
- const dirs = claudeConfigDirs(config);
32085
+ const dirs = claudeHomeDirs(config);
31851
32086
  if (dirs.length < 2) return null;
31852
32087
  const currentIndex = workingDir ? dirs.indexOf(workingDir) : -1;
31853
32088
  const next = dirs[(currentIndex + 1) % dirs.length];
@@ -32048,6 +32283,8 @@ var WsClient = class {
32048
32283
  this.heartbeat = null;
32049
32284
  if (this.usageTimer) clearInterval(this.usageTimer);
32050
32285
  this.usageTimer = null;
32286
+ if (this.logUploadTimer) clearInterval(this.logUploadTimer);
32287
+ this.logUploadTimer = null;
32051
32288
  }
32052
32289
  /**
32053
32290
  * Poll the Claude subscription usage API and push a `machine.usage` snapshot (ADR-0072).
@@ -32060,7 +32297,7 @@ var WsClient = class {
32060
32297
  const config = readProfileConfig(this.context.profileDir);
32061
32298
  const aiCli2 = config.aiCli ?? "claude";
32062
32299
  const working = getWorkingProfile(this.context.profileDir, aiCli2);
32063
- const configured = claudeConfigDirs(config);
32300
+ const configured = claudeHomeDirs(config);
32064
32301
  const dirs = working ? [working, ...configured.filter((d) => d !== working)] : configured;
32065
32302
  const profile = working ? basename3(working) : dirs[0] ? basename3(dirs[0]) : "default";
32066
32303
  const api = await checkClaudeUsage(dirs, aiCli2);
@@ -32085,6 +32322,29 @@ var WsClient = class {
32085
32322
  this.usageTimer.unref?.();
32086
32323
  void this.pollUsage();
32087
32324
  }
32325
+ /** Start the periodic upload of the cli's own JSONL log to the server (ADR-0122). */
32326
+ startLogUpload() {
32327
+ if (this.logUploadTimer) clearInterval(this.logUploadTimer);
32328
+ this.logUploadTimer = setInterval(() => this.uploadLog(), LOG_UPLOAD_MS);
32329
+ this.logUploadTimer.unref?.();
32330
+ this.uploadLog();
32331
+ }
32332
+ /**
32333
+ * Upload the newest cli log file + the total log footprint to the server (ADR-0122), so it is
32334
+ * stored + retained server-side and counted in the machine user's storage. Skips when nothing
32335
+ * changed since the last upload. Best-effort — never disrupts the session.
32336
+ */
32337
+ uploadLog() {
32338
+ try {
32339
+ const upload = readLogUpload(join13(this.context.profileDir, "logs"));
32340
+ if (!upload) return;
32341
+ const sig = `${upload.fileName}:${upload.totalBytes}`;
32342
+ if (sig === this.lastLogSig) return;
32343
+ this.lastLogSig = sig;
32344
+ this.send(WsChannels.MACHINE_LOG, upload);
32345
+ } catch {
32346
+ }
32347
+ }
32088
32348
  /**
32089
32349
  * Logout: close WS, delete `.cre`, and prompt to pair again (Unlinked state).
32090
32350
  */
@@ -38258,9 +38518,9 @@ import { existsSync as existsSync10 } from "fs";
38258
38518
  import { join as join16 } from "path";
38259
38519
  var CONFIG_KEYS = [
38260
38520
  "aiCli",
38261
- "aiArgs",
38262
- "claudeConfigDir",
38521
+ "claudeHome",
38263
38522
  "codexHome",
38523
+ "antigravityHome",
38264
38524
  "physicPath",
38265
38525
  "commandHistoryUploadMinutes",
38266
38526
  "autoUpdate",
@@ -38276,14 +38536,13 @@ function parseConfigAssignment(key, valueTokens) {
38276
38536
  case "physicPath":
38277
38537
  if (!value) return { error: `Missing value for "${key}".` };
38278
38538
  return { patch: { [key]: value } };
38279
- case "claudeConfigDir":
38280
- case "codexHome": {
38539
+ case "claudeHome":
38540
+ case "codexHome":
38541
+ case "antigravityHome": {
38281
38542
  const dirs = valueTokens.filter((t) => t.trim());
38282
38543
  if (dirs.length === 0) return { error: `Missing value for "${key}".` };
38283
- return { patch: { [key]: dirs.length === 1 ? dirs[0] : dirs } };
38544
+ return { patch: { [key]: dirs.map((profile) => ({ profile, model: "" })) } };
38284
38545
  }
38285
- case "aiArgs":
38286
- return { patch: { aiArgs: valueTokens } };
38287
38546
  case "commandHistoryUploadMinutes": {
38288
38547
  const n = Number(value);
38289
38548
  if (!Number.isFinite(n) || n <= 0) return { error: `"${value}" is not a positive number.` };
@@ -38313,22 +38572,19 @@ function runConfig(ctx) {
38313
38572
  return;
38314
38573
  }
38315
38574
  if (sub === "init") {
38575
+ const writeDefaults = (verb) => {
38576
+ overwriteProfileConfig(dir2, defaultProfileConfig());
38577
+ ctx.print(`\u2714 ${verb} default config: ${path}`);
38578
+ ctx.print("add claude profiles: /config set claudeHome .claude .claude-1 (tried in order)");
38579
+ };
38316
38580
  if (existsSync10(path)) {
38317
- ctx.print(`config already exists: ${path} (use /config set <key> <value>)`, "warn");
38581
+ ctx.confirm(
38582
+ `config exists: ${path} \u2014 replace it with defaults? (y/N)`,
38583
+ () => writeDefaults("replaced with")
38584
+ );
38318
38585
  return;
38319
38586
  }
38320
- const defaults = {
38321
- aiCli: "claude",
38322
- aiArgs: ["-p"],
38323
- // Lists so failover across profiles is discoverable (ADR-0057) — add more dirs.
38324
- claudeConfigDir: [".claude"],
38325
- codexHome: [".codex"],
38326
- autoUpdate: true,
38327
- commandHistoryUploadMinutes: 10
38328
- };
38329
- writeProfileConfig(dir2, defaults);
38330
- ctx.print(`\u2714 wrote default config: ${path}`);
38331
- ctx.print("add claude profiles: /config set claudeConfigDir .claude .claude-1 (tried in order)");
38587
+ writeDefaults("wrote");
38332
38588
  return;
38333
38589
  }
38334
38590
  if (sub === "set") {
@@ -38729,6 +38985,7 @@ function App2({
38729
38985
  const [expanded, setExpanded] = (0, import_react26.useState)(() => /* @__PURE__ */ new Set());
38730
38986
  const [history, setHistory] = (0, import_react26.useState)(() => loadInputHistory(info.profileDir));
38731
38987
  const [startedAt] = (0, import_react26.useState)(() => Date.now());
38988
+ const [pendingConfirm, setPendingConfirm] = (0, import_react26.useState)(null);
38732
38989
  (0, import_react26.useEffect)(() => {
38733
38990
  const offTranscript = bus.onTranscript((entry) => {
38734
38991
  setEntries(
@@ -38785,8 +39042,16 @@ function App2({
38785
39042
  };
38786
39043
  const handleSubmit = (input) => {
38787
39044
  setScrollBack(0);
38788
- setHistory((prev) => appendInputHistory(info.profileDir, input, prev));
38789
39045
  const trimmed = input.trim();
39046
+ if (pendingConfirm) {
39047
+ const { onYes } = pendingConfirm;
39048
+ setPendingConfirm(null);
39049
+ bus.push({ source: "local", kind: "cmd", text: trimmed });
39050
+ if (/^y(es)?$/i.test(trimmed)) onYes();
39051
+ else bus.push({ source: "local", kind: "log", text: "cancelled." });
39052
+ return;
39053
+ }
39054
+ setHistory((prev) => appendInputHistory(info.profileDir, input, prev));
38790
39055
  if (trimmed.startsWith("/")) {
38791
39056
  bus.push({ source: "local", kind: "cmd", text: trimmed });
38792
39057
  runSlashCommand(trimmed, {
@@ -38798,6 +39063,10 @@ function App2({
38798
39063
  stdout.write("\x1B[2J\x1B[3J\x1B[H");
38799
39064
  },
38800
39065
  quit: () => exit(),
39066
+ confirm: (prompt, onYes) => {
39067
+ bus.push({ source: "local", kind: "log", text: prompt });
39068
+ setPendingConfirm({ prompt, onYes });
39069
+ },
38801
39070
  submitAi: (aiInput) => bus.submitLocal(aiInput),
38802
39071
  reconnect: () => bus.requestReconnect(),
38803
39072
  expand: expandFold,
@@ -38869,6 +39138,7 @@ async function runStart(profileDir2, profileName) {
38869
39138
  base: { version: CLI_VERSION, profile: profileName, scope: credential.scope }
38870
39139
  });
38871
39140
  logger.info("cli.start", { serverUrl: credential.serverUrl });
39141
+ ensureProfileConfig(profileDir2);
38872
39142
  const config = readProfileConfig(profileDir2);
38873
39143
  const autoUpdate = config.autoUpdate !== false && process.env.FOURPM_NO_UPDATE !== "1";
38874
39144
  const result = await checkAndUpdate(credential.serverUrl, autoUpdate);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@4pm/cli",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "private": false,
5
5
  "description": "4PM CLI — drives Claude/Codex CLIs on AI worker machines (Node + TypeScript)",
6
6
  "type": "module",