@tekmidian/pai 0.37.0 → 0.38.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.
@@ -104,6 +104,28 @@ function writeJsonAtomic(path, data, opts = {}) {
104
104
  * `engine: "codex"` providers run through the Codex CLI instead of Claude
105
105
  * Code.
106
106
  */
107
+ const DEFAULT_COST_TIER = 3;
108
+ /** Tags a provider may carry; classes filter auto-routing on them. */
109
+ const PROVIDER_TAGS = [
110
+ "code",
111
+ "vision",
112
+ "image-gen",
113
+ "long-context",
114
+ "fast",
115
+ "reasoning"
116
+ ];
117
+ /** The standard task classes; `workers.classes` maps each to a target. */
118
+ const WORKER_CLASSES = [
119
+ "draft",
120
+ "plan",
121
+ "implement",
122
+ "review",
123
+ "research",
124
+ "spotcheck",
125
+ "simple",
126
+ "complex",
127
+ "image"
128
+ ];
107
129
  const DEFAULT_LOG_DIR = "~/.claude/logs/workers";
108
130
  const DEFAULT_PANE = {
109
131
  enabled: true,
@@ -120,7 +142,7 @@ function defaultWorkersConfig() {
120
142
  enabled: false,
121
143
  active: null,
122
144
  providers: {},
123
- roles: {},
145
+ classes: {},
124
146
  mcpSets: {},
125
147
  pane: { ...DEFAULT_PANE },
126
148
  logDir: DEFAULT_LOG_DIR,
@@ -171,6 +193,16 @@ function parseProvider(name, raw) {
171
193
  }
172
194
  const upstreamUrl = str(p.upstreamUrl);
173
195
  if (protocol === "openai" && !upstreamUrl) bad(`.providers.${name}.upstreamUrl`, `is required for protocol "openai" (the Chat Completions base, e.g. "https://api.openai.com/v1")`);
196
+ const costTier = p.costTier === void 0 ? void 0 : p.costTier;
197
+ if (costTier !== void 0) {
198
+ if (typeof costTier !== "number" || !Number.isInteger(costTier) || costTier < 1 || costTier > 5) bad(`.providers.${name}.costTier`, "must be an integer 1 (cheapest) … 5 (most expensive)");
199
+ }
200
+ let tags;
201
+ if (p.tags !== void 0) {
202
+ if (!Array.isArray(p.tags) || p.tags.some((x) => typeof x !== "string")) bad(`.providers.${name}.tags`, `must be an array of tags from: ${PROVIDER_TAGS.join(", ")}`);
203
+ for (const t of p.tags) if (!PROVIDER_TAGS.includes(t)) bad(`.providers.${name}.tags`, `"${t}" is not a tag (from: ${PROVIDER_TAGS.join(", ")})`);
204
+ tags = p.tags;
205
+ }
174
206
  return {
175
207
  enabled: p.enabled === void 0 ? true : p.enabled === true,
176
208
  protocol,
@@ -186,7 +218,9 @@ function parseProvider(name, raw) {
186
218
  ...engine !== "claude" ? { engine } : {},
187
219
  ...str(p.quotaProbe) ? { quotaProbe: str(p.quotaProbe) } : {},
188
220
  ...quotaSkipAt !== void 0 ? { quotaSkipAt } : {},
189
- ...contextWindow !== void 0 ? { contextWindow } : {}
221
+ ...contextWindow !== void 0 ? { contextWindow } : {},
222
+ ...costTier !== void 0 ? { costTier } : {},
223
+ ...tags ? { tags } : {}
190
224
  };
191
225
  }
192
226
  /**
@@ -203,26 +237,47 @@ function parseWorkersConfig(raw) {
203
237
  if (typeof w.providers !== "object" || w.providers === null || Array.isArray(w.providers)) bad(".providers", "must be an object keyed by provider name");
204
238
  for (const [name, p] of Object.entries(w.providers)) providers[name] = parseProvider(name, p);
205
239
  }
206
- const roles = {};
207
- if (w.roles !== void 0) {
208
- if (typeof w.roles !== "object" || w.roles === null || Array.isArray(w.roles)) bad(".roles", "must be an object of role → provider[/alias] or {provider, mcp}");
209
- for (const [role, target] of Object.entries(w.roles)) if (typeof target === "object" && target !== null && !Array.isArray(target)) {
240
+ const classes = {};
241
+ const classesRaw = w.classes !== void 0 ? w.classes : w.roles;
242
+ if (classesRaw !== void 0) {
243
+ if (typeof classesRaw !== "object" || classesRaw === null || Array.isArray(classesRaw)) bad(w.classes !== void 0 ? ".classes" : ".roles", "must be an object of class → provider[/alias] or {provider, mcp, maxCostTier, requireTags, order}");
244
+ for (const [cls, target] of Object.entries(classesRaw)) if (typeof target === "object" && target !== null && !Array.isArray(target)) {
210
245
  const o = target;
211
246
  const provider = str(o.provider);
212
- if (!provider || provider.includes(" ")) bad(`.roles.${role}.provider`, `invalid provider "${provider}"`);
247
+ if (o.provider !== void 0 && (!provider || provider.includes(" "))) bad(`.classes.${cls}.provider`, `invalid provider "${provider}"`);
213
248
  let mcp;
214
249
  if (o.mcp !== void 0) {
215
- if (!Array.isArray(o.mcp) || o.mcp.some((x) => typeof x !== "string")) bad(`.roles.${role}.mcp`, "must be an array of MCP server or set names");
250
+ if (!Array.isArray(o.mcp) || o.mcp.some((x) => typeof x !== "string")) bad(`.classes.${cls}.mcp`, "must be an array of MCP server or set names");
216
251
  mcp = o.mcp;
217
252
  }
218
- roles[role] = mcp ? {
219
- provider,
220
- mcp
221
- } : { provider };
253
+ let maxCostTier;
254
+ if (o.maxCostTier !== void 0) {
255
+ if (typeof o.maxCostTier !== "number" || !Number.isInteger(o.maxCostTier) || o.maxCostTier < 1 || o.maxCostTier > 5) bad(`.classes.${cls}.maxCostTier`, "must be an integer 1 … 5");
256
+ maxCostTier = o.maxCostTier;
257
+ }
258
+ let requireTags;
259
+ if (o.requireTags !== void 0) {
260
+ if (!Array.isArray(o.requireTags) || o.requireTags.some((x) => typeof x !== "string")) bad(`.classes.${cls}.requireTags`, `must be an array of tags from: ${PROVIDER_TAGS.join(", ")}`);
261
+ for (const t of o.requireTags) if (!PROVIDER_TAGS.includes(t)) bad(`.classes.${cls}.requireTags`, `"${t}" is not a tag (from: ${PROVIDER_TAGS.join(", ")})`);
262
+ requireTags = o.requireTags;
263
+ }
264
+ let order;
265
+ if (o.order !== void 0) {
266
+ if (!Array.isArray(o.order) || o.order.some((x) => typeof x !== "string")) bad(`.classes.${cls}.order`, "must be an array of provider names");
267
+ order = o.order;
268
+ }
269
+ const obj = {
270
+ ...provider ? { provider } : {},
271
+ ...mcp ? { mcp } : {},
272
+ ...maxCostTier !== void 0 ? { maxCostTier } : {},
273
+ ...requireTags ? { requireTags } : {},
274
+ ...order ? { order } : {}
275
+ };
276
+ classes[cls] = Object.keys(obj).length ? obj : {};
222
277
  } else {
223
278
  const t = str(target);
224
- if (!t || t.includes(" ")) bad(`.roles.${role}`, `invalid target "${t}"`);
225
- roles[role] = t;
279
+ if (!t || t.includes(" ")) bad(`.classes.${cls}`, `invalid target "${t}"`);
280
+ classes[cls] = t;
226
281
  }
227
282
  }
228
283
  const mcpSets = {};
@@ -271,7 +326,7 @@ function parseWorkersConfig(raw) {
271
326
  enabled: w.enabled === void 0 ? d.enabled : w.enabled === true,
272
327
  active,
273
328
  providers,
274
- roles,
329
+ classes,
275
330
  mcpSets,
276
331
  pane,
277
332
  logDir: str(w.logDir) || d.logDir,
@@ -308,6 +363,10 @@ function providerKeyPath(p) {
308
363
  return p.keyFile ? expandHome(p.keyFile) : null;
309
364
  }
310
365
  const DEFAULT_CONTEXT_WINDOW = 2e5;
366
+ /** Cost tier of a provider for class filtering (unset = 3, the middle). */
367
+ function providerCostTier(p) {
368
+ return p.costTier ?? DEFAULT_COST_TIER;
369
+ }
311
370
  /** Context window used by the meter when the init event carries none. */
312
371
  function providerContextWindow(p) {
313
372
  return p.contextWindow ?? DEFAULT_CONTEXT_WINDOW;
@@ -525,9 +584,18 @@ function nowStamp(d = /* @__PURE__ */ new Date()) {
525
584
  const p = (n) => String(n).padStart(2, "0");
526
585
  return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`;
527
586
  }
587
+ let lastId = "";
588
+ let idSeq = 0;
528
589
  function newWorkerId(d = /* @__PURE__ */ new Date(), pid = process.pid) {
529
590
  const p = (n) => String(n).padStart(2, "0");
530
- return `${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}-${p(d.getHours())}${p(d.getMinutes())}${p(d.getSeconds())}-${pid}`;
591
+ const base = `${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}-${p(d.getHours())}${p(d.getMinutes())}${p(d.getSeconds())}-${pid}`;
592
+ if (base === lastId) {
593
+ idSeq += 1;
594
+ return `${base}-${String(idSeq).padStart(2, "0")}`;
595
+ }
596
+ lastId = base;
597
+ idSeq = 0;
598
+ return base;
531
599
  }
532
600
  /** Write status atomically (temp + rename) and stamp `updated`. */
533
601
  function saveStatus(logDir, status, d = /* @__PURE__ */ new Date()) {
@@ -676,16 +744,19 @@ function sessionTag(worker) {
676
744
  //#endregion
677
745
  //#region src/workers/routing.ts
678
746
  /**
679
- * routing.ts — provider selection: flag > role > active (possibly "auto").
747
+ * routing.ts — provider selection: flag > class > active (possibly "auto").
680
748
  *
681
- * Auto-routing walks `workers.routing.order` and takes the first provider
682
- * that is enabled, out of cooldown, and (when it defines a quotaProbe) under
683
- * its quotaSkipAt threshold. A run that dies of a quota/rate error puts its
684
- * provider in cooldown for cooldownMinutes; when that happens before the
685
- * first tool call and retryOnQuota is set, the runner restarts the same task
686
- * on the next provider (ledger: WORKER-REROUTE).
749
+ * Auto-routing walks `workers.routing.order` or the class's own `order` —
750
+ * and takes the first provider that is enabled, out of cooldown, (when it
751
+ * defines a quotaProbe) under its quotaSkipAt threshold, and when the class
752
+ * constrains it within `maxCostTier` and carrying all `requireTags`. A run
753
+ * that dies of a quota/rate error puts its provider in cooldown for
754
+ * cooldownMinutes; when that happens before the first tool call and
755
+ * retryOnQuota is set, the runner restarts the same task on the next provider
756
+ * (ledger: WORKER-REROUTE).
687
757
  *
688
- * An explicit --provider or --role always bypasses all of this.
758
+ * An explicit --provider or a class mapping that pins a provider always
759
+ * bypasses all of this.
689
760
  */
690
761
  const QUOTA_SKIP_DEFAULT = 95;
691
762
  const PROBE_TIMEOUT_MS = 1e4;
@@ -764,34 +835,61 @@ function mustBeRunnable(name, p) {
764
835
  if (!p.enabled) throw new NoProviderError(`provider "${name}" is disabled. Enable it with: pai worker providers enable ${name}`);
765
836
  return p;
766
837
  }
838
+ /** Why one provider of a routing order did not qualify (message fragment). */
839
+ function exclusionReason(config, state, name, cls) {
840
+ const p = config.providers[name];
841
+ if (!p) return "not configured";
842
+ if (!p.enabled) return "disabled";
843
+ if (cooldownRemaining(state, name) > 0) return "cooldown";
844
+ if (quotaExceeded(p)) return "quota";
845
+ const tier = providerCostTier(p);
846
+ if (cls?.maxCostTier !== void 0 && tier > cls.maxCostTier) return `cost tier ${tier} > max ${cls.maxCostTier}`;
847
+ if (cls?.requireTags?.length) {
848
+ const have = p.tags ?? [];
849
+ const missing = cls.requireTags.filter((t) => !have.includes(t));
850
+ if (missing.length) return `missing tags: ${missing.join(", ")}`;
851
+ }
852
+ return null;
853
+ }
767
854
  /**
768
855
  * Resolve which provider (and model alias) a run uses.
769
856
  *
770
857
  * @param flagProvider --provider value, highest precedence
771
- * @param role --role value, looked up in workers.roles
858
+ * @param className --class value (the old --role), looked up in workers.classes
772
859
  */
773
860
  function resolveTarget(config, logDir, opts = {}) {
774
861
  if (opts.flagProvider) return {
775
862
  providerName: opts.flagProvider,
776
863
  provider: mustBeRunnable(opts.flagProvider, mustExist(config, opts.flagProvider)),
777
864
  modelAlias: null,
778
- roleMcp: null,
865
+ classMcp: null,
779
866
  via: "flag"
780
867
  };
781
- if (opts.role) {
782
- const target = config.roles[opts.role];
783
- if (!target) throw new NoProviderError(`no role named "${opts.role}". Defined: ${Object.keys(config.roles).join(", ") || "(none)"}.\nSet one with: pai worker roles set ${opts.role}=<provider[/alias]>`);
784
- const name = typeof target === "string" ? target.split("/")[0] : target.provider;
785
- const alias = typeof target === "string" ? target.split("/")[1] : void 0;
786
- const roleMcp = typeof target === "string" ? null : target.mcp ?? null;
868
+ const clsTarget = opts.className ? config.classes[opts.className] : void 0;
869
+ const cls = typeof clsTarget === "object" && clsTarget !== null ? clsTarget : void 0;
870
+ if (opts.className && clsTarget !== void 0 && typeof clsTarget !== "object") {
871
+ const [name, alias] = clsTarget.split("/");
787
872
  return {
788
873
  providerName: name,
789
874
  provider: mustBeRunnable(name, mustExist(config, name)),
790
875
  modelAlias: alias ?? null,
791
- roleMcp,
792
- via: "role"
876
+ classMcp: null,
877
+ via: "class"
878
+ };
879
+ }
880
+ if (opts.className && cls?.provider) {
881
+ const provider = mustBeRunnable(cls.provider, mustExist(config, cls.provider));
882
+ return {
883
+ providerName: cls.provider,
884
+ provider,
885
+ modelAlias: null,
886
+ classMcp: cls.mcp ?? null,
887
+ via: "class"
793
888
  };
794
889
  }
890
+ if (opts.className && clsTarget === void 0) {
891
+ if (!WORKER_CLASSES.includes(opts.className)) throw new NoProviderError(`no class named "${opts.className}". Standard classes: ${WORKER_CLASSES.join(", ")}.Defined: ${Object.keys(config.classes).join(", ") || "(none)"}.\nSet one with: pai worker classes set ${opts.className}=<provider[/alias]>`);
892
+ }
795
893
  if (config.active !== "auto") {
796
894
  const name = config.active;
797
895
  if (!name) throw new NoProviderError("no active worker provider. Add one with: pai worker providers add <name> --base-url <url> --key-file <path> --model <model>\n(or point one that exists at it: pai worker providers use <name>)");
@@ -799,32 +897,29 @@ function resolveTarget(config, logDir, opts = {}) {
799
897
  providerName: name,
800
898
  provider: mustBeRunnable(name, mustExist(config, name)),
801
899
  modelAlias: null,
802
- roleMcp: null,
900
+ classMcp: null,
803
901
  via: "active"
804
902
  };
805
903
  }
806
904
  const state = readRoutingState(logDir);
807
- const skipped = [];
808
- for (const name of config.routing.order) {
905
+ const order = cls?.order ?? config.routing.order;
906
+ const excluded = [];
907
+ for (const name of order) {
809
908
  const p = config.providers[name];
810
- if (!p || !p.enabled) continue;
811
- if (cooldownRemaining(state, name) > 0) {
812
- skipped.push(`${name}(cooldown)`);
813
- continue;
814
- }
815
- if (quotaExceeded(p)) {
816
- skipped.push(`${name}(quota)`);
909
+ const why = exclusionReason(config, state, name, cls);
910
+ if (why) {
911
+ excluded.push(`${name}: ${why}`);
817
912
  continue;
818
913
  }
819
914
  return {
820
915
  providerName: name,
821
916
  provider: p,
822
917
  modelAlias: null,
823
- roleMcp: null,
918
+ classMcp: cls?.mcp ?? null,
824
919
  via: "auto"
825
920
  };
826
921
  }
827
- throw new NoProviderError(`auto-routing found no usable provider (order: [${config.routing.order.join(", ")}]${skipped.length ? `; skipped: ${skipped.join(", ")}` : ""}).\nClear a cooldown with: pai worker providers enable <name>`);
922
+ throw new NoProviderError(`auto-routing found no usable provider${opts.className ? ` for class "${opts.className}"` : ""} (order: [${order.join(", ")}]).${excluded.length ? `\nExcluded: ${excluded.join("; ")}.` : ""}\nClear a cooldown with: pai worker providers enable <name>; widen the class with: pai worker classes set ${opts.className ?? "<class>"}=<target>`);
828
923
  }
829
924
  /**
830
925
  * Next provider after `from` in auto order, applying the same filters.
@@ -859,11 +954,13 @@ function isQuotaFailure(resultText) {
859
954
  * pane.ts — the per-worker follow pane in iTerm2.
860
955
  *
861
956
  * One small-font pane per worker, stacked in a right-hand column: the first
862
- * worker of a scope splits the launching session vertically (right, ~40% of
863
- * the columns), every further one splits the lowest live worker pane
864
- * horizontally, so panes stack top to bottom. Each pane runs
865
- * `pai worker follow <id> --auto-exit <n>` under the `pai-worker` dynamic
866
- * profile (Close Sessions On End), so panes disappear by themselves.
957
+ * worker of a scope splits the launching session vertically, every further
958
+ * one splits the lowest live worker pane horizontally, so panes stack top to
959
+ * bottom. The split never sizes the new session that would grow the whole
960
+ * window; instead the window's bounds are read before the split and restored
961
+ * right after, so the panes share the space the window already had. Each
962
+ * pane runs `pai worker follow <id> --auto-exit <n>` under the `pai-worker`
963
+ * dynamic profile (Close Sessions On End), so panes disappear by themselves.
867
964
  *
868
965
  * Panes are tracked per scope (AIBroker session id, else tab key) in
869
966
  * <logDir>/panes/<key>.json, keyed by iTerm session unique id, newest first —
@@ -893,10 +990,14 @@ const WORKER_SPLIT_SCRIPT = `on run(argv)
893
990
  repeat with t in tabs of w
894
991
  repeat with s in sessions of t
895
992
  if id of s is targetID then
896
- set parentCols to missing value
897
- try
898
- set parentCols to columns of s
899
- end try
993
+ -- sizing the new session would grow the whole window:
994
+ -- pin the window's bounds now and restore them after
995
+ -- the split, so the panes share the existing space.
996
+ -- copy, never set: set stores the property
997
+ -- reference lazily, so restoring it would re-read the
998
+ -- post-split bounds instead of these — observed as the
999
+ -- window jumping to the main display
1000
+ copy bounds of w to winBounds
900
1001
  set sessIDs to {}
901
1002
  repeat with other in sessions of t
902
1003
  set end of sessIDs to (id of other as text)
@@ -936,13 +1037,9 @@ const WORKER_SPLIT_SCRIPT = `on run(argv)
936
1037
  tell newS
937
1038
  write text followCmd
938
1039
  end tell
939
- if splitS is missing value and parentCols is not missing value then
940
- try
941
- tell newS
942
- set columns to (round (parentCols * 0.4))
943
- end tell
944
- end try
945
- end if
1040
+ try
1041
+ set bounds of w to winBounds
1042
+ end try
946
1043
  set out to ""
947
1044
  repeat with lid in lived
948
1045
  set out to out & lid & ","
@@ -987,6 +1084,27 @@ const TAB_TTYS_SCRIPT = `on run(argv)
987
1084
  end tell
988
1085
  return "notfound"
989
1086
  end run`;
1087
+ const WINDOW_BOUNDS_SCRIPT = `on run(argv)
1088
+ set targetID to item 1 of argv
1089
+ tell application id "com.googlecode.iterm2"
1090
+ if not running then return "notrunning"
1091
+ repeat with w in windows
1092
+ repeat with t in tabs of w
1093
+ repeat with s in sessions of t
1094
+ if id of s is targetID then
1095
+ copy bounds of w to winBounds
1096
+ set prevDels to AppleScript's text item delimiters
1097
+ set AppleScript's text item delimiters to ", "
1098
+ set out to winBounds as text
1099
+ set AppleScript's text item delimiters to prevDels
1100
+ return out
1101
+ end if
1102
+ end repeat
1103
+ end repeat
1104
+ end repeat
1105
+ end tell
1106
+ return "notfound"
1107
+ end run`;
990
1108
  const SPLIT_SCRIPT = `on run(argv)
991
1109
  set targetID to item 1 of argv
992
1110
  set followCmd to item 2 of argv
@@ -1257,12 +1375,31 @@ async function openPaneForWorker(logDir, config, wid, term) {
1257
1375
  return `pane opened for ${wid}`;
1258
1376
  }
1259
1377
  /**
1378
+ * One `--check` line with the bounds of the window hosting `term`'s iTerm
1379
+ * session — the before/after pair that shows whether a split moved it.
1380
+ * Read-only; never touches the window.
1381
+ */
1382
+ async function windowBoundsLine(term) {
1383
+ const uid = itermUuid(term);
1384
+ if (!uid) return "window bounds: (not in iTerm2)";
1385
+ try {
1386
+ const p = await osascript(WINDOW_BOUNDS_SCRIPT, [uid]);
1387
+ const out = p.stdout.trim();
1388
+ if (out && out !== "notfound" && out !== "notrunning") return `window bounds: ${out}`;
1389
+ return `window bounds: (${out === "notfound" ? "iTerm2 session not found" : out === "notrunning" ? "iTerm2 not running" : (p.stderr.trim() || "no output").slice(0, 120)})`;
1390
+ } catch (e) {
1391
+ return `window bounds: (osascript: ${String(e.message ?? e).slice(0, 120)})`;
1392
+ }
1393
+ }
1394
+ /**
1260
1395
  * Report-only variant used by `pai worker pane <id> --check`: whether a pane
1261
- * runs for the worker, plus the dynamic profile's path, its existence, and
1262
- * the font it contains (or, when missing, would write).
1396
+ * runs for the worker, the bounds of the window hosting the asking session,
1397
+ * plus the dynamic profile's path, its existence, and the font it contains
1398
+ * (or, when missing, would write).
1263
1399
  */
1264
- async function checkPaneForWorker(wid, fontSize) {
1400
+ async function checkPaneForWorker(wid, fontSize, term) {
1265
1401
  const lines = [workerPaneOpen(wid) ? `pane for ${wid} open` : `no pane for ${wid}`];
1402
+ lines.push(await windowBoundsLine(term));
1266
1403
  const path = dynamicProfilePath();
1267
1404
  if (existsSync(path)) {
1268
1405
  let font = "(unreadable)";
@@ -1877,9 +2014,19 @@ function stdinUserMessage(text) {
1877
2014
  }
1878
2015
  });
1879
2016
  }
1880
- /** ISO stamp with seconds, attached to every mirrored event (2g). */
1881
- function isoStamp(d = /* @__PURE__ */ new Date()) {
1882
- return d.toISOString().replace(/\.\d{3}Z$/, "Z");
2017
+ /**
2018
+ * ISO stamp with seconds, attached to every mirrored event (2g): local time
2019
+ * with its offset (`2026-09-17T14:19:23+02:00`), so the viewer can render the
2020
+ * wall clock the operator lives in. `offMin` is east-positive minutes —
2021
+ * injectable so tests do not depend on the machine's zone.
2022
+ */
2023
+ function isoStamp(d = /* @__PURE__ */ new Date(), offMin = -d.getTimezoneOffset()) {
2024
+ const t = new Date(d.getTime() + offMin * 6e4);
2025
+ const sign = offMin < 0 ? "-" : "+";
2026
+ const abs = Math.abs(offMin);
2027
+ const hh = String(Math.floor(abs / 60)).padStart(2, "0");
2028
+ const mm = String(abs % 60).padStart(2, "0");
2029
+ return `${t.toISOString().slice(0, 19)}${sign}${hh}:${mm}`;
1883
2030
  }
1884
2031
  /** Context tokens of an assistant/result usage block (input+cache+output). */
1885
2032
  function usageContextTokens(u) {
@@ -1904,7 +2051,7 @@ async function runWorker(opts) {
1904
2051
  mkdirSync(logDir, { recursive: true });
1905
2052
  const target = resolveTarget(config, logDir, {
1906
2053
  flagProvider: opts.providerFlag,
1907
- role: opts.role
2054
+ className: opts.className
1908
2055
  });
1909
2056
  assertProviderRunnable(target.providerName, target.provider);
1910
2057
  const parsed = parseRunnerArgs(opts.claudeArgs);
@@ -1920,6 +2067,10 @@ async function runWorker(opts) {
1920
2067
  parsed,
1921
2068
  claudeArgs: opts.claudeArgs,
1922
2069
  noPane: opts.noPane ?? false,
2070
+ cwd: opts.cwd,
2071
+ parent: opts.parent,
2072
+ stage: opts.stage,
2073
+ quiet: opts.quiet,
1923
2074
  onWorkerStart: opts.onWorkerStart
1924
2075
  });
1925
2076
  return await executeRun({
@@ -1932,6 +2083,10 @@ async function runWorker(opts) {
1932
2083
  claudeArgs: opts.claudeArgs,
1933
2084
  noPane: opts.noPane ?? false,
1934
2085
  mcpFlag: opts.mcpFlag,
2086
+ cwd: opts.cwd,
2087
+ parent: opts.parent,
2088
+ stage: opts.stage,
2089
+ quiet: opts.quiet,
1935
2090
  onWorkerStart: opts.onWorkerStart,
1936
2091
  reroutes: opts._reroutes ?? 0
1937
2092
  });
@@ -1947,7 +2102,7 @@ async function executeRun(a) {
1947
2102
  if (target.provider.protocol === "openai") proxyUrl = `${await ensureProxyRunning(DEFAULT_PROXY_PORT, logDir)}/${target.providerName}`;
1948
2103
  const env = buildRunEnv(target.provider, headless, proxyUrl);
1949
2104
  const wid = newWorkerId();
1950
- const cwd = process.cwd();
2105
+ const cwd = a.cwd ?? process.cwd();
1951
2106
  const term = process.env.ITERM_SESSION_ID ?? "";
1952
2107
  const session = resolveSession(term);
1953
2108
  const status = {
@@ -1967,7 +2122,11 @@ async function executeRun(a) {
1967
2122
  rc: null,
1968
2123
  secs: null,
1969
2124
  ...session ? { session } : {},
1970
- contextWindow: providerContextWindow(target.provider)
2125
+ contextWindow: providerContextWindow(target.provider),
2126
+ ...a.parent ? {
2127
+ parent: a.parent,
2128
+ stage: a.stage
2129
+ } : {}
1971
2130
  };
1972
2131
  saveStatus(logDir, status);
1973
2132
  a.onWorkerStart?.(wid);
@@ -1986,7 +2145,7 @@ async function executeRun(a) {
1986
2145
  const wanted = [
1987
2146
  ...a.mcpFlag ? [a.mcpFlag] : [],
1988
2147
  ...parsed.mcp,
1989
- ...target.roleMcp ?? []
2148
+ ...target.classMcp ?? []
1990
2149
  ];
1991
2150
  if (wanted.length) mcpArgs = [
1992
2151
  "--strict-mcp-config",
@@ -2154,7 +2313,7 @@ async function executeRun(a) {
2154
2313
  tools: status.tools,
2155
2314
  label
2156
2315
  });
2157
- if (headless) printResult(parsed.outputFormat, resultEvent, rc, logDir, wid, ctx.resultReport);
2316
+ if (headless && !a.quiet) printResult(parsed.outputFormat, resultEvent, rc, logDir, wid, ctx.resultReport);
2158
2317
  const resultText = resultEvent?.result ?? "";
2159
2318
  if (!ok && headless && a.target.via === "auto" && config.routing.retryOnQuota && status.turns <= 1 && status.tools === 0 && isQuotaFailure(resultText)) {
2160
2319
  setCooldown(logDir, target.providerName, config.routing.cooldownMinutes);
@@ -2170,6 +2329,9 @@ async function executeRun(a) {
2170
2329
  label,
2171
2330
  noPane: a.noPane,
2172
2331
  mcpFlag: a.mcpFlag,
2332
+ cwd: a.cwd,
2333
+ parent: a.parent,
2334
+ stage: a.stage,
2173
2335
  claudeArgs: a.claudeArgs,
2174
2336
  onWorkerStart: a.onWorkerStart,
2175
2337
  _reroutes: a.reroutes + 1
@@ -2183,7 +2345,7 @@ async function executeCodexRun(a) {
2183
2345
  if (!parsed.headless || parsed.prompt === null) throw new Error(`provider "${target.providerName}" (engine codex) supports headless runs only: pass the task with -p '<prompt>'`);
2184
2346
  const env = buildCodexEnv(target.provider);
2185
2347
  const wid = newWorkerId();
2186
- const cwd = process.cwd();
2348
+ const cwd = a.cwd ?? process.cwd();
2187
2349
  const term = process.env.ITERM_SESSION_ID ?? "";
2188
2350
  const session = resolveSession(term);
2189
2351
  const status = {
@@ -2203,7 +2365,11 @@ async function executeCodexRun(a) {
2203
2365
  rc: null,
2204
2366
  secs: null,
2205
2367
  ...session ? { session } : {},
2206
- contextWindow: providerContextWindow(target.provider)
2368
+ contextWindow: providerContextWindow(target.provider),
2369
+ ...a.parent ? {
2370
+ parent: a.parent,
2371
+ stage: a.stage
2372
+ } : {}
2207
2373
  };
2208
2374
  saveStatus(logDir, status);
2209
2375
  a.onWorkerStart?.(wid);
@@ -2313,7 +2479,7 @@ async function executeCodexRun(a) {
2313
2479
  tools: status.tools,
2314
2480
  label
2315
2481
  });
2316
- printResult(parsed.outputFormat, resultEvent, rc, logDir, wid, report);
2482
+ if (!a.quiet) printResult(parsed.outputFormat, resultEvent, rc, logDir, wid, report);
2317
2483
  return rc !== 0 ? rc : ok ? 0 : 1;
2318
2484
  }
2319
2485
  function printResult(fmt, resultEvent, rc, logDir, wid, report) {
@@ -2440,6 +2606,392 @@ function resultFromOutput(out) {
2440
2606
  return trimmed.slice(0, 200);
2441
2607
  }
2442
2608
 
2609
+ //#endregion
2610
+ //#region src/workers/chain.ts
2611
+ /**
2612
+ * chain.ts — draft-then-implement chains: `--chain draft,implement[,review]`.
2613
+ *
2614
+ * The chain runs each stage as its own worker (own id, own pane, `parent` set
2615
+ * to the chain id), so `ps` shows the chain as a tree and every stage can be
2616
+ * followed, replayed and said to like any other worker:
2617
+ *
2618
+ * - draft turns the operator's brief into a full spec file under
2619
+ * <logDir>/specs/<chain id>.md (goal, constraints, files likely
2620
+ * touched, acceptance checks, verification commands);
2621
+ * - any other stage (implement, plan, …) runs with that spec as its prompt
2622
+ * and the original brief attached;
2623
+ * - review reads the spec and the working-tree diff and produces the
2624
+ * structured report.
2625
+ *
2626
+ * A stage that fails (or a draft that produces no spec file) stops the chain;
2627
+ * the caller then writes the spec itself and re-runs without the draft stage.
2628
+ */
2629
+ /** Where a chain's spec file lives: <logDir>/specs/<chain id>.md. */
2630
+ function specPathFor(logDir, chainId) {
2631
+ return join(logDir, "specs", `${chainId}.md`);
2632
+ }
2633
+ /**
2634
+ * Replace the caller's -p value with `prompt`, dropping every existing
2635
+ * -p/--print pair first (two -p flags on one claude command line are an error,
2636
+ * so the chain must never leave the brief in place when swapping prompts).
2637
+ */
2638
+ function swapPromptArg(claudeArgs, prompt) {
2639
+ const out = [];
2640
+ for (let i = 0; i < claudeArgs.length; i++) {
2641
+ const a = claudeArgs[i];
2642
+ if (a === "-p" || a === "--print") {
2643
+ if (i + 1 < claudeArgs.length && !claudeArgs[i + 1].startsWith("-")) i += 1;
2644
+ continue;
2645
+ }
2646
+ out.push(a);
2647
+ }
2648
+ out.push("-p", prompt);
2649
+ return out;
2650
+ }
2651
+ function draftPrompt(brief, specPath) {
2652
+ return [
2653
+ "You are the DRAFT stage of a worker chain. Turn the operator's brief below into a full implementation spec and write it to",
2654
+ specPath,
2655
+ "with the Write tool.",
2656
+ "",
2657
+ "Use exactly these five sections as Markdown headings:",
2658
+ "# Goal",
2659
+ "# Constraints",
2660
+ "# Files likely touched",
2661
+ "# Acceptance checks",
2662
+ "# Verification commands",
2663
+ "",
2664
+ "Read the repository first (Glob/Grep/Read) so the spec names real files and real commands. Do not implement anything.",
2665
+ "",
2666
+ "## Operator brief",
2667
+ "",
2668
+ brief
2669
+ ].join("\n");
2670
+ }
2671
+ function implementPrompt(brief, specPath, spec) {
2672
+ return [
2673
+ ...spec ? [
2674
+ "You are the IMPLEMENT stage of a worker chain. The draft stage wrote the spec below (also at " + specPath + "). Implement it exactly, then run the spec's verification commands before finishing.",
2675
+ "",
2676
+ "## Spec",
2677
+ "",
2678
+ spec
2679
+ ] : ["Implement the operator's brief below."],
2680
+ "",
2681
+ "## Operator brief",
2682
+ "",
2683
+ brief
2684
+ ].join("\n");
2685
+ }
2686
+ function reviewPrompt(brief, specPath, spec) {
2687
+ return [
2688
+ "You are the REVIEW stage of a worker chain. The implement stage just ran. Read the repository's diff (run `git diff` and `git status`; use `git diff --stat` for the overview) and check it against the spec's acceptance checks and verification commands — run the checks when they are cheap. Do not fix anything you find; report it.",
2689
+ ...spec ? [
2690
+ "",
2691
+ "## Spec (also at " + specPath + ")",
2692
+ "",
2693
+ spec
2694
+ ] : [],
2695
+ "",
2696
+ "## Operator brief",
2697
+ "",
2698
+ brief
2699
+ ].join("\n");
2700
+ }
2701
+ /** Run a chain of stages; returns the exit code of the first failed stage, 0 when all pass. */
2702
+ async function runChain(opts, deps = {}) {
2703
+ const stages = opts.stages.map((s) => s.trim()).filter(Boolean);
2704
+ if (!stages.length) throw new Error("--chain needs at least one class, e.g. --chain draft,implement");
2705
+ const runStage = deps.runStage ?? runWorker;
2706
+ const logDir = deps.logDir ?? workersLogDir(readWorkersSection().workers);
2707
+ const chainId = newWorkerId();
2708
+ const specPath = specPathFor(logDir, chainId);
2709
+ mkdirSync(join(logDir, "specs"), { recursive: true });
2710
+ const baseLabel = opts.label ?? shortText(opts.brief, 40);
2711
+ appendLedger(ledgerPath(logDir), "WORKER-CHAIN", {
2712
+ chain: chainId,
2713
+ stages: stages.join(","),
2714
+ label: baseLabel
2715
+ });
2716
+ opts.onChainStart?.(chainId);
2717
+ let spec = null;
2718
+ for (let i = 0; i < stages.length; i++) {
2719
+ const stage = stages[i];
2720
+ if (stage === "draft") spec = null;
2721
+ else if (spec === null && existsSync(specPath)) spec = readFileSync(specPath, "utf8");
2722
+ const prompt = stage === "draft" ? draftPrompt(opts.brief, specPath) : stage === "review" ? reviewPrompt(opts.brief, spec ? specPath : null, spec) : implementPrompt(opts.brief, spec ? specPath : null, spec);
2723
+ process.stderr.write(`chain ${chainId}: stage ${i + 1}/${stages.length} ${stage} (spec: ${specPath})\n`);
2724
+ const rc = await runStage({
2725
+ className: opts.className ?? stage,
2726
+ providerFlag: opts.providerFlag,
2727
+ modelFlag: opts.modelFlag,
2728
+ label: `${baseLabel} · ${stage}`,
2729
+ noPane: opts.noPane,
2730
+ mcpFlag: opts.mcpFlag,
2731
+ claudeArgs: swapPromptArg(opts.claudeArgs, prompt),
2732
+ cwd: opts.cwd,
2733
+ parent: chainId,
2734
+ stage,
2735
+ quiet: opts.quiet
2736
+ });
2737
+ if (stage === "draft") {
2738
+ if (!existsSync(specPath)) {
2739
+ process.stderr.write(`chain ${chainId}: draft stage produced no spec at ${specPath} — stopping. Write the spec yourself and re-run without the draft stage.\n`);
2740
+ appendLedger(ledgerPath(logDir), "WORKER-CHAIN-END", {
2741
+ chain: chainId,
2742
+ rc: rc !== 0 ? rc : 1,
2743
+ failed: "draft"
2744
+ });
2745
+ return rc !== 0 ? rc : 1;
2746
+ }
2747
+ spec = readFileSync(specPath, "utf8");
2748
+ }
2749
+ if (rc !== 0) {
2750
+ appendLedger(ledgerPath(logDir), "WORKER-CHAIN-END", {
2751
+ chain: chainId,
2752
+ rc,
2753
+ failed: stage
2754
+ });
2755
+ return rc;
2756
+ }
2757
+ }
2758
+ appendLedger(ledgerPath(logDir), "WORKER-CHAIN-END", {
2759
+ chain: chainId,
2760
+ rc: 0
2761
+ });
2762
+ return 0;
2763
+ }
2764
+
2765
+ //#endregion
2766
+ //#region src/workers/chatui.ts
2767
+ /**
2768
+ * chatui.ts — the chat line of a `follow` pane.
2769
+ *
2770
+ * A follow pane with a target behaves like a small chat, Claude Code style:
2771
+ * the transcript lives in a terminal scroll region that ends two rows above
2772
+ * the pane's bottom; the last two rows are fixed — the prompt row (`› `,
2773
+ * readline line editing) and the ticker row. A transcript line is inserted
2774
+ * above the fixed rows with a save-cursor / scroll-region / restore-cursor
2775
+ * write that never touches them; the scroll region makes the transcript roll
2776
+ * inside itself. Everything here builds strings (or parses one line), so the
2777
+ * tests assert exact byte sequences — no terminal needed.
2778
+ */
2779
+ /** The prompt marker of the chat row. */
2780
+ const CHAT_PROMPT = "› ";
2781
+ /** Dim hint shown once behind the cursor until the first line is typed. */
2782
+ const CHAT_HINT = "type here and press Enter · /help for commands";
2783
+ /** What `/help` prints (one command per line, dim). */
2784
+ const CHAT_HELP = [
2785
+ "/quit close this pane",
2786
+ "/resume <text> continue the finished worker with <text>",
2787
+ "/status one-line worker status",
2788
+ "anything else is sent to the worker — said while it runs, resumed after"
2789
+ ];
2790
+ /** Index just past the escape starting at `i` (CSI, OSC or a two-char one). */
2791
+ function endOfEscape(s, i) {
2792
+ const n = s[i + 1];
2793
+ if (n === "[") {
2794
+ let j = i + 2;
2795
+ while (j < s.length && !(s[j] >= "@" && s[j] <= "~")) j++;
2796
+ return Math.min(s.length, j + 1);
2797
+ }
2798
+ if (n === "]") {
2799
+ let j = i + 2;
2800
+ while (j < s.length && s[j] !== "\x07") j++;
2801
+ return Math.min(s.length, j + 1);
2802
+ }
2803
+ return i + 2;
2804
+ }
2805
+ /** Printable columns of `s` — ANSI escape sequences measure zero. */
2806
+ function visibleWidth(s) {
2807
+ let w = 0;
2808
+ let i = 0;
2809
+ while (i < s.length) {
2810
+ if (s[i] === "\x1B") {
2811
+ i = endOfEscape(s, i);
2812
+ continue;
2813
+ }
2814
+ w += 1;
2815
+ i += 1;
2816
+ }
2817
+ return w;
2818
+ }
2819
+ /**
2820
+ * The SGR sequences in effect at `upto`: everything opened since the last
2821
+ * reset, in order. Anything that is not an SGR escape is ignored (it does
2822
+ * not change colour state).
2823
+ */
2824
+ function sgrStateAt(text, upto) {
2825
+ const open = [];
2826
+ let i = 0;
2827
+ while (i < Math.min(upto, text.length)) {
2828
+ if (text[i] === "\x1B") {
2829
+ const end = endOfEscape(text, i);
2830
+ const esc = text.slice(i, end);
2831
+ if (/^\x1b\[[0-9;]*m$/.test(esc)) {
2832
+ const params = esc.slice(2, -1);
2833
+ if (params === "" || params.split(";").includes("0")) open.length = 0;
2834
+ if (!(params === "" || params === "0")) open.push(esc);
2835
+ }
2836
+ i = end;
2837
+ continue;
2838
+ }
2839
+ i += 1;
2840
+ }
2841
+ return open;
2842
+ }
2843
+ /**
2844
+ * Wrap one rendered row to `width` printable columns. Breaks on whitespace
2845
+ * where possible, hard-wraps words longer than the width, never splits an
2846
+ * ANSI escape, and re-opens the colours it wraps inside of, so a diff row
2847
+ * keeps its `-`/`+` colour on every continuation row.
2848
+ */
2849
+ function wrapText(text, width) {
2850
+ if (width < 1 || visibleWidth(text) <= width) return [text];
2851
+ const chars = [];
2852
+ for (let i = 0; i < text.length; i++) {
2853
+ if (text[i] === "\x1B") {
2854
+ i = endOfEscape(text, i) - 1;
2855
+ continue;
2856
+ }
2857
+ chars.push(i);
2858
+ }
2859
+ const words = [];
2860
+ {
2861
+ let s = -1;
2862
+ let w = 0;
2863
+ for (let k = 0; k <= chars.length; k++) if ((k === chars.length ? " " : text[chars[k]]) === " ") {
2864
+ if (s >= 0) {
2865
+ words.push({
2866
+ s,
2867
+ e: k - 1,
2868
+ w
2869
+ });
2870
+ s = -1;
2871
+ w = 0;
2872
+ }
2873
+ } else {
2874
+ if (s < 0) s = k;
2875
+ w++;
2876
+ }
2877
+ }
2878
+ if (words.length && words[0].s > 0) words[0] = {
2879
+ s: 0,
2880
+ e: words[0].e,
2881
+ w: words[0].w + words[0].s
2882
+ };
2883
+ const spans = [];
2884
+ let cs = -1;
2885
+ let ce = -1;
2886
+ for (const word of words) {
2887
+ if (word.w > width) {
2888
+ const room = cs < 0 ? 0 : width - (ce - cs + 1);
2889
+ const take = room > 0 ? ce + room - word.s + 1 : 0;
2890
+ if (take > 0) spans.push([cs, ce + room]);
2891
+ else if (cs >= 0) spans.push([cs, ce]);
2892
+ let pos = word.s + Math.max(0, take);
2893
+ let remaining = word.w - Math.max(0, take);
2894
+ while (remaining > width) {
2895
+ spans.push([pos, pos + width - 1]);
2896
+ pos += width;
2897
+ remaining -= width;
2898
+ }
2899
+ cs = pos;
2900
+ ce = pos + remaining - 1;
2901
+ continue;
2902
+ }
2903
+ if (cs < 0) {
2904
+ cs = word.s;
2905
+ ce = word.e;
2906
+ continue;
2907
+ }
2908
+ if (word.e - cs + 1 <= width) {
2909
+ ce = word.e;
2910
+ continue;
2911
+ }
2912
+ spans.push([cs, ce]);
2913
+ cs = word.s;
2914
+ ce = word.e;
2915
+ }
2916
+ if (cs >= 0) spans.push([cs, ce]);
2917
+ const out = [];
2918
+ for (const [a, b] of spans) {
2919
+ const from = chars[a];
2920
+ let end = chars[b] + 1;
2921
+ while (end < text.length && text[end] === "\x1B") end = endOfEscape(text, end);
2922
+ let piece = text.slice(from, end);
2923
+ const reopen = sgrStateAt(text, from);
2924
+ if (reopen.length) piece = reopen.join("") + piece;
2925
+ if (sgrStateAt(text, end).length) piece += "\x1B[0m";
2926
+ out.push(piece);
2927
+ }
2928
+ return out.length ? out : [""];
2929
+ }
2930
+ /** Restrict scrolling to the transcript region (rows 1 … rows-2). */
2931
+ function chatScrollRegion(rows) {
2932
+ return `\x1b[1;${Math.max(1, rows - 2)}r`;
2933
+ }
2934
+ /**
2935
+ * Enter the chat layout: clear the pane, set the scroll region, park the
2936
+ * cursor at column 1 of the prompt row (rows-1). The ticker owns row `rows`.
2937
+ */
2938
+ function chatEnter(rows) {
2939
+ return "\x1B[2J" + chatScrollRegion(rows) + `\x1b[${Math.max(1, rows - 1)};1H`;
2940
+ }
2941
+ /** Leave it: reset the scroll region, show the cursor, drop to the last row. */
2942
+ function chatLeave(rows) {
2943
+ return `[?25h\x1b[${Math.max(1, rows)};1H`;
2944
+ }
2945
+ /** Redraw the ticker on its own row without moving the user's cursor. */
2946
+ function chatTickerRow(text, rows) {
2947
+ return `7\x1b[${Math.max(1, rows)};1H\x1b[K` + text + "\x1B8";
2948
+ }
2949
+ /** Move to column 1 of the prompt row and draw prompt (and hint). */
2950
+ function chatPromptRow(rows, prompt = CHAT_PROMPT, hint) {
2951
+ return `\x1b[${Math.max(1, rows - 1)};1H` + prompt + (hint ?? "");
2952
+ }
2953
+ /**
2954
+ * Insert one transcript row above the fixed prompt/ticker rows. While the
2955
+ * region is still filling (`fill < regionRows`) the row is placed top-down;
2956
+ * once full, the cursor moves to the region's bottom row and a newline
2957
+ * scrolls the region up by one — the two fixed rows are never touched. The
2958
+ * user's cursor is saved before and restored after, so readline keeps its
2959
+ * position on the prompt row.
2960
+ */
2961
+ function chatInsertLine(line, fill, regionRows) {
2962
+ return {
2963
+ seq: "\x1B7" + (fill < regionRows ? `\x1b[${fill + 1};1H${line}\x1b[K` : `\x1b[${regionRows};1H\n${line}\x1b[K`) + "\x1B8",
2964
+ fill: Math.min(fill + 1, regionRows)
2965
+ };
2966
+ }
2967
+ /**
2968
+ * One submitted prompt line → what to do with it. `/help`, `/quit`,
2969
+ * `/status` and `/resume <text>` are commands (a bare `/resume` comes back
2970
+ * with empty text so the caller can print its usage); anything else,
2971
+ * including any other `/word`, is a message for the worker.
2972
+ */
2973
+ function parseChatLine(raw) {
2974
+ const text = raw.trim();
2975
+ if (text === "/help") return { kind: "help" };
2976
+ if (text === "/quit") return { kind: "quit" };
2977
+ if (text === "/status") return { kind: "status" };
2978
+ if (text.startsWith("/resume")) return {
2979
+ kind: "resume",
2980
+ text: text.slice(7).trim()
2981
+ };
2982
+ return {
2983
+ kind: "message",
2984
+ text
2985
+ };
2986
+ }
2987
+ /**
2988
+ * The auto-exit countdown must not fire while the prompt holds unsent text:
2989
+ * true while it does. null/undefined (no prompt wired) never holds.
2990
+ */
2991
+ function holdAutoExit(promptText) {
2992
+ return typeof promptText === "string" && promptText.trim() !== "";
2993
+ }
2994
+
2443
2995
  //#endregion
2444
2996
  //#region src/workers/render.ts
2445
2997
  /**
@@ -2493,21 +3045,82 @@ function unifiedDiffLines(oldStr, newStr) {
2493
3045
  return out;
2494
3046
  }
2495
3047
  /**
3048
+ * `HH:MM:SS` at an offset east of UTC in minutes (Date.getTimezoneOffset()
3049
+ * negated), from any ISO stamp the runner wrote — `Z` or a local `+HH:MM`.
3050
+ * null when the stamp cannot be parsed. Offsets make this testable without
3051
+ * depending on the machine's zone; the default is this machine's.
3052
+ */
3053
+ function clockOf(ts, offMin = -(/* @__PURE__ */ new Date()).getTimezoneOffset()) {
3054
+ const t = Date.parse(ts);
3055
+ if (Number.isNaN(t)) return null;
3056
+ return new Date(t + offMin * 6e4).toISOString().slice(11, 19);
3057
+ }
3058
+ /** `YYYY-MM-DD` at the same offset — the local day a date separator shows. */
3059
+ function dayOf(ts, offMin = -(/* @__PURE__ */ new Date()).getTimezoneOffset()) {
3060
+ const t = Date.parse(ts);
3061
+ if (Number.isNaN(t)) return null;
3062
+ return new Date(t + offMin * 6e4).toISOString().slice(0, 10);
3063
+ }
3064
+ /**
2496
3065
  * The transcript gutter (2g): `HH:MM:SS │ ` from the event's `_ts`, dim, with
2497
3066
  * the worker tag in front when several run at once. Continuation lines get
2498
- * blanks of the same width so wrapped text stays aligned. null when the event
2499
- * carries no stamp (logs from before 2g render with the plain prefix).
3067
+ * blanks of the same width so wrapped text stays aligned; when the viewer
3068
+ * wraps lines itself, continuation rows carry the `│` bar instead (barCont)
3069
+ * so the bar runs unbroken down the pane. null when the event carries no
3070
+ * stamp (logs from before 2g render with the plain prefix). The time is the
3071
+ * stamp's wall clock at `offMin` — the default renders local time, whatever
3072
+ * zone stamped the log (old logs were stamped in UTC).
2500
3073
  */
2501
- function gutterFor(c, e, tag) {
3074
+ function gutterFor(c, e, tag, offMin) {
2502
3075
  if (!e._ts) return null;
2503
- const time = e._ts.length >= 19 ? e._ts.slice(11, 19) : e._ts;
3076
+ const time = clockOf(e._ts, offMin) ?? (e._ts.length >= 19 ? e._ts.slice(11, 19) : e._ts);
2504
3077
  const head = tag ? `${tag} ${time}` : time;
2505
3078
  const width = head.length + 3;
2506
3079
  return {
2507
3080
  first: c("dim", `${head} │ `),
2508
- cont: " ".repeat(width)
3081
+ cont: " ".repeat(width),
3082
+ barCont: c("dim", `${" ".repeat(head.length)} │ `),
3083
+ width
2509
3084
  };
2510
3085
  }
3086
+ /**
3087
+ * The ticker's tool part: `$ <command>` for Bash (first 60 chars), the file
3088
+ * basename for the file tools, the bare name for everything else.
3089
+ */
3090
+ function tickerTool(name, inp) {
3091
+ const i = typeof inp === "object" && inp !== null ? inp : {};
3092
+ const get = (k) => typeof i[k] === "string" ? i[k] : "";
3093
+ if (name === "Bash") return `$ ${shortText(get("command").replace(/\s+/g, " ").trim(), 60)}`;
3094
+ if (name === "Read" || name === "Edit" || name === "Write" || name === "MultiEdit") return (get("file_path").split("/").pop() ?? "") || name;
3095
+ return name;
3096
+ }
3097
+ /**
3098
+ * The liveness line: `⋯ 12s · run tests before the fix · $ bun run test` —
3099
+ * seconds since the last *rendered* event, the worker's last stated intent
3100
+ * (its last assistant text, ≤60 chars) and the tool it is currently running.
3101
+ * Parts that are empty drop out.
3102
+ */
3103
+ function tickerText(secs, intent, tool, meter) {
3104
+ const head = `⋯ ${secs}s`;
3105
+ const tail = [intent, tool].map((p) => p.trim()).filter(Boolean).join(" · ");
3106
+ const line = tail ? `${head} · ${tail}` : head;
3107
+ return meter ? `${line} · ${meter}` : line;
3108
+ }
3109
+ /**
3110
+ * One blank line between turns, none inside one: a blank goes before an
3111
+ * assistant message that follows a tool result or an operator message (the
3112
+ * worker starting to speak again after its tools were answered / it was told
3113
+ * something), not between the text, tool calls and results of one turn.
3114
+ */
3115
+ function blankBetween(prev, e) {
3116
+ if (!prev) return false;
3117
+ if (e.type !== "assistant") return false;
3118
+ return prev.type === "user" || prev.type === "operator";
3119
+ }
3120
+ /** The worker's last stated intent: the first line of its last text, ≤60. */
3121
+ function intentOf(text) {
3122
+ return shortText((text.trim().split("\n").find((l) => l.trim()) ?? "").trim().replace(/\s+/g, " "), 60);
3123
+ }
2511
3124
  /** Compact token count: 84k, 200k, 900. */
2512
3125
  function fmtK(n) {
2513
3126
  return n >= 1e3 ? `${Math.round(n / 1e3)}k` : String(n);
@@ -2582,7 +3195,18 @@ function headerLine(c, s) {
2582
3195
  const sep = bits ? ` ${bits}` : "";
2583
3196
  return c("bold", `━━ ${s.id}${sep} ${s.label} (${basename(s.cwd)})`);
2584
3197
  }
2585
- /** The ps table (RUNNING + FINISHED last 8). */
3198
+ /** The chain label behind a stage label: strip the trailing " · <stage>". */
3199
+ function chainLabelOf(stages) {
3200
+ const first = stages[0];
3201
+ if (!first) return "";
3202
+ const suffix = first.stage ? ` · ${first.stage}` : "";
3203
+ return first.label.endsWith(suffix) && suffix ? first.label.slice(0, first.label.length - suffix.length) : first.label;
3204
+ }
3205
+ /**
3206
+ * The ps table (RUNNING + FINISHED last 8). Chain stages carry `parent` and
3207
+ * render as a tree under one `chain <id>` header; plain workers render as
3208
+ * before.
3209
+ */
2586
3210
  function renderTable(c, statuses, scopeLabel, now = /* @__PURE__ */ new Date()) {
2587
3211
  const running = [];
2588
3212
  const done = [];
@@ -2591,21 +3215,55 @@ function renderTable(c, statuses, scopeLabel, now = /* @__PURE__ */ new Date())
2591
3215
  if (s.state === "running") s.state = "lost";
2592
3216
  done.push(s);
2593
3217
  }
3218
+ const group = (list) => {
3219
+ const out = [];
3220
+ for (const s of list) out.push({
3221
+ s,
3222
+ chain: s.parent ?? null
3223
+ });
3224
+ return out;
3225
+ };
3226
+ const treeLine = (line, chain, last) => {
3227
+ if (chain === null) return line;
3228
+ const mark = last ? "└" : "├";
3229
+ const bar = last ? " " : "│";
3230
+ return line.startsWith(" ") ? ` ${bar} ${line.slice(6)}` : ` ${mark} ${line.slice(2)}`;
3231
+ };
2594
3232
  const lines = [c("bold", `Workers ${`${String(now.getHours()).padStart(2, "0")}:${String(now.getMinutes()).padStart(2, "0")}:${String(now.getSeconds()).padStart(2, "0")}`}`), ""];
2595
3233
  lines.push(c("bold", `RUNNING (${running.length})`));
2596
3234
  if (!running.length) lines.push(" none");
2597
- for (const s of running) {
3235
+ const runEntries = group(running);
3236
+ for (let i = 0; i < runEntries.length; i++) {
3237
+ const { s, chain } = runEntries[i];
3238
+ if (chain) {
3239
+ const prev = runEntries[i - 1];
3240
+ if (!prev || prev.chain !== chain) {
3241
+ const stages = runEntries.filter((e) => e.chain === chain).map((e) => e.s);
3242
+ lines.push(` ${c("bold", `chain ${chain}`)} ${chainLabelOf(stages)}`);
3243
+ }
3244
+ }
3245
+ const last = !chain || !runEntries[i + 1] || runEntries[i + 1].chain !== chain;
2598
3246
  const meter = contextMeter(c, s, 60);
2599
- lines.push(` ${c("cyan", s.id)} [${s.provider}] ${ageOf(s.started, now).padStart(4)} old turns ${String(s.turns).padStart(2)} tools ${String(s.tools).padStart(2)} ${basename(s.cwd)}${meter ? " " + meter : ""}`);
2600
- lines.push(` task: ${s.label}`);
2601
- lines.push(` now: ${c("yellow", s.last)} (${ageOf(s.updated, now)} ago)`);
3247
+ lines.push(treeLine(` ${c("cyan", s.id)} [${s.provider}] ${ageOf(s.started, now).padStart(4)} old turns ${String(s.turns).padStart(2)} tools ${String(s.tools).padStart(2)} ${basename(s.cwd)}${meter ? " " + meter : ""}`, chain, last));
3248
+ lines.push(treeLine(` task: ${s.label}`, chain, last));
3249
+ lines.push(treeLine(` now: ${c("yellow", s.last)} (${ageOf(s.updated, now)} ago)`, chain, last));
2602
3250
  }
2603
3251
  lines.push("");
2604
3252
  lines.push(c("bold", "FINISHED (last 8)"));
2605
- for (const s of done.slice(-8)) {
3253
+ const doneEntries = group(done.slice(-8));
3254
+ for (let i = 0; i < doneEntries.length; i++) {
3255
+ const { s, chain } = doneEntries[i];
3256
+ if (chain) {
3257
+ const prev = doneEntries[i - 1];
3258
+ if (!prev || prev.chain !== chain) {
3259
+ const stages = doneEntries.filter((e) => e.chain === chain).map((e) => e.s);
3260
+ lines.push(` ${c("bold", `chain ${chain}`)} ${chainLabelOf(stages)}`);
3261
+ }
3262
+ }
3263
+ const last = !chain || !doneEntries[i + 1] || doneEntries[i + 1].chain !== chain;
2606
3264
  const col = s.state === "done" ? "green" : "red";
2607
3265
  const tag = sessionTag(s);
2608
- lines.push(` ${s.id} [${s.provider}]${tag ? " " + tag : ""} ${c(col, s.state.padEnd(6))} rc=${s.rc} ${String(s.secs ?? "?").padStart(4)}s turns ${String(s.turns).padStart(2)} tools ${String(s.tools).padStart(2)} ${basename(s.cwd)} ${s.label}`);
3266
+ lines.push(treeLine(` ${s.id} [${s.provider}]${tag ? " " + tag : ""} ${c(col, s.state.padEnd(6))} rc=${s.rc} ${String(s.secs ?? "?").padStart(4)}s turns ${String(s.turns).padStart(2)} tools ${String(s.tools).padStart(2)} ${basename(s.cwd)} ${s.label}`, chain, last));
2609
3267
  }
2610
3268
  lines.push("");
2611
3269
  lines.push(c("dim", "worker follow live transcript of running workers"));
@@ -2642,19 +3300,101 @@ function renderStatusLine(mine, now = /* @__PURE__ */ new Date(), c = makeColor(
2642
3300
  * Outside iTerm, everything degrades to "all workers" — the Python behaviour.
2643
3301
  *
2644
3302
  * Transcripts render with a `HH:MM:SS │ ` gutter (2g): dim, taken from the
2645
- * `_ts` stamp on every mirrored event, the worker tag in front when several
2646
- * run at once, a date separator when the day changes, and on a TTY — a
2647
- * liveness line (`⋯ 12s since last event · Bash: npm test · ctx 84k/200k (42%)`)
2648
- * that is rewritten in place between events. Following one worker also wires
2649
- * this pane's stdin: every typed line is said to the worker while it runs and
2650
- * resumes it (same Claude session) once it has finished.
3303
+ * `_ts` stamp on every mirrored event (local wall clock stamps carry a local
3304
+ * offset and old UTC stamps are converted), the worker tag in front when
3305
+ * several run at once, a date separator when the day changes, and on a TTY —
3306
+ * a liveness line (`⋯ 12s · run tests before the fix · $ bun run test`) that
3307
+ * is rewritten in place between events. Attaching to a worker that is already
3308
+ * running first replays its last events (backfill), then continues live.
3309
+ *
3310
+ * Following one worker turns the pane into a small chat (see chatui.ts): the
3311
+ * transcript scrolls in a region that ends two rows above the bottom, the
3312
+ * prompt row (`› `, readline editing) and the ticker row stay fixed, and every
3313
+ * submitted line is said to the worker while it runs and resumes it (same
3314
+ * Claude session) once it has finished. Lines the pane wraps itself keep the
3315
+ * `│` bar on continuation rows, so no content ever lands left of the bar.
3316
+ * Non-TTY output keeps the plain scrolling behaviour.
2651
3317
  */
3318
+ /** How many existing events a fresh follow pane replays before going live. */
3319
+ const BACKFILL_EVENTS = 200;
2652
3320
  function psOutput(logDir, showAll, env = process.env, color = process.stdout.isTTY === true) {
2653
3321
  const c = makeColor(color);
2654
3322
  const term = env.ITERM_SESSION_ID ?? "";
2655
3323
  const statuses = loadStatuses(logDir);
2656
3324
  return renderTable(c, showAll || !term ? statuses : statuses.filter((s) => workerInScope(s, term)), showAll || !term ? "scope: all workers" : resolveSession(term) ? `scope: session ${resolveSession(term).name}` : `scope: tab ${currentTabKey(env)} (this iTerm tab)`);
2657
3325
  }
3326
+ function initialFollowState(intent = "waiting for first event") {
3327
+ return {
3328
+ lastDay: "",
3329
+ tools: {},
3330
+ intent,
3331
+ tool: "",
3332
+ prev: null
3333
+ };
3334
+ }
3335
+ /**
3336
+ * Gutter the rendered body of one event, wrapping when the pane width is
3337
+ * known. Unwrapped (null `wrapWidth`, e.g. piped output): the first row gets
3338
+ * the stamped gutter, later rows of the event blanks — exactly the pre-chat
3339
+ * rendering. Wrapped: every row is folded at `wrapWidth` columns and each
3340
+ * continuation row carries the blank gutter with the `│` bar, so the bar runs
3341
+ * unbroken down the pane and no content ever lands left of it.
3342
+ */
3343
+ function gutterBody(body, gutter, wrapWidth) {
3344
+ if (!gutter) return body;
3345
+ if (wrapWidth === null || wrapWidth <= gutter.width) return body.map((ln, i) => (i === 0 ? gutter.first : gutter.cont) + ln);
3346
+ const out = [];
3347
+ for (const ln of body) for (const piece of wrapText(ln, wrapWidth - gutter.width)) out.push((out.length === 0 ? gutter.first : gutter.barCont) + piece);
3348
+ return out;
3349
+ }
3350
+ /**
3351
+ * Render one event and advance the follow state. Everything the viewer shows
3352
+ * between events of one worker comes from here: replay, the backfill on
3353
+ * attach and the live tail all use it, so they space identically — events
3354
+ * back to back, one blank line between turns. `activity` is false for events
3355
+ * that render nothing (stream noise, empty tool results): they leave the
3356
+ * ticker's "since last event" clock running. `wrapWidth` (the pane's column
3357
+ * count, re-read on resize) makes the viewer wrap rows itself; null keeps
3358
+ * the terminal's own wrapping.
3359
+ */
3360
+ function applyEvent(c, s, e, cwd, tag, offMin, wrapWidth) {
3361
+ const tools = { ...s.tools };
3362
+ if (e.type === "assistant") {
3363
+ for (const b of e.message?.content ?? []) if (b.type === "tool_use" && b.id) tools[b.id] = b.name ?? "?";
3364
+ }
3365
+ const day = typeof e._ts === "string" ? dayOf(e._ts, offMin) ?? rawDay(e._ts) : "";
3366
+ const state = {
3367
+ lastDay: day && day !== s.lastDay ? day : s.lastDay,
3368
+ tools,
3369
+ intent: s.intent,
3370
+ tool: s.tool,
3371
+ prev: e
3372
+ };
3373
+ if (e.type === "assistant") {
3374
+ for (const b of e.message?.content ?? []) if (b.type === "text" && (b.text ?? "").trim()) state.intent = intentOf(b.text ?? "");
3375
+ else if (b.type === "tool_use") state.tool = tickerTool(b.name ?? "?", b.input);
3376
+ }
3377
+ const gutter = gutterFor(c, e, tag, offMin);
3378
+ const body = gutterBody(renderEvent(c, "", e, cwd, tools), gutter, wrapWidth ?? null);
3379
+ const lines = blankBetween(s.prev, e) ? ["", ...body] : body;
3380
+ return {
3381
+ lines,
3382
+ day: day && day !== s.lastDay ? day : null,
3383
+ activity: lines.some((ln) => ln !== ""),
3384
+ state
3385
+ };
3386
+ }
3387
+ /** `YYYY-MM-DD` straight out of an unparsable stamp, "" when it has none. */
3388
+ function rawDay(ts) {
3389
+ return /^\d{4}-\d{2}-\d{2}/.test(ts) ? ts.slice(0, 10) : "";
3390
+ }
3391
+ /**
3392
+ * The events a fresh follow replays before going live: the last `cap`
3393
+ * non-empty log lines, oldest first.
3394
+ */
3395
+ function backfillLines(raw, cap = BACKFILL_EVENTS) {
3396
+ return raw.split("\n").filter((l) => l.trim()).slice(-cap);
3397
+ }
2658
3398
  /** The rendered transcript of one worker, from the start, as one string. */
2659
3399
  function replayOutput(logDir, wid, color = process.stdout.isTTY === true, tailLines) {
2660
3400
  const path = eventsPath(logDir, wid);
@@ -2666,11 +3406,12 @@ function replayOutput(logDir, wid, color = process.stdout.isTTY === true, tailLi
2666
3406
  cwd: "",
2667
3407
  provider: ""
2668
3408
  };
2669
- const tools = {};
3409
+ const wrapWidth = typeof process.stdout.columns === "number" ? process.stdout.columns : null;
2670
3410
  const out = [headerLine(c, st)];
2671
- const lines = readFileSync(path, "utf8").split("\n");
2672
- let lastDay = "";
2673
- for (const line of tailLines !== void 0 ? lines.slice(-tailLines) : lines) {
3411
+ const raw = readFileSync(path, "utf8");
3412
+ const lines = tailLines !== void 0 ? raw.split("\n").slice(-tailLines) : raw.split("\n");
3413
+ let state = initialFollowState("");
3414
+ for (const line of lines) {
2674
3415
  if (!line.trim()) continue;
2675
3416
  let e;
2676
3417
  try {
@@ -2678,20 +3419,13 @@ function replayOutput(logDir, wid, color = process.stdout.isTTY === true, tailLi
2678
3419
  } catch {
2679
3420
  continue;
2680
3421
  }
2681
- if (typeof e._ts === "string" && e._ts.slice(0, 10) !== lastDay) {
2682
- lastDay = e._ts.slice(0, 10);
2683
- out.push(c("dim", `── ${lastDay} ──`));
2684
- }
2685
- const g = gutterFor(c, e);
2686
- collectTools(e, tools);
2687
- out.push(...renderEvent(c, g ? "" : " ", e, st.cwd ?? "", tools, g));
3422
+ const step = applyEvent(c, state, e, st.cwd ?? "", void 0, void 0, wrapWidth);
3423
+ if (step.day) out.push(c("dim", `── ${step.day} ──`));
3424
+ out.push(...step.lines);
3425
+ state = step.state;
2688
3426
  }
2689
3427
  return out.join("\n");
2690
3428
  }
2691
- function collectTools(e, tools) {
2692
- if (e.type !== "assistant") return;
2693
- for (const b of e.message?.content ?? []) if (b.type === "tool_use" && b.id) tools[b.id] = b.name ?? "?";
2694
- }
2695
3429
  /**
2696
3430
  * The follow exit decision for one worker: it is over once its result event
2697
3431
  * was rendered, or once its status left "running" while its pid is gone — a
@@ -2702,110 +3436,307 @@ function workerEnded(resultRendered, state, pidAlive) {
2702
3436
  return resultRendered || state !== void 0 && state !== "running" && !pidAlive;
2703
3437
  }
2704
3438
  /**
3439
+ * One typed stdin line → say (worker running) or resume (worker finished):
3440
+ * the operator channel of a `follow <id>` pane. Trimmed; empty lines and a
3441
+ * missing target are ignored.
3442
+ */
3443
+ function makeOperatorInput(d) {
3444
+ return (raw) => {
3445
+ const text = raw.trim();
3446
+ if (!text) return;
3447
+ const id = d.target();
3448
+ if (id === null) return;
3449
+ d.say(id, text).then(() => d.sent ? d.sent(id) : d.note(d.paint("dim", `» sent to ${id}`)), (e) => {
3450
+ if (d.workerKnown(id)) d.resume(text, id);
3451
+ else d.note(d.paint("red", `» ${e.message}`));
3452
+ });
3453
+ };
3454
+ }
3455
+ /**
2705
3456
  * Tail one worker (target) or the running workers of this scope, live.
2706
- * Auto-exit: with a target, wait `autoExit` seconds after its end; without
2707
- * one, exit once no worker in scope has run for that many seconds in a row,
2708
- * never within the first 30 s. With a target on a TTY, typed stdin lines are
2709
- * said to the worker (or resume it after it finished).
3457
+ * Workers whose event log already exists are first replayed (last
3458
+ * BACKFILL_EVENTS events), then tailed. Auto-exit: with a target, wait
3459
+ * `autoExit` seconds after its end; without one, exit once no worker in
3460
+ * scope has run for that many seconds in a row, never within the first 30 s.
3461
+ *
3462
+ * With a target on a TTY the pane becomes a chat (chatui.ts): transcript in
3463
+ * a scroll region, fixed prompt and ticker rows, submitted lines said to the
3464
+ * worker (or resuming it after it finished) and echoed as `»` rows. A draft
3465
+ * in the prompt holds the auto-exit countdown. Non-TTY output keeps the
3466
+ * plain scrolling behaviour; FORCE_TTY=1 emits the chat layout over a pipe.
2710
3467
  */
2711
- async function followWorkers(logDir, target, showAll, autoExit, env = process.env, color = process.stdout.isTTY === true) {
3468
+ async function followWorkers(logDir, target, showAll, autoExit, env = process.env, color = process.stdout.isTTY === true, io) {
2712
3469
  const c = makeColor(color);
2713
- const tty = process.stdout.isTTY === true;
3470
+ const out_ = io?.stdout ?? process.stdout;
3471
+ const in_ = io?.stdin ?? process.stdin;
3472
+ const tty = out_.isTTY === true || env.FORCE_TTY === "1";
2714
3473
  const term = env.ITERM_SESSION_ID ?? "";
2715
3474
  const scopeTab = target || showAll ? "" : currentTabKey(env);
2716
- const tools = {};
2717
3475
  const handles = /* @__PURE__ */ new Map();
2718
3476
  const seenHeader = /* @__PURE__ */ new Set();
2719
3477
  const finished = /* @__PURE__ */ new Set();
2720
- const lastDayBy = /* @__PURE__ */ new Map();
3478
+ const states = /* @__PURE__ */ new Map();
2721
3479
  const started = Date.now();
2722
3480
  let idleSince = null;
2723
3481
  let aborted = false;
2724
3482
  let lastEventAt = Date.now();
2725
- let lastAction = "waiting for first event";
2726
3483
  let meterStatus = null;
2727
- let livenessLen = 0;
2728
3484
  const onInt = () => {
2729
3485
  aborted = true;
2730
3486
  };
2731
3487
  process.once("SIGINT", onInt);
2732
- const plainOf = (s) => s.replace(/\x1b\[[0-9;]*m/g, "");
2733
- const eraseLiveness = () => {
2734
- if (livenessLen > 0) {
2735
- process.stdout.write("\r" + " ".repeat(livenessLen) + "\r");
2736
- livenessLen = 0;
3488
+ const chat = tty && target !== null;
3489
+ let rows = out_.rows ?? 24;
3490
+ const columns = () => typeof out_.columns === "number" ? out_.columns : null;
3491
+ let fill = 0;
3492
+ const regionRows = () => Math.max(1, rows - 2);
3493
+ /** Every line the pane shows goes through here: plain newline, or a row
3494
+ * inserted above the fixed prompt/ticker rows (chatui.chatInsertLine). */
3495
+ const out = (line) => {
3496
+ if (!chat) {
3497
+ out_.write(line + "\n");
3498
+ return;
2737
3499
  }
3500
+ const r = chatInsertLine(line, fill, regionRows());
3501
+ out_.write(r.seq);
3502
+ fill = r.fill;
3503
+ };
3504
+ const eraseLiveness = () => {
3505
+ if (chat) return;
3506
+ if (tty) out_.write("\r\x1B[K");
2738
3507
  };
3508
+ let ticker = initialFollowState();
2739
3509
  const writeLiveness = () => {
2740
3510
  if (!tty) return;
2741
- eraseLiveness();
2742
3511
  const secs = Math.max(0, Math.floor((Date.now() - lastEventAt) / 1e3));
2743
3512
  const meter = meterStatus ? contextMeter(c, meterStatus) : null;
2744
- const plain = `⋯ ${secs}s since last event · ${lastAction}${meter ? ` · ${plainOf(meter)}` : ""}`;
2745
- process.stdout.write(`
2746
- livenessLen = plain.length;
3513
+ const text = tickerText(secs, ticker.intent, ticker.tool, meter);
3514
+ if (chat) out_.write(chatTickerRow(text, rows));
3515
+ else {
3516
+ out_.write("\r\x1B[K");
3517
+ out_.write(text);
3518
+ }
2747
3519
  };
2748
3520
  const runningIds = () => loadStatuses(logDir).filter((s) => s.state === "running" && alive(s.pid) && (target !== null || showAll || (scopeTab ? workerInScope(s, term) : true))).map((s) => s.id);
3521
+ const emitEvent = (e, wid, st, multi) => {
3522
+ if (e.type === "operator" && chat && suppressMirror(String(e.text ?? ""))) return;
3523
+ if (!seenHeader.has(wid)) {
3524
+ eraseLiveness();
3525
+ out(headerLine(c, st));
3526
+ seenHeader.add(wid);
3527
+ }
3528
+ const step = applyEvent(c, states.get(wid) ?? initialFollowState(), e, st.cwd ?? "", multi ? c("cyan", wid.slice(-4)) : void 0, void 0, columns());
3529
+ if (step.day) out(c("dim", `── ${step.day} ──`));
3530
+ states.set(wid, step.state);
3531
+ if (wid === target) ticker = step.state;
3532
+ eraseLiveness();
3533
+ for (const ln of step.lines) out(ln);
3534
+ if (step.activity) lastEventAt = Date.now();
3535
+ if (e.type === "result") {
3536
+ meterStatus = st;
3537
+ finished.add(wid);
3538
+ } else if (wid === target || target === null) meterStatus = st;
3539
+ };
3540
+ const attachHandle = (wid, path, st, multi) => {
3541
+ const handle = {
3542
+ fd: openSync(path, "r"),
3543
+ buf: ""
3544
+ };
3545
+ try {
3546
+ const existing = readFileSync(path, "utf8");
3547
+ if (existing.trim()) for (const line of backfillLines(existing)) {
3548
+ let e;
3549
+ try {
3550
+ e = JSON.parse(line);
3551
+ } catch {
3552
+ continue;
3553
+ }
3554
+ emitEvent(e, wid, st, multi);
3555
+ }
3556
+ else if (!seenHeader.has(wid)) {
3557
+ out(headerLine(c, st));
3558
+ seenHeader.add(wid);
3559
+ }
3560
+ const sink = Buffer.alloc(65536);
3561
+ for (;;) {
3562
+ let n;
3563
+ try {
3564
+ n = readSync(handle.fd, sink, 0, sink.length, null);
3565
+ } catch {
3566
+ break;
3567
+ }
3568
+ if (n <= 0) break;
3569
+ }
3570
+ } catch {}
3571
+ return handle;
3572
+ };
2749
3573
  const noteLine = (s) => {
2750
3574
  eraseLiveness();
2751
- process.stdout.write(s + "\n");
3575
+ out(s);
2752
3576
  };
3577
+ const spawnResume = io?.spawnResume ?? ((id, text) => spawn("pai", [
3578
+ "worker",
3579
+ "resume",
3580
+ id,
3581
+ text,
3582
+ "--print-id",
3583
+ "--no-pane"
3584
+ ], { stdio: [
3585
+ "ignore",
3586
+ "pipe",
3587
+ "inherit"
3588
+ ] }));
2753
3589
  const resumeTarget = (text, id) => {
2754
3590
  noteLine(c("dim", `» resuming ${id} …`));
2755
- const child = spawn("pai", [
2756
- "worker",
2757
- "resume",
2758
- id,
2759
- text,
2760
- "--print-id",
2761
- "--no-pane"
2762
- ], { stdio: [
2763
- "ignore",
2764
- "pipe",
2765
- "inherit"
2766
- ] });
3591
+ const child = spawnResume(id, text);
2767
3592
  let idOut = "";
2768
3593
  child.stdout?.on("data", (chunk) => {
2769
3594
  idOut += chunk.toString("utf8");
2770
3595
  });
2771
3596
  child.on("close", (rc) => {
2772
3597
  const newId = idOut.trim().split("\n").pop() ?? "";
2773
- if (rc === 0 && /^[0-9]{8}-[0-9]{6}-[0-9]+$/.test(newId)) {
3598
+ if (rc === 0 && /^\d{8}-\d{6}-\d+$/.test(newId)) {
2774
3599
  noteLine(c("dim", `» resumed as ${newId}`));
2775
3600
  target = newId;
2776
3601
  finished.delete(newId);
2777
3602
  seenHeader.delete(newId);
3603
+ states.delete(newId);
3604
+ ticker = initialFollowState("resumed");
2778
3605
  lastEventAt = Date.now();
2779
- lastAction = "resumed";
2780
3606
  } else noteLine(c("red", `» resume failed (rc=${rc})`));
2781
3607
  });
2782
3608
  };
2783
- const handleOperatorLine = (raw) => {
2784
- const text = raw.trim();
2785
- if (!text || target === null) return;
2786
- const id = target;
2787
- sayToWorker(logDir, id, text).then(() => noteLine(c("dim", `» sent to ${id}`)), (e) => {
2788
- if (loadStatuses(logDir).some((s) => s.id === id)) resumeTarget(text, id);
2789
- else noteLine(c("red", `» ${e.message}`));
2790
- });
2791
- };
3609
+ const handleOperatorLine = makeOperatorInput({
3610
+ target: () => target,
3611
+ say: (id, text) => sayToWorker(logDir, id, text),
3612
+ workerKnown: (id) => loadStatuses(logDir).some((s) => s.id === id),
3613
+ resume: resumeTarget,
3614
+ note: noteLine,
3615
+ paint: c,
3616
+ ...chat ? { sent: () => void 0 } : {}
3617
+ });
3618
+ const terminalIn = in_.isTTY === true;
3619
+ let hintUp = true;
2792
3620
  let rlIn = null;
2793
- if (target !== null && process.stdin.isTTY) {
2794
- rlIn = createInterface({ input: process.stdin });
3621
+ let onResize = null;
3622
+ const echoed = /* @__PURE__ */ new Map();
3623
+ const suppressMirror = (text) => {
3624
+ const g = echoed.get(text);
3625
+ if (!g || Date.now() > g.until) return false;
3626
+ g.n -= 1;
3627
+ if (g.n <= 0) echoed.delete(text);
3628
+ return true;
3629
+ };
3630
+ const drawPrompt = () => {
3631
+ if (!chat || terminalIn) return;
3632
+ out_.write(chatPromptRow(rows, CHAT_PROMPT, hintUp ? c("dim", CHAT_HINT) : void 0));
3633
+ };
3634
+ if (chat) {
3635
+ const echoOperator = (text) => {
3636
+ const id = target;
3637
+ if (id === null) return;
3638
+ echoed.set(text, {
3639
+ n: 1,
3640
+ until: Date.now() + 1e4
3641
+ });
3642
+ const step = applyEvent(c, states.get(id) ?? initialFollowState(), {
3643
+ type: "operator",
3644
+ _ts: (/* @__PURE__ */ new Date()).toISOString(),
3645
+ text
3646
+ }, "", void 0, void 0, columns());
3647
+ if (step.day) out(c("dim", `── ${step.day} ──`));
3648
+ for (const ln of step.lines) out(ln);
3649
+ states.set(id, step.state);
3650
+ };
3651
+ const handleChatLine = (raw) => {
3652
+ hintUp = false;
3653
+ const act = parseChatLine(raw);
3654
+ switch (act.kind) {
3655
+ case "message":
3656
+ if (!act.text) break;
3657
+ echoOperator(act.text);
3658
+ handleOperatorLine(act.text);
3659
+ break;
3660
+ case "resume":
3661
+ if (!act.text) {
3662
+ out(c("dim", "usage: /resume <text>"));
3663
+ break;
3664
+ }
3665
+ echoOperator(act.text);
3666
+ if (target !== null) resumeTarget(act.text, target);
3667
+ break;
3668
+ case "help":
3669
+ for (const ln of CHAT_HELP) out(c("dim", ln));
3670
+ break;
3671
+ case "status": {
3672
+ const s = target !== null ? loadStatuses(logDir).find((x) => x.id === target) : void 0;
3673
+ out(c("dim", s ? `${s.id} · ${s.state} · ${s.last}` : `${target ?? "?"} · no status`));
3674
+ break;
3675
+ }
3676
+ case "quit":
3677
+ aborted = true;
3678
+ break;
3679
+ }
3680
+ drawPrompt();
3681
+ };
3682
+ rlIn = createInterface({
3683
+ input: in_,
3684
+ output: out_,
3685
+ terminal: terminalIn
3686
+ });
3687
+ rlIn.on("line", handleChatLine);
3688
+ rlIn.on("SIGINT", () => {
3689
+ if ((rlIn?.line ?? "").trim() === "") aborted = true;
3690
+ else rlIn?.write(null, {
3691
+ ctrl: true,
3692
+ name: "u"
3693
+ });
3694
+ });
3695
+ rlIn.on("close", () => {
3696
+ aborted = true;
3697
+ });
3698
+ out_.write(chatEnter(rows));
3699
+ if (terminalIn) {
3700
+ rlIn.setPrompt(CHAT_PROMPT);
3701
+ rlIn.prompt();
3702
+ out_.write(c("dim", CHAT_HINT));
3703
+ } else drawPrompt();
3704
+ onResize = () => {
3705
+ if (typeof out_.rows === "number") rows = out_.rows;
3706
+ fill = 0;
3707
+ out_.write(chatScrollRegion(rows));
3708
+ drawPrompt();
3709
+ };
3710
+ out_.on?.("resize", onResize);
3711
+ } else if (target !== null && in_.isTTY) {
3712
+ rlIn = createInterface({ input: in_ });
2795
3713
  rlIn.on("line", handleOperatorLine);
2796
3714
  }
3715
+ /** The prompt's unsent text — a draft holds the auto-exit countdown. */
3716
+ const promptText = () => io?.promptLine ? io.promptLine() : rlIn?.line ?? "";
2797
3717
  try {
2798
3718
  for (;;) {
2799
3719
  if (aborted) return;
2800
- for (const wid of target ? [target] : runningIds()) {
3720
+ const statuses = new Map(loadStatuses(logDir).map((s) => [s.id, s]));
3721
+ const wanted = target ? [target] : runningIds();
3722
+ for (const wid of wanted) {
2801
3723
  if (handles.has(wid) || finished.has(wid)) continue;
2802
3724
  const path = eventsPath(logDir, wid);
2803
- if (existsSync(path)) handles.set(wid, {
2804
- fd: openSync(path, "r"),
2805
- buf: ""
2806
- });
3725
+ const st = statuses.get(wid) ?? {
3726
+ id: wid,
3727
+ label: "",
3728
+ cwd: "",
3729
+ provider: ""
3730
+ };
3731
+ if (existsSync(path)) {
3732
+ const multi = target === null || handles.size > 0;
3733
+ handles.set(wid, attachHandle(wid, path, st, multi));
3734
+ states.set(wid, states.get(wid) ?? initialFollowState());
3735
+ } else if (!seenHeader.has(wid)) {
3736
+ out(headerLine(c, st));
3737
+ seenHeader.add(wid);
3738
+ }
2807
3739
  }
2808
- const statuses = new Map(loadStatuses(logDir).map((s) => [s.id, s]));
2809
3740
  const multi = handles.size > 1 || target === null;
2810
3741
  let progressed = false;
2811
3742
  for (const [wid, h] of [...handles.entries()]) {
@@ -2815,7 +3746,6 @@ async function followWorkers(logDir, target, showAll, autoExit, env = process.en
2815
3746
  cwd: "",
2816
3747
  provider: ""
2817
3748
  };
2818
- const prefix = multi ? c("cyan", wid.slice(-4)) + c("dim", " ┃ ") : " ";
2819
3749
  const buffer = Buffer.alloc(65536);
2820
3750
  for (;;) {
2821
3751
  let n;
@@ -2830,42 +3760,20 @@ async function followWorkers(logDir, target, showAll, autoExit, env = process.en
2830
3760
  const lines = h.buf.split("\n");
2831
3761
  h.buf = lines.pop() ?? "";
2832
3762
  for (const line of lines) {
2833
- progressed = true;
2834
- if (!seenHeader.has(wid)) {
2835
- eraseLiveness();
2836
- process.stdout.write(headerLine(c, st) + "\n");
2837
- seenHeader.add(wid);
2838
- }
2839
3763
  if (!line.trim()) continue;
3764
+ progressed = true;
2840
3765
  let e;
2841
3766
  try {
2842
3767
  e = JSON.parse(line);
2843
3768
  } catch {
2844
3769
  continue;
2845
3770
  }
2846
- const day = typeof e._ts === "string" ? e._ts.slice(0, 10) : "";
2847
- if (day && day !== lastDayBy.get(wid)) {
2848
- eraseLiveness();
2849
- process.stdout.write(`${multi ? prefix : ""}${c("dim", `── ${day} ──`)}\n`);
2850
- lastDayBy.set(wid, day);
2851
- }
2852
- collectTools(e, tools);
2853
- const g = gutterFor(c, e, multi ? c("cyan", wid.slice(-4)) : void 0);
2854
- eraseLiveness();
2855
- for (const ln of renderEvent(c, g ? "" : prefix, e, st.cwd ?? "", tools, g)) process.stdout.write(ln + "\n");
2856
- lastEventAt = Date.now();
2857
- if (e.type === "operator") lastAction = "operator message";
2858
- else if (e.type === "assistant") {
2859
- const tool = (e.message?.content ?? []).find((b) => b.type === "tool_use");
2860
- lastAction = tool ? `${tool.name ?? "?"} running` : "thinking";
2861
- } else if (e.type === "result") lastAction = "finished";
2862
- meterStatus = st;
2863
- if (e.type === "result") finished.add(wid);
3771
+ emitEvent(e, wid, st, multi);
2864
3772
  }
2865
3773
  if (workerEnded(finished.has(wid), st.state, alive(st.pid))) {
2866
3774
  if (!finished.has(wid)) {
2867
3775
  eraseLiveness();
2868
- process.stdout.write(`${prefix}${c("red", "✗ " + (st.state || "ended"))} · ${st.last ?? ""}\n`);
3776
+ out(`${multi ? c("cyan", wid.slice(-4)) + c("dim", " ┃ ") : " "}${c("red", "✗ " + (st.state || "ended"))} · ${st.last ?? ""}`);
2869
3777
  finished.add(wid);
2870
3778
  }
2871
3779
  closeSync(h.fd);
@@ -2875,14 +3783,19 @@ async function followWorkers(logDir, target, showAll, autoExit, env = process.en
2875
3783
  const lingerOn = target;
2876
3784
  if (lingerOn !== null && finished.has(lingerOn)) {
2877
3785
  if (autoExit) {
3786
+ if (holdAutoExit(promptText())) {
3787
+ writeLiveness();
3788
+ await sleep(250);
3789
+ continue;
3790
+ }
2878
3791
  const until = Date.now() + autoExit * 1e3;
2879
- while (Date.now() < until && !aborted && target === lingerOn) {
3792
+ while (Date.now() < until && !aborted && target === lingerOn && !holdAutoExit(promptText())) {
2880
3793
  writeLiveness();
2881
3794
  await sleep(250);
2882
3795
  }
2883
- if (aborted || target !== lingerOn) continue;
3796
+ if (aborted || target !== lingerOn || holdAutoExit(promptText())) continue;
2884
3797
  eraseLiveness();
2885
- process.stdout.write(c("dim", "closing") + "\n");
3798
+ out(c("dim", "closing"));
2886
3799
  return;
2887
3800
  }
2888
3801
  if (rlIn === null) return;
@@ -2892,7 +3805,7 @@ async function followWorkers(logDir, target, showAll, autoExit, env = process.en
2892
3805
  else if (idleSince === null) idleSince = Date.now();
2893
3806
  else if (Date.now() - started >= 3e4 && Date.now() - idleSince >= autoExit * 1e3) {
2894
3807
  eraseLiveness();
2895
- process.stdout.write(c("dim", "closing") + "\n");
3808
+ out(c("dim", "closing"));
2896
3809
  return;
2897
3810
  }
2898
3811
  }
@@ -2904,6 +3817,8 @@ async function followWorkers(logDir, target, showAll, autoExit, env = process.en
2904
3817
  } finally {
2905
3818
  rlIn?.close();
2906
3819
  process.removeListener("SIGINT", onInt);
3820
+ if (onResize) out_.removeListener?.("resize", onResize);
3821
+ if (chat) out_.write(chatLeave(rows));
2907
3822
  for (const h of handles.values()) try {
2908
3823
  closeSync(h.fd);
2909
3824
  } catch {}
@@ -2924,7 +3839,7 @@ function statusLineOutput(logDir, term, cwd, now = /* @__PURE__ */ new Date()) {
2924
3839
  //#endregion
2925
3840
  //#region src/workers/providers.ts
2926
3841
  /**
2927
- * providers.ts — provider, role and switch management over the workers config.
3842
+ * providers.ts — provider, class and switch management over the workers config.
2928
3843
  *
2929
3844
  * One layer under both `pai worker providers …` and the MCP worker_providers
2930
3845
  * tool. Every mutation re-reads the config file, changes only the workers
@@ -2969,27 +3884,52 @@ function addProvider(input) {
2969
3884
  ...input.upstreamUrl ? { upstreamUrl: input.upstreamUrl } : {},
2970
3885
  ...input.engine && input.engine !== "claude" ? { engine: input.engine } : {},
2971
3886
  ...input.quotaProbe ? { quotaProbe: input.quotaProbe } : {},
2972
- ...input.contextWindow ? { contextWindow: input.contextWindow } : {}
3887
+ ...input.contextWindow ? { contextWindow: input.contextWindow } : {},
3888
+ ...input.costTier ? { costTier: input.costTier } : {},
3889
+ ...input.tags?.length ? { tags: input.tags } : {}
2973
3890
  };
2974
3891
  workers.providers[input.name] = provider;
2975
3892
  if (Object.keys(workers.providers).length === 1) {
2976
3893
  workers.enabled = true;
2977
3894
  workers.active = input.name;
2978
3895
  workers.logDir = workers.logDir || DEFAULT_LOG_DIR;
2979
- workers.roles = {
3896
+ const fast = input.fastModel ? `${input.name}/fast` : input.name;
3897
+ workers.classes = {
3898
+ draft: fast,
3899
+ plan: input.name,
2980
3900
  implement: input.name,
3901
+ review: input.name,
2981
3902
  research: input.name,
2982
- spotcheck: input.fastModel ? `${input.name}/fast` : input.name
3903
+ spotcheck: fast,
3904
+ simple: fast,
3905
+ complex: input.name,
3906
+ image: input.name
2983
3907
  };
2984
3908
  }
2985
3909
  writeWorkersSection(raw, workers);
2986
3910
  return workers;
2987
3911
  }
3912
+ /** Change costTier / tags on an existing provider (MCP action "update"). */
3913
+ function updateProvider(name, changes) {
3914
+ const { raw, workers } = readWorkersSection();
3915
+ const p = workers.providers[name];
3916
+ if (!p) throw new WorkersConfigError(`no provider named "${name}". Configured: ${Object.keys(workers.providers).join(", ") || "(none)"}`);
3917
+ if (changes.costTier !== void 0) {
3918
+ if (!Number.isInteger(changes.costTier) || changes.costTier < 1 || changes.costTier > 5) throw new WorkersConfigError("costTier must be an integer 1 (cheapest) … 5 (most expensive)");
3919
+ p.costTier = changes.costTier;
3920
+ }
3921
+ if (changes.tags !== void 0) {
3922
+ for (const t of changes.tags) if (!PROVIDER_TAGS.includes(t)) throw new WorkersConfigError(`"${t}" is not a tag (from: ${PROVIDER_TAGS.join(", ")})`);
3923
+ p.tags = changes.tags;
3924
+ }
3925
+ writeWorkersSection(raw, workers);
3926
+ return workers;
3927
+ }
2988
3928
  function removeProvider(name) {
2989
3929
  const { raw, workers } = readWorkersSection();
2990
3930
  if (!workers.providers[name]) throw new WorkersConfigError(`no provider named "${name}"`);
2991
3931
  delete workers.providers[name];
2992
- for (const [role, target] of Object.entries(workers.roles)) if ((typeof target === "string" ? target.split("/")[0] : target.provider) === name) delete workers.roles[role];
3932
+ for (const [cls, target] of Object.entries(workers.classes)) if ((typeof target === "string" ? target.split("/")[0] : target.provider) === name) delete workers.classes[cls];
2993
3933
  if (workers.active === name) workers.active = null;
2994
3934
  writeWorkersSection(raw, workers);
2995
3935
  return workers;
@@ -3010,21 +3950,30 @@ function setProviderEnabled(name, enabled) {
3010
3950
  if (enabled) clearCooldown(workersLogDir(parseWorkersConfig(raw.workers)), name);
3011
3951
  return workers;
3012
3952
  }
3013
- function setRole(role, target) {
3953
+ /**
3954
+ * Point a class at a target ("provider", "provider/fast", or an object with
3955
+ * provider + optional mcp/maxCostTier/requireTags/order). An object without
3956
+ * a provider only constrains auto-routing for that class.
3957
+ */
3958
+ function setClass(name, target) {
3014
3959
  const { raw, workers } = readWorkersSection();
3015
- const [name, alias] = target.split("/");
3016
- const p = workers.providers[name];
3017
- if (!p) throw new WorkersConfigError(`no provider named "${name}" in "${target}". Configured: ${Object.keys(workers.providers).join(", ") || "(none)"}`);
3018
- if (alias && alias !== "default" && alias !== "fast") throw new WorkersConfigError(`unknown model alias "${alias}" providers expose "default" and "fast"`);
3019
- if (alias === "fast" && !p.models.fast) throw new WorkersConfigError(`provider "${name}" has no fast model configured`);
3020
- workers.roles[role] = target;
3960
+ if (typeof target === "string") {
3961
+ const [provider, alias] = target.split("/");
3962
+ const p = workers.providers[provider];
3963
+ if (!p) throw new WorkersConfigError(`no provider named "${provider}" in "${target}". Configured: ${Object.keys(workers.providers).join(", ") || "(none)"}`);
3964
+ if (alias && alias !== "default" && alias !== "fast") throw new WorkersConfigError(`unknown model alias "${alias}" providers expose "default" and "fast"`);
3965
+ if (alias === "fast" && !p.models.fast) throw new WorkersConfigError(`provider "${provider}" has no fast model configured`);
3966
+ } else if (target.provider) {
3967
+ if (!workers.providers[target.provider]) throw new WorkersConfigError(`no provider named "${target.provider}". Configured: ${Object.keys(workers.providers).join(", ") || "(none)"}`);
3968
+ }
3969
+ workers.classes[name] = target;
3021
3970
  writeWorkersSection(raw, workers);
3022
3971
  return workers;
3023
3972
  }
3024
- function unsetRole(role) {
3973
+ function unsetClass(name) {
3025
3974
  const { raw, workers } = readWorkersSection();
3026
- if (!(role in workers.roles)) throw new WorkersConfigError(`no role named "${role}"`);
3027
- delete workers.roles[role];
3975
+ if (!(name in workers.classes)) throw new WorkersConfigError(`no class named "${name}"`);
3976
+ delete workers.classes[name];
3028
3977
  writeWorkersSection(raw, workers);
3029
3978
  return workers;
3030
3979
  }
@@ -3034,6 +3983,17 @@ function setWorkersEnabled(enabled) {
3034
3983
  writeWorkersSection(raw, workers);
3035
3984
  return workers;
3036
3985
  }
3986
+ /** `glm/fast` | `{provider, mcp, …}` → one printable target line. */
3987
+ function classTargetText(target) {
3988
+ if (typeof target === "string") return target;
3989
+ return [
3990
+ target.provider ?? "(routing)",
3991
+ ...target.mcp?.length ? [`mcp(${target.mcp.join(",")})`] : [],
3992
+ ...target.maxCostTier !== void 0 ? [`max tier ${target.maxCostTier}`] : [],
3993
+ ...target.requireTags?.length ? [`needs ${target.requireTags.join(",")}`] : [],
3994
+ ...target.order?.length ? [`order [${target.order.join(",")}]`] : []
3995
+ ].join(" ");
3996
+ }
3037
3997
  /** Human-readable provider listing (quota probe included when configured). */
3038
3998
  function describeProviders(workers) {
3039
3999
  const lines = [];
@@ -3048,8 +4008,10 @@ function describeProviders(workers) {
3048
4008
  const flags = [p.enabled ? "enabled" : "disabled", workers.active === name ? "active" : null].filter(Boolean);
3049
4009
  const quota = p.quotaProbe ? probeQuota(p) : null;
3050
4010
  const quotaNote = quota === null ? "" : ` quota ${quota}% (skip at ${quotaSkipThreshold(p)})`;
4011
+ const tierTags = [`tier ${providerCostTier(p)}`, ...p.tags ?? []].join(", ");
3051
4012
  lines.push(`${name} [${flags.join(", ")}] ${p.baseUrl}`);
3052
4013
  lines.push(` model ${p.models.default}${p.models.fast ? ` (fast: ${p.models.fast})` : ""}${quotaNote}`);
4014
+ lines.push(` ${tierTags}`);
3053
4015
  if (p.keyFile) lines.push(` key file ${expandHome(p.keyFile)}`);
3054
4016
  else lines.push(` no key file (token "local")`);
3055
4017
  if (p.note) lines.push(` ${p.note}`);
@@ -3058,10 +4020,12 @@ function describeProviders(workers) {
3058
4020
  }
3059
4021
  const setNames = Object.keys(workers.mcpSets);
3060
4022
  if (setNames.length) lines.push(`mcp sets: ${setNames.map((s) => `${s}=[${workers.mcpSets[s].join(",")}]`).join(" ")}`);
4023
+ const classNames = Object.keys(workers.classes);
4024
+ if (classNames.length) lines.push(`classes: ${classNames.map((cl) => `${cl}=${classTargetText(workers.classes[cl])}`).join(" ")}`);
3061
4025
  if (workers.active === "auto") lines.push(`routing: auto — order [${workers.routing.order.join(", ")}], cooldown ${workers.routing.cooldownMinutes}m`);
3062
4026
  return lines;
3063
4027
  }
3064
4028
 
3065
4029
  //#endregion
3066
- export { readJsonStrict as A, loadStatus as C, workersLogDir as D, ledgerPath as E, WorkersConfigError as O, openPaneForWorker as S, eventsPath as T, stopProxy as _, setRole as a, checkPaneForWorker as b, useProvider as c, replayOutput as d, statusLineOutput as f, ensureProxyRunning as g, DEFAULT_PROXY_PORT as h, setProviderEnabled as i, writeJsonAtomic as j, readWorkersSection as k, followWorkers as l, testProvider as m, describeProviders as n, setWorkersEnabled as o, runWorker as p, removeProvider as r, unsetRole as s, addProvider as t, psOutput as u, sayToWorker as v, ledgerSummary as w, openFollowPane as x, describeMcp as y };
3067
- //# sourceMappingURL=providers-sXcK5bDZ.mjs.map
4030
+ export { ledgerPath as A, checkPaneForWorker as C, ledgerSummary as D, loadStatus as E, readJsonStrict as F, writeJsonAtomic as I, PROVIDER_TAGS as M, WorkersConfigError as N, parseRunnerArgs as O, readWorkersSection as P, describeMcp as S, openPaneForWorker as T, testProvider as _, setClass as a, stopProxy as b, unsetClass as c, followWorkers as d, psOutput as f, runWorker as g, runChain as h, removeProvider as i, workersLogDir as j, eventsPath as k, updateProvider as l, statusLineOutput as m, classTargetText as n, setProviderEnabled as o, replayOutput as p, describeProviders as r, setWorkersEnabled as s, addProvider as t, useProvider as u, DEFAULT_PROXY_PORT as v, openFollowPane as w, sayToWorker as x, ensureProxyRunning as y };
4031
+ //# sourceMappingURL=providers-DUshcB-d.mjs.map