@4pm/cli 0.5.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 +1229 -199
  3. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -1995,14 +1995,14 @@ var require_react_development = __commonJS({
1995
1995
  var thenableResult = result;
1996
1996
  var wasAwaited = false;
1997
1997
  var thenable = {
1998
- then: function(resolve5, reject) {
1998
+ then: function(resolve6, reject) {
1999
1999
  wasAwaited = true;
2000
2000
  thenableResult.then(function(returnValue2) {
2001
2001
  popActScope(prevActScopeDepth);
2002
2002
  if (actScopeDepth === 0) {
2003
- recursivelyFlushAsyncActWork(returnValue2, resolve5, reject);
2003
+ recursivelyFlushAsyncActWork(returnValue2, resolve6, reject);
2004
2004
  } else {
2005
- resolve5(returnValue2);
2005
+ resolve6(returnValue2);
2006
2006
  }
2007
2007
  }, function(error2) {
2008
2008
  popActScope(prevActScopeDepth);
@@ -2032,20 +2032,20 @@ var require_react_development = __commonJS({
2032
2032
  ReactCurrentActQueue.current = null;
2033
2033
  }
2034
2034
  var _thenable = {
2035
- then: function(resolve5, reject) {
2035
+ then: function(resolve6, reject) {
2036
2036
  if (ReactCurrentActQueue.current === null) {
2037
2037
  ReactCurrentActQueue.current = [];
2038
- recursivelyFlushAsyncActWork(returnValue, resolve5, reject);
2038
+ recursivelyFlushAsyncActWork(returnValue, resolve6, reject);
2039
2039
  } else {
2040
- resolve5(returnValue);
2040
+ resolve6(returnValue);
2041
2041
  }
2042
2042
  }
2043
2043
  };
2044
2044
  return _thenable;
2045
2045
  } else {
2046
2046
  var _thenable2 = {
2047
- then: function(resolve5, reject) {
2048
- resolve5(returnValue);
2047
+ then: function(resolve6, reject) {
2048
+ resolve6(returnValue);
2049
2049
  }
2050
2050
  };
2051
2051
  return _thenable2;
@@ -2061,7 +2061,7 @@ var require_react_development = __commonJS({
2061
2061
  actScopeDepth = prevActScopeDepth;
2062
2062
  }
2063
2063
  }
2064
- function recursivelyFlushAsyncActWork(returnValue, resolve5, reject) {
2064
+ function recursivelyFlushAsyncActWork(returnValue, resolve6, reject) {
2065
2065
  {
2066
2066
  var queue = ReactCurrentActQueue.current;
2067
2067
  if (queue !== null) {
@@ -2070,16 +2070,16 @@ var require_react_development = __commonJS({
2070
2070
  enqueueTask(function() {
2071
2071
  if (queue.length === 0) {
2072
2072
  ReactCurrentActQueue.current = null;
2073
- resolve5(returnValue);
2073
+ resolve6(returnValue);
2074
2074
  } else {
2075
- recursivelyFlushAsyncActWork(returnValue, resolve5, reject);
2075
+ recursivelyFlushAsyncActWork(returnValue, resolve6, reject);
2076
2076
  }
2077
2077
  });
2078
2078
  } catch (error2) {
2079
2079
  reject(error2);
2080
2080
  }
2081
2081
  } else {
2082
- resolve5(returnValue);
2082
+ resolve6(returnValue);
2083
2083
  }
2084
2084
  }
2085
2085
  }
@@ -24450,7 +24450,7 @@ function resolveProfileArg(args) {
24450
24450
  const rest = [...args.slice(0, index), ...args.slice(index + 2)];
24451
24451
  return [name, rest];
24452
24452
  }
24453
- return [process.env.FOURPM_PROFILE ?? null, args];
24453
+ return [process.env.FOURPM_PROFILE || null, args];
24454
24454
  }
24455
24455
  function listProfiles() {
24456
24456
  const base = join(homedir(), ".4pm", "profiles");
@@ -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
  }
@@ -24842,6 +24869,30 @@ function backoffJitterMs(attempt, baseMs = 500, maxMs = 1e4) {
24842
24869
  const exp = Math.min(baseMs * 2 ** attempt, maxMs);
24843
24870
  return Math.floor(exp / 2 + Math.random() * (exp / 2));
24844
24871
  }
24872
+ function looksLikeJsonOrCode(text) {
24873
+ const t = text.replace(/^\s+/, "");
24874
+ if (t.startsWith("```")) return "code";
24875
+ if (t.startsWith("{") || t.startsWith("[")) return "json";
24876
+ return null;
24877
+ }
24878
+ function prettyResult(text) {
24879
+ const trimmed = text.trim();
24880
+ if (trimmed.startsWith("{") || trimmed.startsWith("[")) {
24881
+ try {
24882
+ return JSON.stringify(JSON.parse(trimmed), null, 2);
24883
+ } catch {
24884
+ const start = trimmed.search(/[{[]/);
24885
+ const end = Math.max(trimmed.lastIndexOf("}"), trimmed.lastIndexOf("]"));
24886
+ if (start >= 0 && end > start) {
24887
+ try {
24888
+ return JSON.stringify(JSON.parse(trimmed.slice(start, end + 1)), null, 2);
24889
+ } catch {
24890
+ }
24891
+ }
24892
+ }
24893
+ }
24894
+ return trimmed;
24895
+ }
24845
24896
 
24846
24897
  // ../../22-packages/11-shared/11-constants/src/roles.ts
24847
24898
  var Role = {
@@ -24880,6 +24931,8 @@ var PermissionCode = {
24880
24931
  // ADMIN resets a sub-account's password directly (user-0010)
24881
24932
  USER_SET_PASSWORD: "user.set_password",
24882
24933
  USER_DELETE: "user.delete",
24934
+ // org-level pause/resume of a user (ADR-0093)
24935
+ USER_PAUSE: "user.pause",
24883
24936
  // invite users by email (ADR-0021)
24884
24937
  USER_INVITE: "user.invite",
24885
24938
  // team
@@ -24904,6 +24957,11 @@ var PermissionCode = {
24904
24957
  PROJECT_DETACH_USER: "project.detach_user",
24905
24958
  PROJECT_ATTACH_MANAGER: "project.attach_manager",
24906
24959
  PROJECT_DETACH_MANAGER: "project.detach_manager",
24960
+ // project lifecycle (ADR-0092): pause+resume / close+reopen
24961
+ PROJECT_PAUSE: "project.pause",
24962
+ PROJECT_CLOSE: "project.close",
24963
+ // project-membership pause+resume of a direct member (ADR-0093)
24964
+ PROJECT_PAUSE_MEMBER: "project.pause_member",
24907
24965
  PROJECT_AI_ASSIST: "project.ai_assist",
24908
24966
  PROJECT_MEMO_READ: "project.memo_read",
24909
24967
  PROJECT_MEMO_WRITE: "project.memo_write",
@@ -24932,6 +24990,9 @@ var PermissionCode = {
24932
24990
  // billing
24933
24991
  BILLING_READ: "billing.read",
24934
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",
24935
24996
  // integration (community-server — ADR-0058; served by @4pm/community)
24936
24997
  INTEGRATION_READ: "integration.read",
24937
24998
  INTEGRATION_CONNECT: "integration.connect",
@@ -24971,9 +25032,12 @@ var ROLE_DEFAULT_PERMISSIONS = {
24971
25032
  P.USER_READ,
24972
25033
  P.USER_UPDATE,
24973
25034
  P.USER_DELETE,
25035
+ P.USER_PAUSE,
24974
25036
  P.USER_INVITE,
24975
25037
  P.QUOTA_READ,
24976
25038
  P.QUOTA_MANAGE,
25039
+ P.TEMPLATE_READ,
25040
+ P.TEMPLATE_WRITE,
24977
25041
  P.TEAM_LIST,
24978
25042
  P.TEAM_CREATE,
24979
25043
  P.TEAM_READ,
@@ -24994,6 +25058,9 @@ var ROLE_DEFAULT_PERMISSIONS = {
24994
25058
  P.PROJECT_DETACH_USER,
24995
25059
  P.PROJECT_ATTACH_MANAGER,
24996
25060
  P.PROJECT_DETACH_MANAGER,
25061
+ P.PROJECT_PAUSE,
25062
+ P.PROJECT_CLOSE,
25063
+ P.PROJECT_PAUSE_MEMBER,
24997
25064
  P.PROJECT_AI_ASSIST,
24998
25065
  P.PROJECT_MEMO_READ,
24999
25066
  P.PROJECT_MEMO_WRITE,
@@ -25060,7 +25127,9 @@ var ROLE_DEFAULT_PERMISSIONS = {
25060
25127
  P.INTEGRATION_PAGE_WRITE
25061
25128
  ]
25062
25129
  };
25063
- var TL_DEFAULT_PERMISSIONS = ROLE_DEFAULT_PERMISSIONS[Role.PM].filter((code) => code !== P.USER_DELETE);
25130
+ var TL_DEFAULT_PERMISSIONS = ROLE_DEFAULT_PERMISSIONS[Role.PM].filter(
25131
+ (code) => code !== P.USER_DELETE && code !== P.USER_PAUSE
25132
+ );
25064
25133
  var PROJECT_SCOPE_CHECKED_CODES = [
25065
25134
  P.PROJECT_UPDATE,
25066
25135
  P.PROJECT_DELETE,
@@ -25070,6 +25139,9 @@ var PROJECT_SCOPE_CHECKED_CODES = [
25070
25139
  P.PROJECT_DETACH_USER,
25071
25140
  P.PROJECT_ATTACH_MANAGER,
25072
25141
  P.PROJECT_DETACH_MANAGER,
25142
+ P.PROJECT_PAUSE,
25143
+ P.PROJECT_CLOSE,
25144
+ P.PROJECT_PAUSE_MEMBER,
25073
25145
  P.PROJECT_AI_ASSIST
25074
25146
  ];
25075
25147
  var SELF_SERVICE_CODES = [
@@ -25104,6 +25176,9 @@ var TEAM_GRANTABLE_CODES = [
25104
25176
  P.PROJECT_DETACH_USER,
25105
25177
  P.PROJECT_ATTACH_MANAGER,
25106
25178
  P.PROJECT_DETACH_MANAGER,
25179
+ P.PROJECT_PAUSE,
25180
+ P.PROJECT_CLOSE,
25181
+ P.PROJECT_PAUSE_MEMBER,
25107
25182
  P.PROJECT_AI_ASSIST,
25108
25183
  P.PROJECT_MEMO_READ,
25109
25184
  P.PROJECT_MEMO_WRITE,
@@ -25122,6 +25197,9 @@ var PROJECT_GRANTABLE_CODES = [
25122
25197
  P.PROJECT_DETACH_USER,
25123
25198
  P.PROJECT_ATTACH_MANAGER,
25124
25199
  P.PROJECT_DETACH_MANAGER,
25200
+ P.PROJECT_PAUSE,
25201
+ P.PROJECT_CLOSE,
25202
+ P.PROJECT_PAUSE_MEMBER,
25125
25203
  P.PROJECT_AI_ASSIST,
25126
25204
  P.PROJECT_MEMO_READ,
25127
25205
  P.PROJECT_MEMO_WRITE,
@@ -25178,6 +25256,12 @@ var QuotaOnExceed = {
25178
25256
  WARN: "warn"
25179
25257
  };
25180
25258
 
25259
+ // ../../22-packages/11-shared/11-constants/src/billing.ts
25260
+ var PaymentProvider = {
25261
+ STRIPE: "stripe",
25262
+ PAYPAL: "paypal"
25263
+ };
25264
+
25181
25265
  // ../../node_modules/.pnpm/zod@3.25.76/node_modules/zod/v3/external.js
25182
25266
  var external_exports = {};
25183
25267
  __export(external_exports, {
@@ -29228,6 +29312,7 @@ var baseRequestSchema = external_exports.object({
29228
29312
  order: external_exports.enum(["asc", "desc"]).default("desc")
29229
29313
  });
29230
29314
  var pinnedFilterSchema = external_exports.enum(["true", "false"]).transform((v) => v === "true").optional();
29315
+ var deletedFilterSchema = external_exports.enum(["true", "false"]).transform((v) => v === "true").optional();
29231
29316
 
29232
29317
  // ../../22-packages/11-shared/41-validation/src/schemas.ts
29233
29318
  var passwordSchema = external_exports.string().min(8, "password: at least 8 characters").max(72, "password: at most 72 characters").regex(/[a-zA-Z]/, "password: needs at least 1 letter").regex(/[0-9]/, "password: needs at least 1 digit");
@@ -29243,12 +29328,18 @@ var ipAllowlistSchema = external_exports.array(ipCidrSchema).max(50).default([])
29243
29328
  var hexTokenSchema = external_exports.string().regex(/^[0-9a-f]{64}$/, "token: invalid format");
29244
29329
 
29245
29330
  // ../../22-packages/11-shared/31-dto/src/auth.ts
29331
+ var paymentProviderSchema = external_exports.enum([
29332
+ PaymentProvider.STRIPE,
29333
+ PaymentProvider.PAYPAL
29334
+ ]);
29246
29335
  var registerRequestSchema = external_exports.object({
29247
29336
  username: usernameSchema,
29248
29337
  email: emailSchema,
29249
29338
  password: passwordSchema,
29250
29339
  phone: phoneSchema,
29251
- ipAllowlist: ipAllowlistSchema.optional()
29340
+ ipAllowlist: ipAllowlistSchema.optional(),
29341
+ planCode: external_exports.string().min(1).max(50).optional(),
29342
+ provider: paymentProviderSchema.optional()
29252
29343
  });
29253
29344
  var mailTokenRequestSchema = external_exports.object({
29254
29345
  token: hexTokenSchema
@@ -29292,21 +29383,37 @@ var resetPasswordRequestSchema = external_exports.object({
29292
29383
 
29293
29384
  // ../../22-packages/11-shared/31-dto/src/org.ts
29294
29385
  var CLI_SETTINGS_BOUNDS = {
29295
- reconnectMaxBackoffSec: { min: 5, max: 600 },
29386
+ reconnectMaxBackoffSec: { min: 5, max: 60 },
29296
29387
  autoUpdateHour: { min: 0, max: 23 }
29297
29388
  };
29389
+ var orgAliasSchema = external_exports.string().regex(/^([a-z0-9-]{3,40})?$/, "alias must be 3\u201340 chars of a\u2013z, 0\u20139, -");
29298
29390
  var updateOrgRequestSchema = external_exports.object({
29299
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(),
29300
29394
  settings: external_exports.record(external_exports.unknown()).optional()
29301
29395
  });
29302
29396
 
29303
29397
  // ../../22-packages/11-shared/31-dto/src/user.ts
29304
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
+ });
29305
29410
  var rolesSchema = external_exports.array(external_exports.enum(ALL_ROLES)).min(1).refine((roles) => isValidRoleSet(roles), {
29306
29411
  message: "ADMIN/MACHINE cannot be combined with other roles"
29307
29412
  });
29308
29413
  var listUsersQuerySchema = baseRequestSchema.extend({
29309
- role: external_exports.enum(ALL_ROLES).optional()
29414
+ role: external_exports.enum(ALL_ROLES).optional(),
29415
+ /** ADMIN-only Trash: list soft-deleted users (ADR-0109). */
29416
+ deleted: deletedFilterSchema
29310
29417
  });
29311
29418
  var createUserRequestSchema = external_exports.object({
29312
29419
  username: usernameSchema,
@@ -29321,7 +29428,11 @@ var updateUserRequestSchema = external_exports.object({
29321
29428
  email: emailSchema.optional(),
29322
29429
  phone: phoneSchema.nullable().optional(),
29323
29430
  roles: rolesSchema.optional(),
29324
- 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()
29325
29436
  });
29326
29437
  var setUserPasswordRequestSchema = external_exports.object({
29327
29438
  password: passwordSchema
@@ -29342,7 +29453,9 @@ var contactChangeConfirmSchema = external_exports.object({
29342
29453
 
29343
29454
  // ../../22-packages/11-shared/31-dto/src/team.ts
29344
29455
  var listTeamsQuerySchema = baseRequestSchema.extend({
29345
- pinned: pinnedFilterSchema
29456
+ pinned: pinnedFilterSchema,
29457
+ /** ADMIN-only Trash: list soft-deleted teams (ADR-0109). */
29458
+ deleted: deletedFilterSchema
29346
29459
  });
29347
29460
  var createTeamRequestSchema = external_exports.object({
29348
29461
  name: external_exports.string().min(1).max(100),
@@ -29358,9 +29471,14 @@ var attachTeamUserRequestSchema = external_exports.object({
29358
29471
  isLead: external_exports.boolean().optional()
29359
29472
  });
29360
29473
 
29474
+ // ../../22-packages/11-shared/31-dto/src/template.ts
29475
+ var TEMPLATE_KINDS = ["report", "est", "wbs", "proposal", "feasibility"];
29476
+
29361
29477
  // ../../22-packages/11-shared/31-dto/src/project.ts
29362
29478
  var listProjectsQuerySchema = baseRequestSchema.extend({
29363
- pinned: pinnedFilterSchema
29479
+ pinned: pinnedFilterSchema,
29480
+ /** ADMIN-only Trash: list soft-deleted projects (ADR-0109). */
29481
+ deleted: deletedFilterSchema
29364
29482
  });
29365
29483
  var recentProjectsQuerySchema = external_exports.object({
29366
29484
  limit: external_exports.coerce.number().int().min(1).max(20).default(5)
@@ -29370,11 +29488,37 @@ var createProjectRequestSchema = external_exports.object({
29370
29488
  description: external_exports.string().max(2e3).optional(),
29371
29489
  machineUserId: external_exports.string().uuid().optional()
29372
29490
  });
29491
+ var DEFAULT_OUTBOUND_RULES = {
29492
+ secret: [
29493
+ String.raw`/\b(api[_-]?key|secret|password|passwd|access[_-]?token|bearer)\b\s*[:=]\s*\S+/i`,
29494
+ String.raw`/AKIA[0-9A-Z]{16}/`,
29495
+ String.raw`/\b(gh[pousr]_[A-Za-z0-9]{20,}|xox[baprs]-[A-Za-z0-9-]{10,}|sk-[A-Za-z0-9]{20,})\b/`,
29496
+ String.raw`/-----BEGIN (?:RSA |OPENSSH |EC |DSA )?PRIVATE KEY-----/`,
29497
+ String.raw`/\b[A-Z][A-Z0-9_]*(?:KEY|TOKEN|SECRET|PASSWORD)\s*=\s*\S{6,}/`
29498
+ ],
29499
+ environment: [
29500
+ String.raw`/\b(?:ssh|scp|rsync)\s+[\w.-]+@/i`,
29501
+ String.raw`/\bsudo\b/i`,
29502
+ String.raw`/\bkubectl\b/i`,
29503
+ String.raw`/\bdocker\s+(?:push|-H|--host)\b/i`,
29504
+ String.raw`/\b(?:aws|gcloud|az)\s+\w+/i`,
29505
+ String.raw`/\bcurl\b[^\n]*\bhttps?:\/\/(?!localhost|127\.0\.0\.1)/i`
29506
+ ]
29507
+ };
29508
+ function compileRulePattern(entry) {
29509
+ const literal = /^\/(.+)\/([gimsuy]*)$/s.exec(entry.trim());
29510
+ if (literal) return new RegExp(literal[1] ?? "", literal[2] ?? "");
29511
+ return new RegExp(entry.trim());
29512
+ }
29373
29513
  var updateProjectRequestSchema = external_exports.object({
29374
29514
  name: external_exports.string().min(1).max(100).optional(),
29375
29515
  description: external_exports.string().max(2e3).optional(),
29376
29516
  // Mid-tier IP allowlist (ADR-0050) — PM(manage)/ADMIN only; must fall within org.
29377
- ipAllowlist: ipAllowlistSchema.optional()
29517
+ ipAllowlist: ipAllowlistSchema.optional(),
29518
+ // Messenger retention in days (0 = keep forever) — manager only (ADR-0078).
29519
+ messengerRetentionDays: external_exports.number().int().min(0).max(3650).optional(),
29520
+ // Project settings (ADR-0081) — key-level shallow-merge; `tokens` clamped on read.
29521
+ settings: external_exports.record(external_exports.unknown()).optional()
29378
29522
  });
29379
29523
  var MEMO_MAX_LENGTH = 65536;
29380
29524
  var putMemoRequestSchema = external_exports.object({
@@ -29390,9 +29534,26 @@ var updateMemoItemSchema = external_exports.object({
29390
29534
  }).refine((v) => v.content !== void 0 || v.done !== void 0, {
29391
29535
  message: "content or done required"
29392
29536
  });
29537
+ var putSpecDraftRequestSchema = external_exports.object({
29538
+ spec: external_exports.record(external_exports.unknown()),
29539
+ specVersion: external_exports.number().int().positive().optional()
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
+ });
29393
29552
  var repoSpecSchema = external_exports.object({
29394
29553
  /** Role label (e.g. "docs" / "web" / "server") — descriptive, not constrained. */
29395
29554
  role: external_exports.string().max(60).optional().default(""),
29555
+ /** Free-form description of what this repo holds (optional). */
29556
+ desc: external_exports.string().max(500).optional().default(""),
29396
29557
  /** Exactly one repo of the project is the primary. */
29397
29558
  primary: external_exports.boolean().optional().default(false),
29398
29559
  /** `create` a new repo (provider) or use an `existing` one (url). */
@@ -29404,14 +29565,44 @@ var repoSpecSchema = external_exports.object({
29404
29565
  /** Existing repo url to clone/link (op=existing). */
29405
29566
  url: external_exports.string().max(500).optional().default(""),
29406
29567
  /** Sub-repo subfolder under the target root (empty ⇒ derived from role/name). */
29407
- subdir: external_exports.string().max(120).optional().default("")
29568
+ subdir: external_exports.string().max(120).optional().default(""),
29569
+ /** Default branch for this repo (empty ⇒ provider default, e.g. `main`). */
29570
+ defaultBranch: external_exports.string().max(100).optional().default(""),
29571
+ /** `.gitignore` template name for this repo (empty ⇒ none). */
29572
+ gitignore: external_exports.string().max(40).optional().default(""),
29573
+ /** Commit-message convention for this repo (empty ⇒ unspecified). */
29574
+ commitConvention: external_exports.string().max(40).optional().default("")
29575
+ });
29576
+ var specFieldEnvelopeSchema = external_exports.object({
29577
+ id: external_exports.string().min(1),
29578
+ name: external_exports.string().optional().default(""),
29579
+ description: external_exports.string().optional().default(""),
29580
+ type: external_exports.string().optional().default("text"),
29581
+ options: external_exports.array(external_exports.string()).optional(),
29582
+ value: external_exports.union([external_exports.string(), external_exports.array(external_exports.string())]).optional().default("")
29583
+ });
29584
+ var subagentSpecSchema = external_exports.object({
29585
+ name: external_exports.string().max(120).optional().default(""),
29586
+ description: external_exports.string().max(2e3).optional().default("")
29408
29587
  });
29409
29588
  var projectSpecSchema = external_exports.object({
29410
29589
  specVersion: external_exports.number().int().positive(),
29411
- id: external_exports.string().min(1),
29412
- name: external_exports.string().min(1),
29413
- repos: external_exports.array(repoSpecSchema).optional()
29414
- }).passthrough().superRefine((v, ctx) => {
29590
+ fields: external_exports.array(specFieldEnvelopeSchema),
29591
+ repos: external_exports.array(repoSpecSchema).optional().default([]),
29592
+ subagents: external_exports.array(subagentSpecSchema).optional().default([]),
29593
+ meta: external_exports.record(external_exports.unknown()).optional()
29594
+ }).superRefine((v, ctx) => {
29595
+ const byId = new Map(v.fields.map((f) => [f.id, f.value]));
29596
+ for (const key of ["id", "name"]) {
29597
+ const val = byId.get(key);
29598
+ if (typeof val !== "string" || val.trim() === "") {
29599
+ ctx.addIssue({
29600
+ code: external_exports.ZodIssueCode.custom,
29601
+ path: ["fields"],
29602
+ message: `the "${key}" field is required`
29603
+ });
29604
+ }
29605
+ }
29415
29606
  if (v.repos && v.repos.length > 0) {
29416
29607
  const primaries = v.repos.filter((r) => r.primary).length;
29417
29608
  if (primaries !== 1) {
@@ -29425,28 +29616,39 @@ var projectSpecSchema = external_exports.object({
29425
29616
  });
29426
29617
  var createFromSpecRequestSchema = external_exports.object({
29427
29618
  projectId: external_exports.string().uuid(),
29428
- /** Target directory on the worker (from FsPicker — machine-0007). */
29429
- path: external_exports.string().min(1).max(500),
29430
29619
  spec: projectSpecSchema
29431
29620
  });
29432
29621
  var addRepoRequestSchema = external_exports.object({
29433
29622
  projectId: external_exports.string().uuid(),
29434
- path: external_exports.string().min(1).max(500),
29435
- /** Repo to clone; empty ⇒ `git init` in place. */
29436
- repoUrl: external_exports.string().url().max(500).optional(),
29437
- 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
+ }
29438
29643
  });
29439
- var specSuggestRequestSchema = external_exports.object({
29440
- projectId: external_exports.string().uuid(),
29441
- machineLinkId: external_exports.string().uuid(),
29442
- field: external_exports.string().min(1).max(100),
29443
- /** The spec-so-far (may be incomplete) for context. */
29444
- spec: external_exports.record(external_exports.unknown())
29644
+
29645
+ // ../../22-packages/11-shared/31-dto/src/activity.ts
29646
+ var listActivityQuerySchema = baseRequestSchema.extend({
29647
+ /** Filter by action prefix, e.g. "project." */
29648
+ action: external_exports.string().max(50).optional()
29445
29649
  });
29446
- var specReviewRequestSchema = external_exports.object({
29447
- projectId: external_exports.string().uuid(),
29448
- machineLinkId: external_exports.string().uuid(),
29449
- spec: external_exports.record(external_exports.unknown())
29650
+ var lifecycleActionRequestSchema = external_exports.object({
29651
+ note: external_exports.string().max(500).optional()
29450
29652
  });
29451
29653
 
29452
29654
  // ../../22-packages/11-shared/31-dto/src/permission.ts
@@ -29505,17 +29707,43 @@ var updateMachineLinkRequestSchema = external_exports.object({
29505
29707
  });
29506
29708
 
29507
29709
  // ../../22-packages/11-shared/31-dto/src/command.ts
29710
+ var COMMAND_MAX_LEN = 8e3;
29711
+ var AI_PROMPT_MAX_LEN = 2e5;
29508
29712
  var dispatchCommandRequestSchema = external_exports.object({
29509
29713
  projectId: external_exports.string().uuid(),
29510
29714
  machineLinkId: external_exports.string().uuid(),
29511
- // In AI mode `command` carries the raw prompt (can be long), else an executable name.
29512
- command: external_exports.string().min(1).max(8e3),
29715
+ // In AI mode `command` carries the raw prompt (can be long — the spec-assist prompts
29716
+ // embed the whole self-describing spec envelope, ADR-0106), else an executable name.
29717
+ command: external_exports.string().min(1).max(AI_PROMPT_MAX_LEN),
29513
29718
  args: external_exports.array(external_exports.string().max(1e3)).max(50).optional(),
29514
29719
  /**
29515
29720
  * AI prompt mode: run `command` as a prompt through the cli's AI CLI (profile
29516
29721
  * failover) instead of spawning it verbatim (mirrors a locally-typed prompt).
29517
29722
  */
29518
- ai: external_exports.boolean().optional()
29723
+ ai: external_exports.boolean().optional(),
29724
+ /**
29725
+ * Short human label shown to OTHER tabs watching this cli's activity feed (ADR-0101):
29726
+ * e.g. "AI review" so the Console shows a friendly line instead of the raw prompt.
29727
+ */
29728
+ label: external_exports.string().max(120).optional()
29729
+ }).superRefine((v, ctx) => {
29730
+ if (!v.ai && v.command.length > COMMAND_MAX_LEN) {
29731
+ ctx.addIssue({
29732
+ code: external_exports.ZodIssueCode.too_big,
29733
+ maximum: COMMAND_MAX_LEN,
29734
+ type: "string",
29735
+ inclusive: true,
29736
+ path: ["command"],
29737
+ message: `command too long (max ${COMMAND_MAX_LEN})`
29738
+ });
29739
+ }
29740
+ });
29741
+ var listCommandsQuerySchema = baseRequestSchema.extend({
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()
29519
29747
  });
29520
29748
 
29521
29749
  // ../../22-packages/11-shared/31-dto/src/invitation.ts
@@ -29603,16 +29831,68 @@ var planLimitSchema = external_exports.object({
29603
29831
  period: external_exports.enum([QuotaPeriod.DAY, QuotaPeriod.MONTH]),
29604
29832
  onExceed: external_exports.enum([QuotaOnExceed.BLOCK, QuotaOnExceed.WARN]).default(QuotaOnExceed.BLOCK)
29605
29833
  });
29834
+ var planEntitlementsSchema = external_exports.object({
29835
+ /** Max human seats (MACHINE excluded — ADR-0041); null = unlimited. */
29836
+ maxUsers: external_exports.number().int().min(1).nullable().default(null),
29837
+ /** Max projects; null = unlimited. */
29838
+ maxProjects: external_exports.number().int().min(1).nullable().default(null),
29839
+ /** Allowed to create teams. */
29840
+ teams: external_exports.boolean().default(false),
29841
+ /** Allowed to use Community/4rum. */
29842
+ community: external_exports.boolean().default(false),
29843
+ /** Retention cap (days) for log/command/chat; null = unlimited. */
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)
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
+ };
29871
+ var checkoutRequestSchema = external_exports.object({
29872
+ planCode: external_exports.string().min(1).max(50),
29873
+ provider: external_exports.enum([PaymentProvider.STRIPE, PaymentProvider.PAYPAL]).optional()
29874
+ });
29606
29875
  var upsertPlanRequestSchema = external_exports.object({
29607
29876
  name: external_exports.string().min(1).max(100),
29608
29877
  price: external_exports.number().int().min(0).default(0),
29609
- limits: external_exports.array(planLimitSchema).default([])
29878
+ limits: external_exports.array(planLimitSchema).default([]),
29879
+ entitlements: planEntitlementsSchema.optional()
29610
29880
  });
29611
29881
  var assignPlanRequestSchema = external_exports.object({
29612
29882
  planCode: external_exports.string().min(1).max(50),
29613
29883
  /** Cycle length in days (defaults to 30). */
29614
29884
  periodDays: external_exports.number().int().min(1).max(366).default(30)
29615
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
+ });
29616
29896
 
29617
29897
  // ../../22-packages/11-shared/31-dto/src/search.ts
29618
29898
  var SearchHitType = {
@@ -29626,11 +29906,11 @@ var SearchHitType = {
29626
29906
  MILESTONE: "milestone",
29627
29907
  /** Page/wiki (confluence + project docs — community). */
29628
29908
  PAGE: "page",
29629
- /** Message trong channel (Teams channel — community). */
29909
+ /** A message in a channel (Teams channel — community). */
29630
29910
  MESSAGE: "message",
29631
- /** Message direct/group (lịch sử trò chuyện — community). */
29911
+ /** A direct/group message (chat history — community). */
29632
29912
  CHAT: "chat",
29633
- /** Bài 4rum (community news/forum). */
29913
+ /** A forum post (community news/forum). */
29634
29914
  POST: "post"
29635
29915
  };
29636
29916
  var searchQuerySchema = external_exports.object({
@@ -29670,6 +29950,12 @@ var WsChannels = {
29670
29950
  FS_READ: "fs.read",
29671
29951
  GIT_DIFF: "git.diff",
29672
29952
  GIT_ENV: "git.env",
29953
+ // git history browse (read-through cli — ADR-0089): repos on the physic project,
29954
+ // commit log, changed files of a commit, parent↔commit diff of one file.
29955
+ GIT_REPOS: "git.repos",
29956
+ GIT_LOG: "git.log",
29957
+ GIT_COMMIT: "git.commit",
29958
+ GIT_COMMIT_DIFF: "git.commit-diff",
29673
29959
  PROJECT_CREATE: "project.create",
29674
29960
  PROJECT_ADD: "project.add",
29675
29961
  PROJECT_PROGRESS: "project.progress",
@@ -29681,8 +29967,8 @@ var WsChannels = {
29681
29967
  PHYSIC_DELETE: "physic.delete",
29682
29968
  // command lifecycle
29683
29969
  COMMAND_ANNOUNCE: "command.announce",
29684
- AI_SUGGEST: "ai.suggest",
29685
- AI_REVIEW: "ai.review",
29970
+ // AI spec-assist (suggest/review/compose) moved off dedicated ai.* channels to
29971
+ // command.dispatch({ ai:true }) → the profile-failover AI path (ADR-0100).
29686
29972
  // quota & usage (ADR-0020)
29687
29973
  QUOTA_CHECK: "quota.check",
29688
29974
  USAGE_REPORT: "usage.report",
@@ -29690,8 +29976,18 @@ var WsChannels = {
29690
29976
  MACHINE_USAGE: "machine.usage",
29691
29977
  // cli → server: durable per-command history record on finish (ADR-0072)
29692
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",
29693
29982
  // server → cli (reply): tail the cli's own JSONL logs on demand (ADR-0072)
29694
- LOG_READ: "log.read"
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",
29987
+ // outbound review (ADR-0082): machine→server request, server→outbound evaluate, result back
29988
+ REVIEW_REQUEST: "review.request",
29989
+ REVIEW_EVALUATE: "review.evaluate",
29990
+ REVIEW_RESULT: "review.result"
29695
29991
  };
29696
29992
 
29697
29993
  // ../../22-packages/21-protocol/21-websocket/src/crypto.ts
@@ -29816,7 +30112,7 @@ import { spawn } from "child_process";
29816
30112
 
29817
30113
  // src/common/logger/logger.ts
29818
30114
  import { appendFile, mkdir, readdir, unlink } from "fs/promises";
29819
- import { readdirSync as readdirSync2, readFileSync as readFileSync6 } from "fs";
30115
+ import { readdirSync as readdirSync2, readFileSync as readFileSync6, statSync } from "fs";
29820
30116
  import { join as join6 } from "path";
29821
30117
  var LEVEL_ORDER = { error: 0, warn: 1, info: 2, debug: 3 };
29822
30118
  var DAY_MS = 24 * 60 * 60 * 1e3;
@@ -29835,6 +30131,13 @@ var CliLogger = class {
29835
30131
  this.retentionDays = opts.retentionDays ?? DEFAULT_RETENTION_DAYS;
29836
30132
  void this.cleanupOldLogs();
29837
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
+ }
29838
30141
  /** Log an error-level event. */
29839
30142
  error(event, fields) {
29840
30143
  this.write("error", event, fields);
@@ -29891,6 +30194,24 @@ function readRecentLogLines(dir2, limit) {
29891
30194
  return [];
29892
30195
  }
29893
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
+ }
29894
30215
 
29895
30216
  // src/core/update-scheduler.ts
29896
30217
  var TICK_MS = 6e4;
@@ -30026,7 +30347,7 @@ async function runCommand(dispatch, emit) {
30026
30347
  });
30027
30348
  child.stdout?.on("data", (data) => batcher.push(data.toString("utf8")));
30028
30349
  child.stderr?.on("data", (data) => batcher.push(data.toString("utf8")));
30029
- await new Promise((resolve5) => {
30350
+ await new Promise((resolve6) => {
30030
30351
  child.on("close", (exitCode) => {
30031
30352
  batcher.flush();
30032
30353
  emit({
@@ -30036,7 +30357,7 @@ async function runCommand(dispatch, emit) {
30036
30357
  done: true,
30037
30358
  exitCode: exitCode ?? -1
30038
30359
  });
30039
- resolve5();
30360
+ resolve6();
30040
30361
  });
30041
30362
  child.on("error", (err) => {
30042
30363
  emit({
@@ -30047,7 +30368,7 @@ async function runCommand(dispatch, emit) {
30047
30368
  done: true,
30048
30369
  exitCode: -1
30049
30370
  });
30050
- resolve5();
30371
+ resolve6();
30051
30372
  });
30052
30373
  });
30053
30374
  }
@@ -30127,10 +30448,10 @@ function isSessionLimit(text) {
30127
30448
  );
30128
30449
  }
30129
30450
  function runAttempt(commandId, cmd, args, cwd2, env3, onChunk) {
30130
- return new Promise((resolve5) => {
30451
+ return new Promise((resolve6) => {
30131
30452
  void runCommand({ commandId, cmd, args, path: cwd2, env: env3 }, (out) => {
30132
30453
  if (out.chunk) onChunk(out.chunk);
30133
- if (out.done) resolve5(out.exitCode ?? -1);
30454
+ if (out.done) resolve6(out.exitCode ?? -1);
30134
30455
  });
30135
30456
  });
30136
30457
  }
@@ -30149,7 +30470,7 @@ async function runAiFailover(plan, commandId, cwd2, handlers) {
30149
30470
  handlers.onChunk(text);
30150
30471
  }
30151
30472
  };
30152
- 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);
30153
30474
  const tail = parser.flush();
30154
30475
  if (tail) {
30155
30476
  captured += tail;
@@ -30209,13 +30530,18 @@ function resolveHomePath(value) {
30209
30530
  if (isAbsolute(value)) return value;
30210
30531
  return join8(homedir3(), value);
30211
30532
  }
30212
- function resolveDirs(value) {
30213
- if (!value) return [];
30214
- const list = Array.isArray(value) ? value : [value];
30215
- 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];
30216
30542
  }
30217
- function claudeConfigDirs(config) {
30218
- const dirs = resolveDirs(config.claudeConfigDir);
30543
+ function claudeHomeDirs(config) {
30544
+ const dirs = profileDirs(config.claudeHome);
30219
30545
  return dirs.length > 0 ? dirs : [join8(homedir3(), ".claude")];
30220
30546
  }
30221
30547
  function profileDisplayLabel(dir2) {
@@ -30232,28 +30558,40 @@ function profileEnvVar(cmd) {
30232
30558
  if (cmd.includes("codex")) return "CODEX_HOME";
30233
30559
  return null;
30234
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
+ }
30235
30567
  function profileLabels(config) {
30236
30568
  const cmd = config.aiCli ?? DEFAULT_AI_CLI;
30237
- const envVar = profileEnvVar(cmd);
30238
- const dirs = envVar === "CLAUDE_CONFIG_DIR" ? resolveDirs(config.claudeConfigDir) : envVar === "CODEX_HOME" ? resolveDirs(config.codexHome) : [];
30239
- return dirs.map((dir2) => profileDisplayLabel(dir2));
30569
+ return profileDirs(profilesFor(config, cmd)).map((dir2) => profileDisplayLabel(dir2));
30240
30570
  }
30241
30571
  function planAiRun(prompt, config, workingDir) {
30242
30572
  const cmd = config.aiCli ?? DEFAULT_AI_CLI;
30243
- const args = [...config.aiArgs ?? DEFAULT_AI_ARGS[cmd] ?? [], prompt];
30244
30573
  const baseEnv = config.aiEnv ?? {};
30245
30574
  const envVar = profileEnvVar(cmd);
30246
- const dirs = envVar ? envVar === "CLAUDE_CONFIG_DIR" ? resolveDirs(config.claudeConfigDir) : resolveDirs(config.codexHome) : [];
30247
- if (!envVar || dirs.length === 0) {
30248
- return { cmd, args, attempts: [{ label: "default", dir: null, env: { ...baseEnv } }] };
30249
- }
30250
- const ordered = workingDir && dirs.includes(workingDir) ? [workingDir, ...dirs.filter((d) => d !== workingDir)] : dirs;
30251
- 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 }) => ({
30252
30589
  label: profileDisplayLabel(dir2),
30253
30590
  dir: dir2,
30591
+ args: buildRunArgs(profile, cmd, prompt),
30254
30592
  env: { ...baseEnv, [envVar]: dir2 }
30255
30593
  }));
30256
- return { cmd, args, attempts };
30594
+ return { cmd, attempts };
30257
30595
  }
30258
30596
 
30259
30597
  // src/core/claude-usage.ts
@@ -30291,16 +30629,16 @@ async function fetchUsage(token) {
30291
30629
  }
30292
30630
  }
30293
30631
  function runUsageCli(dir2, aiCli2) {
30294
- return new Promise((resolve5) => {
30632
+ return new Promise((resolve6) => {
30295
30633
  let out = "";
30296
30634
  let child = null;
30297
30635
  const finish = () => {
30298
30636
  clearTimeout(timer);
30299
- resolve5(out);
30637
+ resolve6(out);
30300
30638
  };
30301
30639
  const timer = setTimeout(() => {
30302
30640
  child?.kill();
30303
- resolve5(out);
30641
+ resolve6(out);
30304
30642
  }, 3e4);
30305
30643
  timer.unref?.();
30306
30644
  try {
@@ -30423,26 +30761,95 @@ async function runAi(prompt) {
30423
30761
  });
30424
30762
  return parseAi(cli, stdout, prompt);
30425
30763
  }
30426
- async function aiSuggest(req) {
30764
+ async function aiGenerate(prompt) {
30765
+ const { text } = await runAi(prompt);
30766
+ return text;
30767
+ }
30768
+ async function aiOutboundReview(input, allowedRepos, projectPath) {
30769
+ const prompt = `You are a security reviewer for an autonomous coding agent. Decide if the following INPUT is SAFE to run. It is UNSAFE if it: (1) asks the AI using secret VALUES (api keys, env values, passwords); (2) would affect other environments (only the sandbox project is allowed, no ssh/deploy/prod); (3) edits outside the project folder "${projectPath}"; (4) commits/pushes to a repo not in this allowlist: ${allowedRepos.length ? allowedRepos.join(", ") : "(none declared)"}. Reply with ONLY JSON: {"ok": <true|false>, "reasons": ["secret"|"environment"|"path"|"repo", ...]}. INPUT:
30770
+ ${input}`;
30771
+ const { text, tokens } = await runAi(prompt);
30772
+ const start = text.indexOf("{");
30773
+ const end = text.lastIndexOf("}");
30774
+ if (start < 0 || end <= start) return { ok: false, reasons: ["ai_parse"], tokens };
30427
30775
  try {
30428
- const prompt = `You are helping fill a software project specification. Suggest a concise value for the field "${req.field}". Reply with ONLY the suggested text, no preamble. Context (spec so far, JSON):
30429
- ${JSON.stringify(req.spec)}`;
30430
- const { text, tokens } = await runAi(prompt);
30431
- return { value: text, tokens };
30432
- } catch (err) {
30433
- return { value: "", error: err instanceof Error ? err.message : String(err) };
30776
+ const j = JSON.parse(text.slice(start, end + 1));
30777
+ const reasons = Array.isArray(j.reasons) ? j.reasons.filter((r) => typeof r === "string") : [];
30778
+ return { ok: j.ok === true && reasons.length === 0, reasons, tokens };
30779
+ } catch {
30780
+ return { ok: false, reasons: ["ai_parse"], tokens };
30434
30781
  }
30435
30782
  }
30436
- async function aiReview(req) {
30437
- try {
30438
- const prompt = `Review this software project specification for inconsistencies, gaps or contradictions. Reply with a short list, one issue per line, no preamble. If it looks consistent, reply exactly "OK". Spec (JSON):
30439
- ${JSON.stringify(req.spec)}`;
30440
- const { text: out, tokens } = await runAi(prompt);
30441
- const issues = out === "OK" ? [] : out.split("\n").map((line) => line.replace(/^[-*]\s*/, "").trim()).filter(Boolean);
30442
- return { issues, tokens };
30443
- } catch (err) {
30444
- return { issues: [], error: err instanceof Error ? err.message : String(err) };
30783
+
30784
+ // src/core/outbound-review.ts
30785
+ function compilePatterns(sources, fallback) {
30786
+ const list = sources ?? fallback;
30787
+ const out = [];
30788
+ for (const src of list) {
30789
+ try {
30790
+ out.push(compileRulePattern(src));
30791
+ } catch (err) {
30792
+ logger.warn("outbound-review.bad-pattern", {
30793
+ pattern: src,
30794
+ error: err instanceof Error ? err.message : String(err)
30795
+ });
30796
+ }
30797
+ }
30798
+ return out;
30799
+ }
30800
+ function referencedRepos(text) {
30801
+ const repos = /* @__PURE__ */ new Set();
30802
+ const re = /(?:https?:\/\/|git@)([\w.-]+)[/:]([\w.-]+\/[\w.-]+?)(?:\.git)?(?=[\s"')]|$)/gi;
30803
+ let m;
30804
+ while (m = re.exec(text)) repos.add(`${m[1]}/${m[2]}`.toLowerCase());
30805
+ return [...repos];
30806
+ }
30807
+ function ruleReview(input, allowedRepos, projectPath, secretPatterns, envPatterns) {
30808
+ const reasons = /* @__PURE__ */ new Set();
30809
+ const secretRe = compilePatterns(secretPatterns, DEFAULT_OUTBOUND_RULES.secret);
30810
+ const envRe = compilePatterns(envPatterns, DEFAULT_OUTBOUND_RULES.environment);
30811
+ if (secretRe.some((re) => re.test(input))) reasons.add("secret");
30812
+ if (envRe.some((re) => re.test(input))) reasons.add("environment");
30813
+ if (/(?:^|[\s"'(])(?:\.\.[/\\])+/.test(input)) reasons.add("path");
30814
+ const absSystem = /(?:^|[\s"'(=])(\/(?:etc|root|var|usr|bin|home|private|proc|sys)\b|[A-Za-z]:\\\\)/;
30815
+ if (absSystem.test(input) && projectPath && !input.includes(projectPath)) reasons.add("path");
30816
+ if (allowedRepos.length) {
30817
+ const allow = allowedRepos.map((r) => r.toLowerCase());
30818
+ const refs = referencedRepos(input);
30819
+ if (refs.some((r) => !allow.some((a) => a.includes(r) || r.includes(a)))) reasons.add("repo");
30820
+ }
30821
+ return [...reasons];
30822
+ }
30823
+ async function evaluateReview(payload) {
30824
+ const reasons = /* @__PURE__ */ new Set();
30825
+ let tokens = 0;
30826
+ if (payload.ruleCheck) {
30827
+ const violations = ruleReview(
30828
+ payload.prompt,
30829
+ payload.allowedRepos,
30830
+ payload.projectPath ?? "",
30831
+ payload.secretPatterns,
30832
+ payload.envPatterns
30833
+ );
30834
+ for (const r of violations) reasons.add(r);
30835
+ }
30836
+ if (payload.aiReview) {
30837
+ try {
30838
+ const ai = await aiOutboundReview(
30839
+ payload.prompt,
30840
+ payload.allowedRepos,
30841
+ payload.projectPath ?? ""
30842
+ );
30843
+ tokens = ai.tokens;
30844
+ if (!ai.ok) for (const r of ai.reasons) reasons.add(r);
30845
+ } catch (err) {
30846
+ logger.warn("outbound-review.ai-error", {
30847
+ commandId: payload.commandId,
30848
+ error: err instanceof Error ? err.message : String(err)
30849
+ });
30850
+ }
30445
30851
  }
30852
+ return { commandId: payload.commandId, ok: reasons.size === 0, reasons: [...reasons], tokens };
30446
30853
  }
30447
30854
 
30448
30855
  // src/core/command-history.ts
@@ -30539,6 +30946,13 @@ function recordCommand(entry) {
30539
30946
  function getCommandHistory() {
30540
30947
  return entries;
30541
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
+ }
30542
30956
  function finishCommand(commandId, exitCode, usage) {
30543
30957
  const entry = entries.find((e) => e.commandId === commandId && e.status === "running");
30544
30958
  if (!entry) return;
@@ -30566,7 +30980,7 @@ import {
30566
30980
  readFileSync as readFileSync11,
30567
30981
  readdirSync as readdirSync3,
30568
30982
  rmSync as rmSync3,
30569
- statSync
30983
+ statSync as statSync2
30570
30984
  } from "fs";
30571
30985
  import { join as join11 } from "path";
30572
30986
  var KEEP_FILES = 200;
@@ -30599,11 +31013,22 @@ function readCommandOutput(commandId) {
30599
31013
  function prune() {
30600
31014
  if (!dir) return;
30601
31015
  try {
30602
- 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);
30603
31017
  for (const { f } of files.slice(KEEP_FILES)) rmSync3(join11(dir, f), { force: true });
30604
31018
  } catch {
30605
31019
  }
30606
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
+ }
30607
31032
 
30608
31033
  // src/core/fs-browse.ts
30609
31034
  import { readdir as readdir2 } from "fs/promises";
@@ -30703,13 +31128,142 @@ ${stderr}`;
30703
31128
  return { installed, authenticated, account, claudeCli };
30704
31129
  }
30705
31130
 
30706
- // src/core/scaffold.ts
30707
- import { cp, mkdir as mkdir2, writeFile } from "fs/promises";
31131
+ // src/core/git-history.ts
31132
+ import { readdir as readdir3 } from "fs/promises";
30708
31133
  import { execFile as execFile4 } from "child_process";
30709
- import { dirname as dirname4, join as join12, resolve as resolve4 } from "path";
30710
- import { fileURLToPath as fileURLToPath3 } from "url";
31134
+ import { resolve as resolve4, sep as sep3 } from "path";
30711
31135
  import { promisify as promisify4 } from "util";
30712
31136
  var run4 = promisify4(execFile4);
31137
+ var FS = "";
31138
+ var RS = "";
31139
+ function resolveRepo(root, repo) {
31140
+ if (!root) return null;
31141
+ const base = resolve4(root);
31142
+ const requested = resolve4(base, repo && repo.trim() ? repo : ".");
31143
+ return requested === base || requested.startsWith(base + sep3) ? requested : base;
31144
+ }
31145
+ async function git(cwd2, args) {
31146
+ try {
31147
+ const { stdout } = await run4("git", args, {
31148
+ cwd: cwd2,
31149
+ timeout: 15e3,
31150
+ maxBuffer: 8 * 1024 * 1024
31151
+ });
31152
+ return stdout;
31153
+ } catch {
31154
+ return "";
31155
+ }
31156
+ }
31157
+ async function gitRepos(root) {
31158
+ if (!root) return { repos: [] };
31159
+ const base = resolve4(root);
31160
+ const found = /* @__PURE__ */ new Set();
31161
+ const repos = [];
31162
+ const add = async (dir2) => {
31163
+ const inside = await git(dir2, ["rev-parse", "--show-toplevel"]);
31164
+ const top = inside.trim();
31165
+ if (!top || found.has(top) || !(top === base || top.startsWith(base + sep3))) return;
31166
+ found.add(top);
31167
+ const subdir = top === base ? "" : top.slice(base.length + 1);
31168
+ const remote = (await git(top, ["remote", "get-url", "origin"])).trim();
31169
+ repos.push({ subdir, name: subdir || "", remote: remote || null });
31170
+ };
31171
+ await add(base);
31172
+ try {
31173
+ const lvl1 = await readdir3(base, { withFileTypes: true });
31174
+ for (const d of lvl1) {
31175
+ if (!d.isDirectory() || d.name === ".git" || d.name === "node_modules") continue;
31176
+ const p1 = resolve4(base, d.name);
31177
+ await add(p1);
31178
+ try {
31179
+ const lvl2 = await readdir3(p1, { withFileTypes: true });
31180
+ for (const d2 of lvl2) {
31181
+ if (!d2.isDirectory() || d2.name === ".git" || d2.name === "node_modules") continue;
31182
+ await add(resolve4(p1, d2.name));
31183
+ }
31184
+ } catch {
31185
+ }
31186
+ }
31187
+ } catch {
31188
+ }
31189
+ repos.sort((a, b) => a.subdir.localeCompare(b.subdir));
31190
+ return { repos };
31191
+ }
31192
+ async function gitLog(root, repo, skip, limit) {
31193
+ const cwd2 = resolveRepo(root, repo);
31194
+ if (!cwd2) return { entries: [], hasMore: false };
31195
+ const capped = Math.min(Math.max(limit, 1), 200);
31196
+ const format = ["%H", "%h", "%an", "%aI", "%s"].join(FS) + RS;
31197
+ const stdout = await git(cwd2, [
31198
+ "log",
31199
+ `--skip=${Math.max(skip, 0)}`,
31200
+ `-n`,
31201
+ String(capped + 1),
31202
+ `--pretty=format:${format}`
31203
+ ]);
31204
+ const rows = stdout.split(RS).map((r) => r.replace(/^\n/, "")).filter((r) => r.length > 0);
31205
+ const entries2 = rows.slice(0, capped).map((row) => {
31206
+ const [hash = "", shortHash = "", author = "", date = "", subject = ""] = row.split(FS);
31207
+ return { hash, shortHash, author, date, subject };
31208
+ });
31209
+ return { entries: entries2, hasMore: rows.length > capped };
31210
+ }
31211
+ async function gitCommit(root, repo, hash) {
31212
+ const cwd2 = resolveRepo(root, repo);
31213
+ const empty = { hash, author: "", date: "", subject: "", files: [] };
31214
+ if (!cwd2 || !hash) return empty;
31215
+ const format = ["%H", "%an", "%aI", "%s"].join(FS);
31216
+ const stdout = await git(cwd2, [
31217
+ "show",
31218
+ "--name-status",
31219
+ "-M",
31220
+ `--pretty=format:${format}`,
31221
+ hash
31222
+ ]);
31223
+ if (!stdout) return empty;
31224
+ const lines = stdout.split("\n");
31225
+ const [full = hash, author = "", date = "", subject = ""] = (lines[0] ?? "").split(FS);
31226
+ const files = [];
31227
+ for (const line of lines.slice(1)) {
31228
+ if (!line.trim()) continue;
31229
+ const parts = line.split(" ");
31230
+ const status = (parts[0] ?? "").charAt(0);
31231
+ if (!status) continue;
31232
+ if (status === "R" || status === "C") {
31233
+ files.push({ path: parts[2] ?? "", status, oldPath: parts[1] ?? void 0 });
31234
+ } else {
31235
+ files.push({ path: parts[1] ?? "", status });
31236
+ }
31237
+ }
31238
+ return { hash: full, author, date, subject, files };
31239
+ }
31240
+ async function gitCommitDiff(root, repo, hash, path) {
31241
+ const cwd2 = resolveRepo(root, repo);
31242
+ if (!cwd2 || !hash || !path) return { path, oldContent: "", newContent: "" };
31243
+ const oldContent = await git(cwd2, ["show", `${hash}^:${path}`]);
31244
+ const newContent = await git(cwd2, ["show", `${hash}:${path}`]);
31245
+ return { path, oldContent, newContent };
31246
+ }
31247
+
31248
+ // src/core/git-commit-identity.ts
31249
+ import { execFile as execFile5 } from "child_process";
31250
+ import { promisify as promisify5 } from "util";
31251
+ var run5 = promisify5(execFile5);
31252
+ async function setCommitAuthor(cwd2, machineUsername) {
31253
+ if (!machineUsername) return;
31254
+ try {
31255
+ await run5("git", ["config", "--local", "user.name", machineUsername], { cwd: cwd2, timeout: 8e3 });
31256
+ } catch {
31257
+ }
31258
+ }
31259
+
31260
+ // src/core/scaffold.ts
31261
+ import { cp, mkdir as mkdir2, writeFile } from "fs/promises";
31262
+ import { execFile as execFile6 } from "child_process";
31263
+ import { dirname as dirname4, join as join12, resolve as resolve5 } from "path";
31264
+ import { fileURLToPath as fileURLToPath3 } from "url";
31265
+ import { promisify as promisify6 } from "util";
31266
+ var run6 = promisify6(execFile6);
30713
31267
  function reposOf(spec) {
30714
31268
  const repos = spec?.repos;
30715
31269
  return Array.isArray(repos) ? repos : [];
@@ -30718,13 +31272,18 @@ function subDirName(repo) {
30718
31272
  const raw = (repo.subdir || repo.role || repo.name || "repo").trim();
30719
31273
  return raw.replace(/[^\w.-]+/g, "-").replace(/^-+|-+$/g, "") || "repo";
30720
31274
  }
30721
- async function provisionRepos(target, repos, emit) {
31275
+ async function provisionRepos(target, repos, emit, opts = {}) {
30722
31276
  for (const repo of repos) {
30723
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
+ }
30724
31283
  emit("git", "Initializing the primary repo\u2026");
30725
- await run4("git", ["init"], { cwd: target, timeout: 2e4 });
31284
+ await run6("git", ["init"], { cwd: target, timeout: 2e4 });
30726
31285
  if (repo.op === "existing" && repo.url) {
30727
- await run4("git", ["remote", "add", "origin", repo.url], { cwd: target, timeout: 2e4 }).catch(
31286
+ await run6("git", ["remote", "add", "origin", repo.url], { cwd: target, timeout: 2e4 }).catch(
30728
31287
  () => void 0
30729
31288
  );
30730
31289
  }
@@ -30733,23 +31292,24 @@ async function provisionRepos(target, repos, emit) {
30733
31292
  const dir2 = join12(target, subDirName(repo));
30734
31293
  if (repo.op === "existing" && repo.url) {
30735
31294
  emit("git", `Cloning sub-repo ${repo.url}\u2026`);
30736
- await run4("git", ["clone", repo.url, dir2], { timeout: 12e4 });
31295
+ await run6("git", ["clone", repo.url, dir2], { timeout: 12e4 });
30737
31296
  } else {
30738
31297
  emit("git", `Initializing sub-repo ${subDirName(repo)}\u2026`);
30739
31298
  await mkdir2(dir2, { recursive: true });
30740
- await run4("git", ["init"], { cwd: dir2, timeout: 2e4 });
31299
+ await run6("git", ["init"], { cwd: dir2, timeout: 2e4 });
30741
31300
  }
30742
31301
  }
30743
31302
  }
30744
31303
  function sampleDir() {
30745
31304
  if (process.env.SCAFFOLD_SAMPLE_DIR) return process.env.SCAFFOLD_SAMPLE_DIR;
30746
31305
  const here = dirname4(fileURLToPath3(import.meta.url));
30747
- return resolve4(here, "../../project-sample");
31306
+ return resolve5(here, "../../project-sample");
30748
31307
  }
30749
- async function scaffoldProject(payload, onProgress) {
31308
+ async function scaffoldProject(payload, profileDir2, onProgress) {
30750
31309
  const emit = (step, message) => onProgress?.({ projectId: payload.projectId, step, message });
30751
31310
  try {
30752
- const target = resolve4(payload.path);
31311
+ const root = process.env.SCAFFOLD_ROOT ? resolve5(process.env.SCAFFOLD_ROOT) : resolve5(profileDir2);
31312
+ const target = join12(root, payload.projectName);
30753
31313
  await mkdir2(target, { recursive: true });
30754
31314
  emit("copy", "Copying the sample template\u2026");
30755
31315
  await cp(sampleDir(), target, { recursive: true, force: false, errorOnExist: false });
@@ -30763,25 +31323,58 @@ async function scaffoldProject(payload, onProgress) {
30763
31323
  }
30764
31324
  const repos = reposOf(payload.spec);
30765
31325
  if (repos.length > 0) await provisionRepos(target, repos, emit);
31326
+ if (payload.spec) await aiInit(target, payload.spec, emit);
30766
31327
  onProgress?.({ projectId: payload.projectId, step: "done", message: "Scaffold complete.", done: true });
30767
31328
  return { ok: true, path: target };
30768
31329
  } catch (err) {
30769
31330
  return { ok: false, error: err instanceof Error ? err.message : String(err) };
30770
31331
  }
30771
31332
  }
30772
- async function addProject(payload, onProgress) {
31333
+ async function aiInit(target, spec, emit) {
31334
+ emit("ai-init", "AI init: subagents, README, CLAUDE.md\u2026");
31335
+ const subagents = Array.isArray(spec.subagents) ? spec.subagents : [];
31336
+ if (subagents.length > 0) {
31337
+ await mkdir2(join12(target, ".claude", "agents"), { recursive: true });
31338
+ for (const sa of subagents) {
31339
+ const name = (sa.name || "").trim().replace(/[^\w.-]+/g, "-").replace(/^-+|-+$/g, "");
31340
+ if (!name) continue;
31341
+ const body = `---
31342
+ name: ${name}
31343
+ ---
31344
+
31345
+ ${(sa.description || "").trim()}
31346
+ `;
31347
+ await writeFile(join12(target, ".claude", "agents", `${name}.md`), body, "utf8");
31348
+ }
31349
+ }
31350
+ const specJson = JSON.stringify(spec);
31351
+ await generateFile(
31352
+ join12(target, "README.md"),
31353
+ `Write a concise README.md (Markdown only, no preamble) for this project from its spec JSON:
31354
+ ${specJson}`
31355
+ );
31356
+ await generateFile(
31357
+ join12(target, "CLAUDE.md"),
31358
+ `Write a CLAUDE.md (Markdown only, no preamble) with guidance/conventions for AI agents working in this project, derived from its spec JSON:
31359
+ ${specJson}`
31360
+ );
31361
+ }
31362
+ async function generateFile(path, prompt) {
31363
+ try {
31364
+ const text = await aiGenerate(prompt);
31365
+ if (text.trim()) await writeFile(path, text.trim() + "\n", "utf8");
31366
+ } catch {
31367
+ }
31368
+ }
31369
+ async function addProject(payload, profileDir2, onProgress) {
30773
31370
  const emit = (step, message) => onProgress?.({ projectId: payload.projectId, step, message });
30774
31371
  try {
30775
- const target = resolve4(payload.path);
31372
+ const root = process.env.SCAFFOLD_ROOT ? resolve5(process.env.SCAFFOLD_ROOT) : resolve5(profileDir2);
31373
+ const target = join12(root, payload.projectName);
30776
31374
  await mkdir2(target, { recursive: true });
30777
- if (payload.repoUrl) {
30778
- emit("clone", `Cloning ${payload.repoUrl}\u2026`);
30779
- await run4("git", ["clone", payload.repoUrl, target], { timeout: 12e4 });
30780
- } else {
30781
- emit("init", "git init\u2026");
30782
- await run4("git", ["init"], { cwd: target, timeout: 2e4 });
30783
- }
30784
- 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 });
30785
31378
  return { ok: true, path: target };
30786
31379
  } catch (err) {
30787
31380
  return { ok: false, error: err instanceof Error ? err.message : String(err) };
@@ -30791,9 +31384,10 @@ async function addProject(payload, onProgress) {
30791
31384
  // src/core/ws-client.ts
30792
31385
  var HEARTBEAT_MS = 3e4;
30793
31386
  var USAGE_POLL_MS = 5 * 6e4;
31387
+ var LOG_UPLOAD_MS = 10 * 6e4;
30794
31388
  var RECONNECT_BACKOFF_BASE_MS = 1e3;
30795
- var RECONNECT_MAX_BACKOFF_DEFAULT_SEC = 600;
30796
- var RECONNECT_BACKOFF_MAX_ATTEMPT = 10;
31389
+ var RECONNECT_MAX_BACKOFF_DEFAULT_SEC = 60;
31390
+ var RECONNECT_BACKOFF_MAX_ATTEMPT = 6;
30797
31391
  function envReconnectMaxSec() {
30798
31392
  const raw = Number(process.env.FOURPM_RECONNECT_MAX_BACKOFF_SEC);
30799
31393
  if (!Number.isFinite(raw)) return null;
@@ -30818,11 +31412,20 @@ var WsClient = class {
30818
31412
  context;
30819
31413
  socket = null;
30820
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;
30821
31419
  ecdh = null;
30822
31420
  wsToken = "";
30823
31421
  heartbeat = null;
30824
31422
  usageTimer = null;
31423
+ logUploadTimer = null;
31424
+ /** Signature (fileName + totalBytes) of the last log upload — skip re-sending if unchanged. */
31425
+ lastLogSig = null;
30825
31426
  usageSnapshot = null;
31427
+ /** Whether the serving project requires outbound review before each AI spawn (ADR-0082). */
31428
+ outboundReviewEnabled = false;
30826
31429
  stopped = false;
30827
31430
  /** Resolver to wake the backoff sleep early (set while waiting) — for /reconnect. */
30828
31431
  wakeReconnect = null;
@@ -30836,11 +31439,20 @@ var WsClient = class {
30836
31439
  /** The physic project folder root this cli serves (null = no project) — fs.list is
30837
31440
  * scoped to it so the web FsPicker can only browse inward, never out. */
30838
31441
  physicRoot = null;
31442
+ /** This machine-user's username — the git commit author for this project (ADR-0097).
31443
+ * Push uses the account already logged in with gh/glab on the worker. */
31444
+ machineUsername = "";
30839
31445
  /** Waiters resolved when the session next becomes connected (readiness gate before
30840
31446
  * running a local prompt). */
30841
31447
  connectWaiters = [];
30842
31448
  /** Output buffered while disconnected (flushed on reconnect). */
30843
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();
30844
31456
  offlineBufferBytes = 0;
30845
31457
  /** cli → server requests awaiting a reply (quota.check — ADR-0020). */
30846
31458
  pending = /* @__PURE__ */ new Map();
@@ -30850,6 +31462,11 @@ var WsClient = class {
30850
31462
  get bus() {
30851
31463
  return this.context.bus;
30852
31464
  }
31465
+ /** True once the session stopped for good (logout / replaced / version) — the
31466
+ * resilient run wrapper uses this to tell a clean stop from a crash to recover from. */
31467
+ get isStopped() {
31468
+ return this.stopped;
31469
+ }
30853
31470
  /**
30854
31471
  * Force an immediate reconnect: wake the backoff sleep if waiting, else drop the live
30855
31472
  * socket so the run() loop reconnects (the next backoff is skipped). No-op if stopped.
@@ -30870,43 +31487,50 @@ var WsClient = class {
30870
31487
  */
30871
31488
  awaitConnected(timeoutMs) {
30872
31489
  if (this.sessionKey && this.bus.status === "connected") return Promise.resolve(true);
30873
- return new Promise((resolve5) => {
31490
+ return new Promise((resolve6) => {
30874
31491
  const waiter = () => {
30875
31492
  clearTimeout(timer);
30876
- resolve5(true);
31493
+ resolve6(true);
30877
31494
  };
30878
31495
  const timer = setTimeout(() => {
30879
31496
  this.connectWaiters = this.connectWaiters.filter((w) => w !== waiter);
30880
- resolve5(false);
31497
+ resolve6(false);
30881
31498
  }, timeoutMs);
30882
31499
  this.connectWaiters.push(waiter);
30883
31500
  });
30884
31501
  }
30885
31502
  /**
30886
31503
  * Resolve the reconnect-backoff cap (ms): the org value (from ws_token) over the
30887
- * 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.
30888
31505
  */
30889
31506
  reconnectMaxMs() {
30890
31507
  const sec = this.orgReconnectMaxSec ?? this.envReconnectMaxSec ?? RECONNECT_MAX_BACKOFF_DEFAULT_SEC;
30891
31508
  return sec * 1e3;
30892
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
+ }
30893
31518
  /** Backoff sleep that can be woken early by reconnectNow(). */
30894
31519
  interruptibleSleep(ms) {
30895
31520
  if (this.forceReconnect) {
30896
31521
  this.forceReconnect = false;
30897
31522
  return Promise.resolve();
30898
31523
  }
30899
- return new Promise((resolve5) => {
31524
+ return new Promise((resolve6) => {
30900
31525
  const timer = setTimeout(() => {
30901
31526
  this.wakeReconnect = null;
30902
- resolve5();
31527
+ resolve6();
30903
31528
  }, ms);
30904
- timer.unref?.();
30905
31529
  this.wakeReconnect = () => {
30906
31530
  clearTimeout(timer);
30907
31531
  this.wakeReconnect = null;
30908
31532
  this.forceReconnect = false;
30909
- resolve5();
31533
+ resolve6();
30910
31534
  };
30911
31535
  });
30912
31536
  }
@@ -30918,8 +31542,10 @@ var WsClient = class {
30918
31542
  for (let attempt = 0; !this.stopped; attempt++) {
30919
31543
  try {
30920
31544
  await this.connectOnce();
30921
- attempt = 0;
30922
- hashcodeInvalidStreak = 0;
31545
+ if (this.sessionEstablished) {
31546
+ attempt = 0;
31547
+ hashcodeInvalidStreak = 0;
31548
+ }
30923
31549
  } catch (err) {
30924
31550
  if (err instanceof CliApiError) {
30925
31551
  if (["HASHCODE_EXPIRED", "LINK_REVOKED"].includes(err.errorCode)) {
@@ -30935,6 +31561,8 @@ var WsClient = class {
30935
31561
  `Token request rejected (HASHCODE_INVALID) \u2014 retrying (${hashcodeInvalidStreak}/${MAX_HASHCODE_INVALID_RETRIES}).`,
30936
31562
  "warn"
30937
31563
  );
31564
+ } else if (err.errorCode === "USER_PAUSED") {
31565
+ this.bus.log("This account is paused \u2014 retrying until it is resumed.", "warn");
30938
31566
  } else {
30939
31567
  this.bus.log(`Connection error: ${err}`, "error");
30940
31568
  }
@@ -30958,8 +31586,9 @@ var WsClient = class {
30958
31586
  * One WS session: token → connect → handshake → handle messages until close.
30959
31587
  */
30960
31588
  connectOnce() {
30961
- return new Promise((resolve5, reject) => {
31589
+ return new Promise((resolve6, reject) => {
30962
31590
  void (async () => {
31591
+ this.sessionEstablished = false;
30963
31592
  this.bus.setStatus("connecting");
30964
31593
  const token = await requestWsToken(this.credential.serverUrl, this.credential.hashcode3);
30965
31594
  this.wsToken = token.wsToken;
@@ -30973,6 +31602,17 @@ var WsClient = class {
30973
31602
  timezone: token.timezone
30974
31603
  });
30975
31604
  }
31605
+ if (typeof token.cliRetentionDays === "number" && token.cliRetentionDays > 0) {
31606
+ logger.setRetentionDays(token.cliRetentionDays);
31607
+ pruneCommandHistoryByAge(token.cliRetentionDays);
31608
+ pruneCommandOutputByAge(token.cliRetentionDays);
31609
+ }
31610
+ writeProfileConfig(this.context.profileDir, {
31611
+ sessionSwitchPct: token.projectTokens?.sessionSwitchPct ?? 0,
31612
+ perPromptTokenLimit: token.projectTokens?.perPromptTokenLimit ?? 0
31613
+ });
31614
+ this.outboundReviewEnabled = token.outboundReview?.enabled === true;
31615
+ this.machineUsername = token.machineUsername ?? "";
30976
31616
  if (token.scope && token.scope !== this.credential.scope) {
30977
31617
  this.credential.scope = token.scope;
30978
31618
  writeCredential(this.context.profileDir, this.credential);
@@ -30980,7 +31620,12 @@ var WsClient = class {
30980
31620
  } else if (token.scope) {
30981
31621
  this.bus.setScope(token.scope);
30982
31622
  }
30983
- 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";
30984
31629
  const socket = new wrapper_default(wsUrl);
30985
31630
  this.socket = socket;
30986
31631
  socket.on("open", () => {
@@ -31010,7 +31655,7 @@ var WsClient = class {
31010
31655
  if (code === CLOSE_DRAINING) {
31011
31656
  this.bus.log("Server draining \u2014 will reconnect (backoff + jitter).");
31012
31657
  }
31013
- resolve5();
31658
+ resolve6();
31014
31659
  });
31015
31660
  socket.on("error", (err) => {
31016
31661
  this.clearHeartbeat();
@@ -31026,6 +31671,7 @@ var WsClient = class {
31026
31671
  if ("type" in message) {
31027
31672
  if (message.type === "hello_ack" && this.ecdh) {
31028
31673
  this.sessionKey = deriveSessionKey(this.ecdh, message.pubkey);
31674
+ this.sessionEstablished = true;
31029
31675
  if (message.workerName) this.bus.setWorker(message.workerName);
31030
31676
  if (message.projectName) {
31031
31677
  this.bus.setProject(message.projectName);
@@ -31042,6 +31688,7 @@ var WsClient = class {
31042
31688
  this.flushOfflineBuffer();
31043
31689
  this.startHeartbeat();
31044
31690
  this.startUsagePolling();
31691
+ this.startLogUpload();
31045
31692
  this.connectWaiters.splice(0).forEach((fn) => fn());
31046
31693
  return;
31047
31694
  }
@@ -31070,6 +31717,11 @@ var WsClient = class {
31070
31717
  this.socket?.close();
31071
31718
  return;
31072
31719
  }
31720
+ if (message.errorCode === "USER_PAUSED") {
31721
+ this.bus.log("This account is paused \u2014 retrying until it is resumed.", "warn");
31722
+ this.socket?.close();
31723
+ return;
31724
+ }
31073
31725
  this.bus.log(`Server reported an error: ${message.errorCode}`, "warn");
31074
31726
  this.socket?.close();
31075
31727
  return;
@@ -31113,6 +31765,11 @@ var WsClient = class {
31113
31765
  }
31114
31766
  case WsChannels.COMMAND_DISPATCH: {
31115
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);
31116
31773
  logger.info("command.dispatch", {
31117
31774
  commandId: dispatch.commandId,
31118
31775
  cmd: dispatch.cmd,
@@ -31174,32 +31831,62 @@ var WsClient = class {
31174
31831
  (reply) => this.send(WsChannels.GIT_ENV, reply, message.id)
31175
31832
  );
31176
31833
  break;
31834
+ case WsChannels.GIT_REPOS:
31835
+ void gitRepos(this.physicRoot).then(
31836
+ (reply) => this.send(WsChannels.GIT_REPOS, reply, message.id)
31837
+ );
31838
+ break;
31839
+ case WsChannels.GIT_LOG: {
31840
+ const req = payload;
31841
+ void gitLog(this.physicRoot, req.repo ?? "", req.skip ?? 0, req.limit ?? 50).then(
31842
+ (reply) => this.send(WsChannels.GIT_LOG, reply, message.id)
31843
+ );
31844
+ break;
31845
+ }
31846
+ case WsChannels.GIT_COMMIT: {
31847
+ const req = payload;
31848
+ void gitCommit(this.physicRoot, req.repo ?? "", req.hash).then(
31849
+ (reply) => this.send(WsChannels.GIT_COMMIT, reply, message.id)
31850
+ );
31851
+ break;
31852
+ }
31853
+ case WsChannels.GIT_COMMIT_DIFF: {
31854
+ const req = payload;
31855
+ void gitCommitDiff(this.physicRoot, req.repo ?? "", req.hash, req.path).then(
31856
+ (reply) => this.send(WsChannels.GIT_COMMIT_DIFF, reply, message.id)
31857
+ );
31858
+ break;
31859
+ }
31177
31860
  case WsChannels.LOG_READ: {
31178
31861
  const limit = Math.min(Math.max(payload.limit ?? 200, 1), 2e3);
31179
31862
  const lines = readRecentLogLines(join13(this.context.profileDir, "logs"), limit);
31180
31863
  this.send(WsChannels.LOG_READ, { lines }, message.id);
31181
31864
  break;
31182
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
+ }
31183
31871
  case WsChannels.PROJECT_CREATE:
31184
31872
  void scaffoldProject(
31185
31873
  payload,
31874
+ this.context.profileDir,
31186
31875
  (p) => this.send(WsChannels.PROJECT_PROGRESS, p)
31187
31876
  ).then((reply) => this.send(WsChannels.PROJECT_CREATE, reply, message.id));
31188
31877
  break;
31189
31878
  case WsChannels.PROJECT_ADD:
31190
31879
  void addProject(
31191
31880
  payload,
31881
+ this.context.profileDir,
31192
31882
  (p) => this.send(WsChannels.PROJECT_PROGRESS, p)
31193
31883
  ).then((reply) => this.send(WsChannels.PROJECT_ADD, reply, message.id));
31194
31884
  break;
31195
- case WsChannels.AI_SUGGEST:
31196
- void aiSuggest(payload).then(
31197
- (reply) => this.send(WsChannels.AI_SUGGEST, reply, message.id)
31198
- );
31199
- break;
31200
- case WsChannels.AI_REVIEW:
31201
- void aiReview(payload).then(
31202
- (reply) => this.send(WsChannels.AI_REVIEW, reply, message.id)
31885
+ // AI spec-assist (project-0012/0013/0033) now runs via command.dispatch({ ai:true })
31886
+ // → runAiPrompt (profile-failover), not dedicated ai.* channels (ADR-0100).
31887
+ case WsChannels.REVIEW_EVALUATE:
31888
+ void evaluateReview(payload).then(
31889
+ (reply) => this.send(WsChannels.REVIEW_EVALUATE, reply, message.id)
31203
31890
  );
31204
31891
  break;
31205
31892
  default:
@@ -31242,34 +31929,93 @@ var WsClient = class {
31242
31929
  */
31243
31930
  async runAiPrompt(prompt, commandId, origin) {
31244
31931
  const config = readProfileConfig(this.context.profileDir);
31932
+ const perPromptLimit = config.perPromptTokenLimit ?? 0;
31933
+ if (perPromptLimit > 0) {
31934
+ const estimated = estimateTokens(prompt);
31935
+ if (estimated > perPromptLimit) {
31936
+ this.rejectPromptOverLimit(commandId, origin, estimated, perPromptLimit);
31937
+ return;
31938
+ }
31939
+ }
31940
+ if (this.outboundReviewEnabled) {
31941
+ let verdict;
31942
+ try {
31943
+ verdict = await this.request(WsChannels.REVIEW_REQUEST, {
31944
+ commandId,
31945
+ prompt
31946
+ });
31947
+ } catch {
31948
+ verdict = { commandId, ok: false, reasons: ["unavailable"] };
31949
+ }
31950
+ if (!verdict.ok) {
31951
+ this.rejectByReview(commandId, origin, verdict.reasons);
31952
+ return;
31953
+ }
31954
+ }
31245
31955
  const cwd2 = this.physicRoot ?? config.physicPath ?? process.cwd();
31246
31956
  if (this.physicRoot) {
31247
31957
  this.ensurePhysicFolderPath(this.physicRoot);
31958
+ await setCommitAuthor(cwd2, this.machineUsername);
31248
31959
  } else {
31249
31960
  this.bus.log("No project attached \u2014 running in the current directory.", "warn");
31250
31961
  }
31251
31962
  const cmd = config.aiCli ?? "claude";
31252
31963
  const workingDir = getWorkingProfile(this.context.profileDir, cmd);
31253
- const plan = planAiRun(prompt, config, workingDir);
31254
- this.bus.push({ source: origin, kind: "cmd", text: `${plan.cmd} \u2039 ${prompt}` });
31964
+ const startDir = this.profileUnderSessionPressure(config, cmd, workingDir);
31965
+ if (startDir && startDir !== workingDir) {
31966
+ this.bus.push({
31967
+ source: origin,
31968
+ kind: "log",
31969
+ text: `session ${this.usageSnapshot?.session.utilizationPct ?? 0}% \u2265 ${config.sessionSwitchPct}% \u2014 switching to Claude profile "${profileDisplayLabel(startDir)}"`
31970
+ });
31971
+ }
31972
+ const plan = planAiRun(prompt, config, startDir ?? workingDir);
31973
+ const markerArgs = plan.attempts[0]?.args ?? [];
31974
+ this.bus.push({ source: origin, kind: "aireq", text: `${plan.cmd} \u2039 ${prompt}` });
31255
31975
  logger.info("command.ai", { commandId, origin, cmd: plan.cmd, profiles: plan.attempts.length });
31256
31976
  if (origin === "local") {
31257
31977
  this.send(WsChannels.COMMAND_ANNOUNCE, {
31258
31978
  commandId,
31259
31979
  cmd: plan.cmd,
31260
- args: plan.args,
31980
+ args: markerArgs,
31261
31981
  origin: "local"
31262
31982
  });
31263
31983
  }
31264
- recordCommand({ commandId, cmd: plan.cmd, args: plan.args });
31984
+ recordCommand({ commandId, cmd: plan.cmd, args: markerArgs });
31265
31985
  const startedAt = (/* @__PURE__ */ new Date()).toISOString();
31266
31986
  this.bus.startBusy(plan.cmd);
31267
31987
  let seq = 0;
31988
+ let responseHeaderShown = false;
31989
+ let displayMode = "pending";
31990
+ let resultId = null;
31991
+ let resultBuf = "";
31268
31992
  const result = await runAiFailover(plan, commandId, cwd2, {
31269
31993
  onChunk: (text) => {
31270
- this.bus.push({ source: origin, kind: "out", text });
31994
+ if (!responseHeaderShown) {
31995
+ this.bus.push({ source: origin, kind: "aires", text: `${plan.cmd} \u203A` });
31996
+ responseHeaderShown = true;
31997
+ }
31271
31998
  appendCommandOutput(commandId, text);
31272
31999
  this.send(WsChannels.COMMAND_OUTPUT, { commandId, seq: seq++, chunk: text });
32000
+ if (displayMode === "result") {
32001
+ resultBuf += text;
32002
+ if (resultId) this.bus.updateEntry(resultId, resultBuf);
32003
+ return;
32004
+ }
32005
+ if (displayMode === "prose") {
32006
+ this.bus.push({ source: origin, kind: "out", text });
32007
+ return;
32008
+ }
32009
+ resultBuf += text;
32010
+ if (resultBuf.replace(/^\s+/, "").length === 0) return;
32011
+ const kind = looksLikeJsonOrCode(resultBuf);
32012
+ if (kind) {
32013
+ displayMode = "result";
32014
+ resultId = this.bus.push({ source: origin, kind: "result", text: resultBuf, resultKind: kind });
32015
+ } else {
32016
+ displayMode = "prose";
32017
+ this.bus.push({ source: origin, kind: "out", text: resultBuf });
32018
+ }
31273
32019
  },
31274
32020
  onAttemptStart: (label, index, total) => this.bus.push({
31275
32021
  source: origin,
@@ -31300,7 +32046,7 @@ var WsClient = class {
31300
32046
  this.send(WsChannels.COMMAND_HISTORY, {
31301
32047
  commandId,
31302
32048
  cmd: plan.cmd,
31303
- args: plan.args,
32049
+ args: markerArgs,
31304
32050
  status: result.exitCode === 0 ? "done" : "failed",
31305
32051
  exitCode: result.exitCode,
31306
32052
  tokens: result.usage.tokens,
@@ -31324,6 +32070,79 @@ var WsClient = class {
31324
32070
  level: result.exitCode === 0 ? "info" : "error"
31325
32071
  });
31326
32072
  }
32073
+ /**
32074
+ * Pick the Claude profile to try first when under session pressure (ADR-0081). Returns
32075
+ * the next candidate dir (cyclically after the current working one) when the knob is set
32076
+ * and the live session utilization is at/over it AND there is more than one candidate;
32077
+ * otherwise null (keep the normal working-first ordering). Claude-only (session % is a
32078
+ * Claude subscription metric).
32079
+ */
32080
+ profileUnderSessionPressure(config, cmd, workingDir) {
32081
+ const threshold = config.sessionSwitchPct ?? 0;
32082
+ if (threshold <= 0 || cmd !== "claude") return null;
32083
+ const util2 = this.usageSnapshot?.session.utilizationPct ?? 0;
32084
+ if (util2 < threshold) return null;
32085
+ const dirs = claudeHomeDirs(config);
32086
+ if (dirs.length < 2) return null;
32087
+ const currentIndex = workingDir ? dirs.indexOf(workingDir) : -1;
32088
+ const next = dirs[(currentIndex + 1) % dirs.length];
32089
+ return next ?? null;
32090
+ }
32091
+ /**
32092
+ * Reject a prompt whose estimated tokens exceed the project's per-prompt limit (ADR-0081)
32093
+ * without spawning: surface it in the transcript + close the command on the server with a
32094
+ * PROMPT_TOKEN_LIMIT_EXCEEDED note (exit 1). No usage is metered (nothing ran).
32095
+ */
32096
+ rejectPromptOverLimit(commandId, origin, estimated, limit) {
32097
+ const message = `[PROMPT_TOKEN_LIMIT_EXCEEDED] Prompt (~${estimated} tokens) exceeds the project per-prompt limit of ${limit} tokens.`;
32098
+ logger.warn("command.ai.prompt-limit", { commandId, origin, estimated, limit });
32099
+ this.bus.push({ source: origin, kind: "log", text: message, level: "error" });
32100
+ if (origin === "local") {
32101
+ this.send(WsChannels.COMMAND_ANNOUNCE, {
32102
+ commandId,
32103
+ cmd: "claude",
32104
+ args: [],
32105
+ origin: "local"
32106
+ });
32107
+ }
32108
+ this.send(WsChannels.COMMAND_OUTPUT, { commandId, seq: 0, chunk: message });
32109
+ this.send(WsChannels.COMMAND_OUTPUT, { commandId, seq: 1, chunk: "", done: true, exitCode: 1 });
32110
+ this.bus.push({
32111
+ source: origin,
32112
+ kind: "exit",
32113
+ text: "\u2717 prompt rejected \u2014 over the project per-prompt token limit",
32114
+ level: "error"
32115
+ });
32116
+ }
32117
+ /**
32118
+ * Reject a prompt an outbound reviewer blocked (ADR-0082) without spawning: surface the
32119
+ * verdict + close the command on the server. `reasons` contains only violation categories
32120
+ * (never secret values); a "unavailable" reason maps to no-outbound-available.
32121
+ */
32122
+ rejectByReview(commandId, origin, reasons) {
32123
+ const unavailable = reasons.includes("unavailable");
32124
+ const code = unavailable ? "OUTBOUND_REVIEW_UNAVAILABLE" : "OUTBOUND_REVIEW_REJECTED";
32125
+ const detail = reasons.filter((r) => r !== "unavailable").join(", ");
32126
+ const message = unavailable ? `[${code}] No outbound reviewer is available \u2014 input blocked.` : `[${code}] Outbound review rejected the input${detail ? ` (${detail})` : ""}.`;
32127
+ logger.warn("command.ai.outbound-review", { commandId, origin, reasons });
32128
+ this.bus.push({ source: origin, kind: "log", text: message, level: "error" });
32129
+ if (origin === "local") {
32130
+ this.send(WsChannels.COMMAND_ANNOUNCE, {
32131
+ commandId,
32132
+ cmd: "claude",
32133
+ args: [],
32134
+ origin: "local"
32135
+ });
32136
+ }
32137
+ this.send(WsChannels.COMMAND_OUTPUT, { commandId, seq: 0, chunk: message });
32138
+ this.send(WsChannels.COMMAND_OUTPUT, { commandId, seq: 1, chunk: "", done: true, exitCode: 1 });
32139
+ this.bus.push({
32140
+ source: origin,
32141
+ kind: "exit",
32142
+ text: "\u2717 input blocked by outbound review",
32143
+ level: "error"
32144
+ });
32145
+ }
31327
32146
  /**
31328
32147
  * Send one envelope (encrypted payload + per-message wsToken — arch 0004).
31329
32148
  * When disconnected: the running command's output is held in a buffer (512KB cap,
@@ -31350,7 +32169,7 @@ var WsClient = class {
31350
32169
  * Used for quota.check before spawning an AI cli (ADR-0020).
31351
32170
  */
31352
32171
  request(channel, data) {
31353
- return new Promise((resolve5, reject) => {
32172
+ return new Promise((resolve6, reject) => {
31354
32173
  if (!this.socket || !this.sessionKey) {
31355
32174
  reject(new Error("Not connected to the server."));
31356
32175
  return;
@@ -31370,7 +32189,7 @@ var WsClient = class {
31370
32189
  }, 15e3);
31371
32190
  timer.unref?.();
31372
32191
  this.pending.set(envelope.id, {
31373
- resolve: (value) => resolve5(value),
32192
+ resolve: (value) => resolve6(value),
31374
32193
  timer
31375
32194
  });
31376
32195
  this.socket.send(JSON.stringify(envelope));
@@ -31464,6 +32283,8 @@ var WsClient = class {
31464
32283
  this.heartbeat = null;
31465
32284
  if (this.usageTimer) clearInterval(this.usageTimer);
31466
32285
  this.usageTimer = null;
32286
+ if (this.logUploadTimer) clearInterval(this.logUploadTimer);
32287
+ this.logUploadTimer = null;
31467
32288
  }
31468
32289
  /**
31469
32290
  * Poll the Claude subscription usage API and push a `machine.usage` snapshot (ADR-0072).
@@ -31476,7 +32297,7 @@ var WsClient = class {
31476
32297
  const config = readProfileConfig(this.context.profileDir);
31477
32298
  const aiCli2 = config.aiCli ?? "claude";
31478
32299
  const working = getWorkingProfile(this.context.profileDir, aiCli2);
31479
- const configured = claudeConfigDirs(config);
32300
+ const configured = claudeHomeDirs(config);
31480
32301
  const dirs = working ? [working, ...configured.filter((d) => d !== working)] : configured;
31481
32302
  const profile = working ? basename3(working) : dirs[0] ? basename3(dirs[0]) : "default";
31482
32303
  const api = await checkClaudeUsage(dirs, aiCli2);
@@ -31501,6 +32322,29 @@ var WsClient = class {
31501
32322
  this.usageTimer.unref?.();
31502
32323
  void this.pollUsage();
31503
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
+ }
31504
32348
  /**
31505
32349
  * Logout: close WS, delete `.cre`, and prompt to pair again (Unlinked state).
31506
32350
  */
@@ -31584,7 +32428,7 @@ var SessionBus = class {
31584
32428
  get status() {
31585
32429
  return this.currentStatus;
31586
32430
  }
31587
- /** Producer → consumers: push one transcript entry. */
32431
+ /** Producer → consumers: push one transcript entry. Returns its id (for `updateEntry`). */
31588
32432
  push(input) {
31589
32433
  const entry = {
31590
32434
  id: randomUUID2(),
@@ -31595,6 +32439,23 @@ var SessionBus = class {
31595
32439
  this.history.push(entry);
31596
32440
  if (this.history.length > REPLAY_CAP) this.history.shift();
31597
32441
  this.emitter.emit("transcript", entry);
32442
+ return entry.id;
32443
+ }
32444
+ /**
32445
+ * Producer → consumers: replace the text of an already-pushed entry (ADR-0108) — used to
32446
+ * grow a streaming `result` block in place without pushing a new line per chunk. No-op if the
32447
+ * entry has aged out of the replay window.
32448
+ */
32449
+ updateEntry(id, text) {
32450
+ const entry = this.history.find((e) => e.id === id);
32451
+ if (!entry) return;
32452
+ entry.text = text;
32453
+ this.emitter.emit("transcript-update", entry);
32454
+ }
32455
+ /** Subscribe to in-place entry updates (a `result` block growing — ADR-0108). */
32456
+ onTranscriptUpdate(fn) {
32457
+ this.emitter.on("transcript-update", fn);
32458
+ return () => this.emitter.off("transcript-update", fn);
31598
32459
  }
31599
32460
  /** Shortcut for a lifecycle log line (source = system). */
31600
32461
  log(text, level = "info") {
@@ -33699,7 +34560,7 @@ var src_default = Yoga;
33699
34560
 
33700
34561
  // ../../node_modules/.pnpm/ink@5.2.1_@types+react@18.3.31_react@18.3.1/node_modules/ink/build/reconciler.js
33701
34562
  var import_react_reconciler = __toESM(require_react_reconciler(), 1);
33702
- var import_constants8 = __toESM(require_constants(), 1);
34563
+ var import_constants10 = __toESM(require_constants(), 1);
33703
34564
  import process3 from "process";
33704
34565
 
33705
34566
  // ../../node_modules/.pnpm/ansi-regex@6.2.2/node_modules/ansi-regex/index.js
@@ -34941,7 +35802,7 @@ var reconciler_default = (0, import_react_reconciler.default)({
34941
35802
  scheduleTimeout: setTimeout,
34942
35803
  cancelTimeout: clearTimeout,
34943
35804
  noTimeout: -1,
34944
- getCurrentEventPriority: () => import_constants8.DefaultEventPriority,
35805
+ getCurrentEventPriority: () => import_constants10.DefaultEventPriority,
34945
35806
  beforeActiveInstanceBlur() {
34946
35807
  },
34947
35808
  afterActiveInstanceBlur() {
@@ -37015,8 +37876,8 @@ var Ink = class {
37015
37876
  }
37016
37877
  }
37017
37878
  async waitUntilExit() {
37018
- this.exitPromise ||= new Promise((resolve5, reject) => {
37019
- this.resolveExitPromise = resolve5;
37879
+ this.exitPromise ||= new Promise((resolve6, reject) => {
37880
+ this.resolveExitPromise = resolve6;
37020
37881
  this.rejectExitPromise = reject;
37021
37882
  });
37022
37883
  return this.exitPromise;
@@ -37513,6 +38374,16 @@ function textColor(line) {
37513
38374
  if (line.kind === "exit") return "green";
37514
38375
  return void 0;
37515
38376
  }
38377
+ function AiMarkerLine({ line }) {
38378
+ const arrow = line.kind === "aireq" ? "\u2039" : "\u203A";
38379
+ const idx = line.text.indexOf(arrow);
38380
+ if (idx < 0) return /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(Text, { children: line.text || " " });
38381
+ return /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)(Text, { children: [
38382
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(Text, { color: "cyan", bold: true, children: line.text.slice(0, idx) }),
38383
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(Text, { dimColor: true, children: arrow }),
38384
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(Text, { children: line.text.slice(idx + 1) })
38385
+ ] });
38386
+ }
37516
38387
  function wrapToWidth(text, width) {
37517
38388
  if (width <= 0 || text.length <= width) return [text];
37518
38389
  const rows = [];
@@ -37538,20 +38409,94 @@ function wrapToWidth(text, width) {
37538
38409
  if (line) rows.push(line.trimEnd());
37539
38410
  return rows.length > 0 ? rows : [""];
37540
38411
  }
37541
- function flattenEntries(entries2, textWidth) {
38412
+ var DETAIL_RE = /<detail\s+title="([^"]*)">([\s\S]*?)<\/detail>/g;
38413
+ function toSegments(raw) {
38414
+ if (!raw.includes("<detail")) {
38415
+ return raw.split("\n").map((text) => ({ text, collapsed: false }));
38416
+ }
38417
+ const out = [];
38418
+ const re = new RegExp(DETAIL_RE);
38419
+ let last = 0;
38420
+ let m;
38421
+ while ((m = re.exec(raw)) !== null) {
38422
+ const before = raw.slice(last, m.index).replace(/\n$/, "");
38423
+ if (before.length > 0) for (const p of before.split("\n")) out.push({ text: p, collapsed: false });
38424
+ out.push({ text: m[1] || "details", collapsed: true, body: m[2] ?? "" });
38425
+ last = m.index + m[0].length;
38426
+ if (raw[last] === "\n") last += 1;
38427
+ }
38428
+ const rest = raw.slice(last);
38429
+ if (rest.length > 0) for (const p of rest.split("\n")) out.push({ text: p, collapsed: false });
38430
+ if (out.length === 0) out.push({ text: "", collapsed: false });
38431
+ return out;
38432
+ }
38433
+ function pushCollapsible(lines, entry, opts) {
38434
+ const { blockNo, title, body, expanded, firstRow, textWidth, keyBase } = opts;
38435
+ const base = { source: entry.source, level: entry.level, kind: entry.kind };
38436
+ lines.push({
38437
+ ...base,
38438
+ key: `${keyBase}:m`,
38439
+ text: `${expanded ? "\u25BE" : "\u25B8"}[${blockNo}] ${title}`,
38440
+ showTag: firstRow,
38441
+ collapsed: true,
38442
+ spacer: false,
38443
+ blockIndex: blockNo
38444
+ });
38445
+ if (!expanded) return;
38446
+ prettyResult(body).split("\n").forEach(
38447
+ (seg, i) => wrapToWidth(seg, textWidth).forEach(
38448
+ (row, j) => lines.push({ ...base, key: `${keyBase}:b:${i}:${j}`, text: row, showTag: false, collapsed: false, spacer: false })
38449
+ )
38450
+ );
38451
+ }
38452
+ function flattenEntries(entries2, textWidth, expanded = /* @__PURE__ */ new Set()) {
37542
38453
  const lines = [];
38454
+ let prevKey = null;
38455
+ let blockNo = 0;
37543
38456
  for (const entry of entries2) {
38457
+ const blockKey = `${entry.source}|${entry.kind}`;
38458
+ const newBlock = blockKey !== prevKey;
38459
+ if (newBlock && lines.length > 0) {
38460
+ lines.push({
38461
+ key: `sp:${entry.id}`,
38462
+ source: entry.source,
38463
+ level: entry.level,
38464
+ kind: entry.kind,
38465
+ text: "",
38466
+ showTag: false,
38467
+ collapsed: false,
38468
+ spacer: true
38469
+ });
38470
+ }
38471
+ prevKey = blockKey;
38472
+ let firstRow = newBlock;
38473
+ if (entry.kind === "result") {
38474
+ blockNo += 1;
38475
+ const count = prettyResult(entry.text).split("\n").length;
38476
+ const title = `result \xB7 ${entry.resultKind ?? "json"} \xB7 ${count} ${count === 1 ? "line" : "lines"}`;
38477
+ pushCollapsible(lines, entry, { blockNo, title, body: entry.text, expanded: expanded.has(blockNo), firstRow, textWidth, keyBase: entry.id });
38478
+ continue;
38479
+ }
37544
38480
  const raw = entry.kind === "out" ? entry.text.replace(/\n$/, "") : entry.text;
37545
- const parts = raw.split("\n");
37546
- parts.forEach((text, i) => {
37547
- wrapToWidth(text, textWidth).forEach((row, j) => {
38481
+ toSegments(raw).forEach((seg, i) => {
38482
+ if (seg.collapsed) {
38483
+ blockNo += 1;
38484
+ pushCollapsible(lines, entry, { blockNo, title: seg.text, body: seg.body ?? "", expanded: expanded.has(blockNo), firstRow, textWidth, keyBase: `${entry.id}:${i}` });
38485
+ firstRow = false;
38486
+ return;
38487
+ }
38488
+ wrapToWidth(seg.text, textWidth).forEach((row, j) => {
37548
38489
  lines.push({
37549
38490
  key: `${entry.id}:${i}:${j}`,
37550
38491
  source: entry.source,
37551
38492
  level: entry.level,
37552
38493
  kind: entry.kind,
37553
- text: row
38494
+ text: row,
38495
+ showTag: firstRow,
38496
+ collapsed: false,
38497
+ spacer: false
37554
38498
  });
38499
+ firstRow = false;
37555
38500
  });
37556
38501
  });
37557
38502
  }
@@ -37560,8 +38505,8 @@ function flattenEntries(entries2, textWidth) {
37560
38505
  function TranscriptLine({ line }) {
37561
38506
  const tag = sourceTag(line.source);
37562
38507
  return /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)(Box_default, { flexDirection: "row", children: [
37563
- /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(Box_default, { width: 7, marginRight: 1, children: /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(Text, { color: tag.color, dimColor: line.source === "system", children: tag.text }) }),
37564
- /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(Box_default, { flexGrow: 1, children: /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(Text, { color: textColor(line), wrap: "truncate-end", children: line.text || " " }) })
38508
+ /* @__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 }),
38509
+ /* @__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 || " " }) })
37565
38510
  ] });
37566
38511
  }
37567
38512
 
@@ -37573,9 +38518,9 @@ import { existsSync as existsSync10 } from "fs";
37573
38518
  import { join as join16 } from "path";
37574
38519
  var CONFIG_KEYS = [
37575
38520
  "aiCli",
37576
- "aiArgs",
37577
- "claudeConfigDir",
38521
+ "claudeHome",
37578
38522
  "codexHome",
38523
+ "antigravityHome",
37579
38524
  "physicPath",
37580
38525
  "commandHistoryUploadMinutes",
37581
38526
  "autoUpdate",
@@ -37591,14 +38536,13 @@ function parseConfigAssignment(key, valueTokens) {
37591
38536
  case "physicPath":
37592
38537
  if (!value) return { error: `Missing value for "${key}".` };
37593
38538
  return { patch: { [key]: value } };
37594
- case "claudeConfigDir":
37595
- case "codexHome": {
38539
+ case "claudeHome":
38540
+ case "codexHome":
38541
+ case "antigravityHome": {
37596
38542
  const dirs = valueTokens.filter((t) => t.trim());
37597
38543
  if (dirs.length === 0) return { error: `Missing value for "${key}".` };
37598
- return { patch: { [key]: dirs.length === 1 ? dirs[0] : dirs } };
38544
+ return { patch: { [key]: dirs.map((profile) => ({ profile, model: "" })) } };
37599
38545
  }
37600
- case "aiArgs":
37601
- return { patch: { aiArgs: valueTokens } };
37602
38546
  case "commandHistoryUploadMinutes": {
37603
38547
  const n = Number(value);
37604
38548
  if (!Number.isFinite(n) || n <= 0) return { error: `"${value}" is not a positive number.` };
@@ -37628,22 +38572,19 @@ function runConfig(ctx) {
37628
38572
  return;
37629
38573
  }
37630
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
+ };
37631
38580
  if (existsSync10(path)) {
37632
- 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
+ );
37633
38585
  return;
37634
38586
  }
37635
- const defaults = {
37636
- aiCli: "claude",
37637
- aiArgs: ["-p"],
37638
- // Lists so failover across profiles is discoverable (ADR-0057) — add more dirs.
37639
- claudeConfigDir: [".claude"],
37640
- codexHome: [".codex"],
37641
- autoUpdate: true,
37642
- commandHistoryUploadMinutes: 10
37643
- };
37644
- writeProfileConfig(dir2, defaults);
37645
- ctx.print(`\u2714 wrote default config: ${path}`);
37646
- ctx.print("add claude profiles: /config set claudeConfigDir .claude .claude-1 (tried in order)");
38587
+ writeDefaults("wrote");
37647
38588
  return;
37648
38589
  }
37649
38590
  if (sub === "set") {
@@ -37758,6 +38699,19 @@ function runWhoami(ctx) {
37758
38699
  ctx.print(`projects: ${w.projects.length ? w.projects.map((p) => p.name).join(", ") : "(none)"}`);
37759
38700
  });
37760
38701
  }
38702
+ function runFold(ctx, action) {
38703
+ if (ctx.maxBlock === 0) {
38704
+ ctx.print("No collapsible blocks on screen.", "warn");
38705
+ return;
38706
+ }
38707
+ const n = ctx.args[0] != null ? Number(ctx.args[0]) : ctx.maxBlock;
38708
+ if (!Number.isInteger(n) || n < 1 || n > ctx.maxBlock) {
38709
+ ctx.print(`usage: /${action} [1-${ctx.maxBlock}] (no number \u21D2 the newest block)`, "error");
38710
+ return;
38711
+ }
38712
+ if (action === "expand") ctx.expand(n);
38713
+ else ctx.collapse(n);
38714
+ }
37761
38715
  var COMMANDS = [
37762
38716
  {
37763
38717
  name: "help",
@@ -37810,6 +38764,18 @@ var COMMANDS = [
37810
38764
  description: "Tail the profile's structured log",
37811
38765
  run: runLogs
37812
38766
  },
38767
+ {
38768
+ name: "expand",
38769
+ usage: "/expand [N]",
38770
+ description: "Expand a collapsed \u25B8[N] block (default: newest); pretty-prints json/code",
38771
+ run: (ctx) => runFold(ctx, "expand")
38772
+ },
38773
+ {
38774
+ name: "collapse",
38775
+ usage: "/collapse [N]",
38776
+ description: "Collapse an expanded \u25B8[N] block (default: newest)",
38777
+ run: (ctx) => runFold(ctx, "collapse")
38778
+ },
37813
38779
  {
37814
38780
  name: "reconnect",
37815
38781
  usage: "/reconnect",
@@ -38016,13 +38982,19 @@ function App2({
38016
38982
  const [usage, setUsage] = (0, import_react26.useState)(bus.usage);
38017
38983
  const [sessionTokens, setSessionTokens] = (0, import_react26.useState)(bus.sessionTokens);
38018
38984
  const [scrollBack, setScrollBack] = (0, import_react26.useState)(0);
38985
+ const [expanded, setExpanded] = (0, import_react26.useState)(() => /* @__PURE__ */ new Set());
38019
38986
  const [history, setHistory] = (0, import_react26.useState)(() => loadInputHistory(info.profileDir));
38020
38987
  const [startedAt] = (0, import_react26.useState)(() => Date.now());
38988
+ const [pendingConfirm, setPendingConfirm] = (0, import_react26.useState)(null);
38021
38989
  (0, import_react26.useEffect)(() => {
38022
- const offTranscript = bus.onTranscript(
38023
- (entry) => setEntries(
38990
+ const offTranscript = bus.onTranscript((entry) => {
38991
+ setEntries(
38024
38992
  (prev) => prev.length >= ENTRY_CAP ? [...prev.slice(prev.length - ENTRY_CAP + 1), entry] : [...prev, entry]
38025
- )
38993
+ );
38994
+ if (entry.kind === "aireq" || entry.kind === "aires") setExpanded(/* @__PURE__ */ new Set());
38995
+ });
38996
+ const offUpdate = bus.onTranscriptUpdate(
38997
+ (updated) => setEntries((prev) => prev.map((e) => e.id === updated.id ? { ...e, text: updated.text } : e))
38026
38998
  );
38027
38999
  const offStatus = bus.onStatus(setStatus);
38028
39000
  const offBusy = bus.onBusy(setBusy);
@@ -38034,6 +39006,7 @@ function App2({
38034
39006
  const offTokens = bus.onTokens(setSessionTokens);
38035
39007
  return () => {
38036
39008
  offTranscript();
39009
+ offUpdate();
38037
39010
  offStatus();
38038
39011
  offBusy();
38039
39012
  offProfile();
@@ -38044,10 +39017,41 @@ function App2({
38044
39017
  offTokens();
38045
39018
  };
38046
39019
  }, [bus]);
39020
+ const bodyHeight = Math.max(3, rows - CHROME_ROWS);
39021
+ const textWidth = Math.max(10, cols - 1 - 8);
39022
+ const lines = (0, import_react26.useMemo)(() => flattenEntries(entries2, textWidth, expanded), [entries2, textWidth, expanded]);
39023
+ const maxBlock = (0, import_react26.useMemo)(() => lines.reduce((m, l) => Math.max(m, l.blockIndex ?? 0), 0), [lines]);
39024
+ const expandFold = (n) => {
39025
+ const target = n ?? maxBlock;
39026
+ if (target < 1 || target > maxBlock) return;
39027
+ setExpanded((prev) => {
39028
+ const next = new Set(prev);
39029
+ if (next.has(target)) next.delete(target);
39030
+ else next.add(target);
39031
+ return next;
39032
+ });
39033
+ };
39034
+ const collapseFold = (n) => {
39035
+ const target = n ?? maxBlock;
39036
+ setExpanded((prev) => {
39037
+ if (!prev.has(target)) return prev;
39038
+ const next = new Set(prev);
39039
+ next.delete(target);
39040
+ return next;
39041
+ });
39042
+ };
38047
39043
  const handleSubmit = (input) => {
38048
39044
  setScrollBack(0);
38049
- setHistory((prev) => appendInputHistory(info.profileDir, input, prev));
38050
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));
38051
39055
  if (trimmed.startsWith("/")) {
38052
39056
  bus.push({ source: "local", kind: "cmd", text: trimmed });
38053
39057
  runSlashCommand(trimmed, {
@@ -38059,17 +39063,21 @@ function App2({
38059
39063
  stdout.write("\x1B[2J\x1B[3J\x1B[H");
38060
39064
  },
38061
39065
  quit: () => exit(),
39066
+ confirm: (prompt, onYes) => {
39067
+ bus.push({ source: "local", kind: "log", text: prompt });
39068
+ setPendingConfirm({ prompt, onYes });
39069
+ },
38062
39070
  submitAi: (aiInput) => bus.submitLocal(aiInput),
38063
39071
  reconnect: () => bus.requestReconnect(),
39072
+ expand: expandFold,
39073
+ collapse: collapseFold,
39074
+ maxBlock,
38064
39075
  live: { status, scope, worker, project, activeProfile, startedAt }
38065
39076
  });
38066
39077
  return;
38067
39078
  }
38068
39079
  bus.submitLocal(input);
38069
39080
  };
38070
- const bodyHeight = Math.max(3, rows - CHROME_ROWS);
38071
- const textWidth = Math.max(10, cols - 1 - 8);
38072
- const lines = (0, import_react26.useMemo)(() => flattenEntries(entries2, textWidth), [entries2, textWidth]);
38073
39081
  const maxScroll = Math.max(0, lines.length - bodyHeight);
38074
39082
  const clampedBack = Math.min(scrollBack, maxScroll);
38075
39083
  const lineRows = clampedBack > 0 ? bodyHeight - 1 : bodyHeight;
@@ -38130,6 +39138,7 @@ async function runStart(profileDir2, profileName) {
38130
39138
  base: { version: CLI_VERSION, profile: profileName, scope: credential.scope }
38131
39139
  });
38132
39140
  logger.info("cli.start", { serverUrl: credential.serverUrl });
39141
+ ensureProfileConfig(profileDir2);
38133
39142
  const config = readProfileConfig(profileDir2);
38134
39143
  const autoUpdate = config.autoUpdate !== false && process.env.FOURPM_NO_UPDATE !== "1";
38135
39144
  const result = await checkAndUpdate(credential.serverUrl, autoUpdate);
@@ -38172,6 +39181,20 @@ async function runStart(profileDir2, profileName) {
38172
39181
  profileDir: profileDir2,
38173
39182
  bus
38174
39183
  });
39184
+ const runResilient = async () => {
39185
+ for (; ; ) {
39186
+ try {
39187
+ await client.run();
39188
+ return;
39189
+ } catch (err) {
39190
+ const e = err instanceof Error ? err : new Error(String(err));
39191
+ logger.error("ws.run.crash", { message: e.message, stack: e.stack });
39192
+ if (client.isStopped) return;
39193
+ bus.log(`Recovered from an internal error \u2014 reconnecting: ${e.message}`, "error");
39194
+ await new Promise((resolve6) => setTimeout(resolve6, 1e3));
39195
+ }
39196
+ }
39197
+ };
38175
39198
  const useTui = Boolean(process.stdout.isTTY) && process.env.FOURPM_HEADLESS !== "1";
38176
39199
  if (useTui) {
38177
39200
  const info = {
@@ -38190,12 +39213,12 @@ async function runStart(profileDir2, profileName) {
38190
39213
  bus.setActiveProfile(workingDir ? profileDisplayLabel(workingDir) : labels[0] ?? null);
38191
39214
  const tui = runTui(bus, info);
38192
39215
  bus.log(`Connecting to ${credential.serverUrl} (profile: ${profileName})\u2026`);
38193
- void client.run();
39216
+ void runResilient();
38194
39217
  await tui;
38195
39218
  } else {
38196
39219
  attachConsoleSink(bus);
38197
39220
  bus.log(`Connecting to ${credential.serverUrl} (profile: ${profileName})\u2026`);
38198
- await client.run();
39221
+ await runResilient();
38199
39222
  }
38200
39223
  } finally {
38201
39224
  release();
@@ -38225,7 +39248,7 @@ async function runUnlink(profileDir2, profileName) {
38225
39248
 
38226
39249
  // src/commands/update.ts
38227
39250
  async function runUpdate(profileDir2, profileName, serverUrlFlag) {
38228
- const serverUrl = (serverUrlFlag ?? readCredential(profileDir2, profileName)?.serverUrl ?? process.env.FOURPM_SERVER ?? "http://localhost:3001").replace(/\/$/, "");
39251
+ const serverUrl = (serverUrlFlag || readCredential(profileDir2, profileName)?.serverUrl || process.env.FOURPM_SERVER || "http://localhost:42001").replace(/\/$/, "");
38229
39252
  let result;
38230
39253
  try {
38231
39254
  result = await updateToLatest(serverUrl);
@@ -38295,14 +39318,21 @@ function Picker({
38295
39318
  ] });
38296
39319
  }
38297
39320
  function selectFromList(items, title) {
38298
- return new Promise((resolve5) => {
39321
+ return new Promise((resolve6) => {
38299
39322
  let chosen = null;
38300
39323
  const app = render_default(/* @__PURE__ */ (0, import_jsx_runtime7.jsx)(Picker, { items, title, onDone: (v) => chosen = v }));
38301
- void app.waitUntilExit().then(() => resolve5(chosen));
39324
+ void app.waitUntilExit().then(() => resolve6(chosen));
38302
39325
  });
38303
39326
  }
38304
39327
 
38305
39328
  // src/index.ts
39329
+ process.on("unhandledRejection", (reason) => {
39330
+ const err = reason instanceof Error ? reason : new Error(String(reason));
39331
+ logger.error("process.unhandledRejection", { message: err.message, stack: err.stack });
39332
+ });
39333
+ process.on("uncaughtException", (err) => {
39334
+ logger.error("process.uncaughtException", { message: err.message, stack: err.stack });
39335
+ });
38306
39336
  function flagValue(args, name) {
38307
39337
  const index = args.indexOf(`--${name}`);
38308
39338
  return index >= 0 ? args[index + 1] : void 0;
@@ -38331,7 +39361,7 @@ async function main() {
38331
39361
  const [command, ...rest] = args;
38332
39362
  switch (command) {
38333
39363
  case "link": {
38334
- const serverUrl = flagValue(rest, "server") ?? process.env.FOURPM_SERVER ?? "http://localhost:3001";
39364
+ const serverUrl = flagValue(rest, "server") || process.env.FOURPM_SERVER || "http://localhost:42001";
38335
39365
  await runLink(serverUrl.replace(/\/$/, ""), explicitProfile);
38336
39366
  break;
38337
39367
  }
@@ -38380,7 +39410,7 @@ async function main() {
38380
39410
  "",
38381
39411
  "Profiles: named after the paired account (e.g. mcacc1); when several exist,",
38382
39412
  "start/unlink show a picker (\u2191\u2193 + Enter) \u2014 no --profile needed.",
38383
- "Server URL: from FOURPM_SERVER (default http://localhost:3001)."
39413
+ "Server URL: from FOURPM_SERVER (default http://localhost:42001)."
38384
39414
  ].join("\n")
38385
39415
  );
38386
39416
  }