@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.
- package/dist/cli/index.mjs +2 -2
- package/dist/cli/program.mjs +2 -2
- package/dist/daemon-mcp/index.mjs +153 -32
- package/dist/daemon-mcp/index.mjs.map +1 -1
- package/dist/hooks/route-agents-to-worker.mjs +76 -14
- package/dist/hooks/route-agents-to-worker.mjs.map +2 -2
- package/dist/hooks/worker-proxy.mjs +76 -14
- package/dist/hooks/worker-proxy.mjs.map +2 -2
- package/dist/hooks/worker-status-line.mjs +76 -14
- package/dist/hooks/worker-status-line.mjs.map +2 -2
- package/dist/{program-CEIHn_Ma.mjs → program-Y0hAiVy8.mjs} +207 -49
- package/dist/program-Y0hAiVy8.mjs.map +1 -0
- package/dist/{providers-sXcK5bDZ.mjs → providers-DUshcB-d.mjs} +1171 -207
- package/dist/providers-DUshcB-d.mjs.map +1 -0
- package/dist/skills/Worker/SKILL.md +34 -12
- package/docs/commands/README.md +8 -7
- package/docs/commands/worker.md +54 -17
- package/docs/worker.md +156 -25
- package/package.json +1 -1
- package/dist/program-CEIHn_Ma.mjs.map +0 -1
- package/dist/providers-sXcK5bDZ.mjs.map +0 -1
|
@@ -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
|
-
|
|
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
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
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(`.
|
|
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(`.
|
|
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
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
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(`.
|
|
225
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 >
|
|
747
|
+
* routing.ts — provider selection: flag > class > active (possibly "auto").
|
|
680
748
|
*
|
|
681
|
-
* Auto-routing walks `workers.routing.order`
|
|
682
|
-
* that is enabled, out of cooldown,
|
|
683
|
-
*
|
|
684
|
-
*
|
|
685
|
-
*
|
|
686
|
-
*
|
|
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
|
|
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
|
|
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
|
-
|
|
865
|
+
classMcp: null,
|
|
779
866
|
via: "flag"
|
|
780
867
|
};
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
const name =
|
|
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
|
-
|
|
792
|
-
via: "
|
|
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
|
-
|
|
900
|
+
classMcp: null,
|
|
803
901
|
via: "active"
|
|
804
902
|
};
|
|
805
903
|
}
|
|
806
904
|
const state = readRoutingState(logDir);
|
|
807
|
-
const
|
|
808
|
-
|
|
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
|
-
|
|
811
|
-
if (
|
|
812
|
-
|
|
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
|
-
|
|
918
|
+
classMcp: cls?.mcp ?? null,
|
|
824
919
|
via: "auto"
|
|
825
920
|
};
|
|
826
921
|
}
|
|
827
|
-
throw new NoProviderError(`auto-routing found no usable provider (order: [${
|
|
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
|
|
863
|
-
*
|
|
864
|
-
*
|
|
865
|
-
*
|
|
866
|
-
*
|
|
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
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
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
|
-
|
|
940
|
-
|
|
941
|
-
|
|
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,
|
|
1262
|
-
* the
|
|
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
|
-
/**
|
|
1881
|
-
|
|
1882
|
-
|
|
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
|
-
|
|
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.
|
|
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 `[r[?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
|
|
2499
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
2646
|
-
*
|
|
2647
|
-
*
|
|
2648
|
-
*
|
|
2649
|
-
*
|
|
2650
|
-
*
|
|
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
|
|
3409
|
+
const wrapWidth = typeof process.stdout.columns === "number" ? process.stdout.columns : null;
|
|
2670
3410
|
const out = [headerLine(c, st)];
|
|
2671
|
-
const
|
|
2672
|
-
|
|
2673
|
-
|
|
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
|
-
|
|
2682
|
-
|
|
2683
|
-
|
|
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
|
-
*
|
|
2707
|
-
*
|
|
2708
|
-
*
|
|
2709
|
-
*
|
|
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
|
|
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
|
|
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
|
|
2733
|
-
|
|
2734
|
-
|
|
2735
|
-
|
|
2736
|
-
|
|
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
|
|
2745
|
-
|
|
2746
|
-
|
|
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
|
-
|
|
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 =
|
|
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 &&
|
|
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 = (
|
|
2784
|
-
|
|
2785
|
-
|
|
2786
|
-
|
|
2787
|
-
|
|
2788
|
-
|
|
2789
|
-
|
|
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
|
-
|
|
2794
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2804
|
-
|
|
2805
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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:
|
|
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 [
|
|
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
|
-
|
|
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
|
-
|
|
3016
|
-
|
|
3017
|
-
|
|
3018
|
-
|
|
3019
|
-
|
|
3020
|
-
|
|
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
|
|
3973
|
+
function unsetClass(name) {
|
|
3025
3974
|
const { raw, workers } = readWorkersSection();
|
|
3026
|
-
if (!(
|
|
3027
|
-
delete workers.
|
|
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 {
|
|
3067
|
-
//# sourceMappingURL=providers-
|
|
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
|