@rol1n/pi-kit 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (136) hide show
  1. package/README.md +98 -2
  2. package/extensions/ask-u-questions/README.md +32 -0
  3. package/extensions/ask-u-questions/answers.ts +53 -0
  4. package/extensions/ask-u-questions/dialog-ui.ts +57 -0
  5. package/extensions/ask-u-questions/index.ts +88 -0
  6. package/extensions/ask-u-questions/multi-choice.ts +260 -0
  7. package/extensions/ask-u-questions/results.ts +83 -0
  8. package/extensions/ask-u-questions/schema.ts +17 -0
  9. package/extensions/ask-u-questions/single-choice.ts +182 -0
  10. package/extensions/ask-u-questions/tool-renderers.ts +69 -0
  11. package/extensions/ask-u-questions/types.ts +45 -0
  12. package/extensions/ask-u-questions/ui-lock.ts +24 -0
  13. package/extensions/continue-signal/README.md +28 -0
  14. package/extensions/continue-signal/index.ts +51 -0
  15. package/extensions/continue-signal/renderer.ts +21 -0
  16. package/extensions/custom-providers/README.md +28 -0
  17. package/extensions/custom-providers/asr/handlers/dashscope.ts +154 -0
  18. package/extensions/custom-providers/asr/index.ts +37 -0
  19. package/extensions/custom-providers/asr/models.ts +18 -0
  20. package/extensions/custom-providers/asr/types.ts +20 -0
  21. package/extensions/custom-providers/chat/index.ts +33 -0
  22. package/extensions/custom-providers/chat/magpie.ts +258 -0
  23. package/extensions/custom-providers/chat/models.ts +111 -0
  24. package/extensions/custom-providers/classifier/index.ts +6 -0
  25. package/extensions/custom-providers/classifier/models.ts +1 -0
  26. package/extensions/custom-providers/image/handlers/dashscope.ts +240 -0
  27. package/extensions/custom-providers/image/handlers/openrouter.ts +185 -0
  28. package/extensions/custom-providers/image/index.ts +96 -0
  29. package/extensions/custom-providers/image/models.ts +71 -0
  30. package/extensions/custom-providers/image/types.ts +22 -0
  31. package/extensions/custom-providers/index.ts +50 -0
  32. package/extensions/custom-providers/types.ts +17 -0
  33. package/extensions/image-generate/README.md +29 -0
  34. package/extensions/image-generate/files.ts +49 -0
  35. package/extensions/image-generate/index.ts +200 -0
  36. package/extensions/image-generate/model-selector.ts +150 -0
  37. package/extensions/image-generate/models.ts +19 -0
  38. package/extensions/memory-log/README.md +54 -0
  39. package/extensions/memory-log/agent/consolidator/prompt.ts +47 -0
  40. package/extensions/memory-log/agent/consolidator/tools.ts +160 -0
  41. package/extensions/memory-log/agent/cost.ts +32 -0
  42. package/extensions/memory-log/agent/index.ts +42 -0
  43. package/extensions/memory-log/agent/observer/prompt.ts +73 -0
  44. package/extensions/memory-log/agent/observer/tool.ts +73 -0
  45. package/extensions/memory-log/commands/compact.ts +35 -0
  46. package/extensions/memory-log/commands/consolidate.ts +47 -0
  47. package/extensions/memory-log/commands/status.ts +50 -0
  48. package/extensions/memory-log/config.ts +253 -0
  49. package/extensions/memory-log/debug-log.ts +76 -0
  50. package/extensions/memory-log/hooks/compaction-hook.ts +161 -0
  51. package/extensions/memory-log/hooks/compaction-trigger.ts +160 -0
  52. package/extensions/memory-log/hooks/consolidator-trigger.ts +143 -0
  53. package/extensions/memory-log/hooks/observer-trigger.ts +176 -0
  54. package/extensions/memory-log/ids.ts +78 -0
  55. package/extensions/memory-log/index.ts +149 -0
  56. package/extensions/memory-log/ledger/fold.ts +77 -0
  57. package/extensions/memory-log/ledger/index.ts +7 -0
  58. package/extensions/memory-log/ledger/pool.ts +38 -0
  59. package/extensions/memory-log/ledger/progress.ts +181 -0
  60. package/extensions/memory-log/ledger/projection.ts +115 -0
  61. package/extensions/memory-log/ledger/render.ts +38 -0
  62. package/extensions/memory-log/ledger/serialize.ts +161 -0
  63. package/extensions/memory-log/ledger/types.ts +201 -0
  64. package/extensions/memory-log/memory/index-render.ts +45 -0
  65. package/extensions/memory-log/memory/paths.ts +120 -0
  66. package/extensions/memory-log/memory/session.ts +64 -0
  67. package/extensions/memory-log/runtime.ts +127 -0
  68. package/extensions/memory-log/spawn/launch.ts +133 -0
  69. package/extensions/memory-log/spawn/runs.ts +83 -0
  70. package/extensions/memory-log/tokens.ts +66 -0
  71. package/extensions/memory-log/ui/status-controller.ts +225 -0
  72. package/extensions/memory-log/ui/timeline.ts +103 -0
  73. package/extensions/model-vision/README.md +28 -0
  74. package/extensions/multi-harness/README.md +63 -0
  75. package/extensions/multi-harness/adapters.mjs +72 -0
  76. package/extensions/multi-harness/harness-done.mjs +14 -0
  77. package/extensions/multi-harness/index.ts +375 -0
  78. package/extensions/multi-harness/ipc.mjs +37 -0
  79. package/extensions/multi-harness/opencode-plugin.mjs +99 -0
  80. package/extensions/multi-harness/process-runner.mjs +115 -0
  81. package/extensions/multi-harness/turn-hook.mjs +29 -0
  82. package/extensions/multi-harness/worker-bridge.ts +52 -0
  83. package/extensions/oai-subquota/README.md +30 -0
  84. package/extensions/oai-subquota/api.ts +27 -0
  85. package/extensions/oai-subquota/auth.ts +34 -0
  86. package/extensions/oai-subquota/cache.ts +30 -0
  87. package/extensions/oai-subquota/index.ts +276 -0
  88. package/extensions/oai-subquota/snapshot.ts +167 -0
  89. package/extensions/oai-subquota/types.ts +40 -0
  90. package/extensions/oai-subquota/ui.ts +183 -0
  91. package/extensions/prompt-snipaste/README.md +33 -0
  92. package/extensions/prompt-snipaste/index.ts +143 -0
  93. package/extensions/prompt-snipaste/menu.ts +159 -0
  94. package/extensions/prompt-snipaste/renderers.ts +88 -0
  95. package/extensions/prompt-snipaste/snippets.ts +203 -0
  96. package/extensions/prompt-snipaste/types.ts +19 -0
  97. package/extensions/screenshot/README.md +25 -0
  98. package/extensions/srp-theme/README.md +24 -0
  99. package/extensions/srp-theme/footer.ts +194 -0
  100. package/extensions/srp-theme/index.ts +172 -0
  101. package/extensions/srp-theme/tps-meter.ts +442 -0
  102. package/extensions/subagent/README.md +35 -0
  103. package/extensions/subagent/bridge.ts +171 -0
  104. package/extensions/subagent/index.ts +590 -0
  105. package/extensions/subagent/mux.ts +621 -0
  106. package/extensions/subagent/session.ts +166 -0
  107. package/extensions/subagent/skills/subagent/SKILL.md +70 -0
  108. package/extensions/subagent/ui.ts +100 -0
  109. package/extensions/tui-asr/README.md +28 -0
  110. package/extensions/tui-asr/audio.ts +40 -0
  111. package/extensions/tui-asr/clipboard.ts +30 -0
  112. package/extensions/tui-asr/dictation.ts +205 -0
  113. package/extensions/tui-asr/index.ts +181 -0
  114. package/extensions/tui-asr/input.ts +58 -0
  115. package/extensions/tui-asr/model-selector.ts +163 -0
  116. package/extensions/tui-asr/providers.ts +25 -0
  117. package/extensions/tui-asr/status.ts +79 -0
  118. package/extensions/web-reach/README.md +37 -0
  119. package/extensions/web-reach/html.ts +83 -0
  120. package/extensions/web-reach/index.ts +82 -0
  121. package/extensions/web-reach/page.ts +132 -0
  122. package/extensions/web-reach/pdf.ts +42 -0
  123. package/extensions/web-reach/reader.ts +42 -0
  124. package/extensions/web-reach/rsc.ts +161 -0
  125. package/extensions/web-reach/search.ts +85 -0
  126. package/extensions/web-reach/transport.ts +35 -0
  127. package/extensions/windows-wheel-fix/README.md +21 -0
  128. package/extensions/windows-wheel-fix/index.ts +44 -0
  129. package/package.json +93 -4
  130. package/prompts/ask-questions.md +8 -0
  131. package/prompts/delegate-exploration.md +7 -0
  132. package/prompts/diagnose-report.md +7 -0
  133. package/prompts/frontend-tech-preset.md +18 -0
  134. package/prompts/orchestrator-mode.md +7 -0
  135. package/prompts/session-kickoff.md +7 -0
  136. package/prompts/verify-not-assume.md +7 -0
@@ -0,0 +1,442 @@
1
+ import type { ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
2
+ import { visibleWidth } from "@earendil-works/pi-tui";
3
+
4
+ export class TpsMeter {
5
+ // Constants
6
+ static readonly WINDOW_SIZE = 60;
7
+ static readonly WINDOW_MS = 60_000;
8
+ static readonly STREAM_INTERVAL_MS = 200;
9
+ static readonly SPARK_LEN = 12;
10
+ static readonly ALLTIME_CAP = 500;
11
+ static readonly FAST = 50;
12
+ static readonly MED = 20;
13
+
14
+ // Render glyphs
15
+ static readonly BLOCKS = ["▁", "▂", "▃", "▄", "▅", "▆", "▇", "█"];
16
+ static readonly HBLOCKS = [" ", "▏", "▎", "▍", "▌", "▋", "▊", "▉"];
17
+ static readonly GAUGE_LEN = 11;
18
+ static readonly GAUGE_FLOOR = 40;
19
+ static readonly TRACK = "·";
20
+ static readonly SPIN = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
21
+
22
+ // State variables
23
+ private streamStartMs = 0;
24
+ private firstTokenMs = 0;
25
+ private streamChars = 0;
26
+ private streamTokens = 0;
27
+ private tickTimer: ReturnType<typeof setInterval> | null = null;
28
+ private streaming = false;
29
+
30
+ // 60-second rolling window (ring buffer: [tps, timestamp])
31
+ private readonly winBuf = new Float64Array(TpsMeter.WINDOW_SIZE * 2);
32
+ private winLen = 0;
33
+ private winHead = 0;
34
+
35
+ // Session-wide sampling (ring buffer: [tps])
36
+ private readonly atBuf = new Float64Array(TpsMeter.ALLTIME_CAP);
37
+ private atLen = 0;
38
+ private atHead = 0;
39
+ private atSum = 0;
40
+
41
+ // Sparkline history for last 12 messages
42
+ private readonly sparkBuf = new Float64Array(TpsMeter.SPARK_LEN);
43
+ private sparkLen = 0;
44
+ private sparkHead = 0;
45
+ private sparkMax = 1;
46
+ private sparkCache = "";
47
+ private sparkDirty = true;
48
+ private sparkTheme: Theme | null = null;
49
+ private spinI = 0;
50
+ private lastStatusText: string | undefined = undefined;
51
+
52
+ public enabled = true;
53
+
54
+ private applyStatus(ctx: ExtensionContext, text: string | undefined): void {
55
+ if (this.lastStatusText === text) return;
56
+ this.lastStatusText = text;
57
+ if (ctx.ui?.setStatus) {
58
+ ctx.ui.setStatus("tps", text);
59
+ }
60
+ }
61
+
62
+ reset(ctx?: ExtensionContext): void {
63
+ this.streaming = false;
64
+ this.stopTick();
65
+ this.streamStartMs = 0;
66
+ this.firstTokenMs = 0;
67
+ this.streamChars = 0;
68
+ this.streamTokens = 0;
69
+ this.winLen = 0;
70
+ this.winHead = 0;
71
+ this.atLen = 0;
72
+ this.atHead = 0;
73
+ this.atSum = 0;
74
+ this.sparkLen = 0;
75
+ this.sparkHead = 0;
76
+ this.sparkMax = 1;
77
+ this.sparkCache = "";
78
+ this.sparkDirty = true;
79
+ this.sparkTheme = null;
80
+ this.spinI = 0;
81
+ this.lastStatusText = undefined;
82
+ if (ctx) {
83
+ if (this.enabled) {
84
+ const theme = ctx.ui?.theme ?? safeFallbackTheme;
85
+ this.applyStatus(ctx, this.renderFinal(theme));
86
+ } else {
87
+ this.applyStatus(ctx, undefined);
88
+ }
89
+ }
90
+ }
91
+
92
+ private now(): number {
93
+ return Date.now();
94
+ }
95
+
96
+ private tokEst(ch: number): number {
97
+ return (ch >>> 2) + ((ch & 3) > 0 ? 1 : 0);
98
+ }
99
+
100
+ private winPush(tps: number, ms: number): void {
101
+ const b = this.winHead * 2;
102
+ this.winBuf[b] = tps;
103
+ this.winBuf[b + 1] = ms;
104
+ this.winHead = (this.winHead + 1) % TpsMeter.WINDOW_SIZE;
105
+ if (this.winLen < TpsMeter.WINDOW_SIZE) this.winLen++;
106
+ }
107
+
108
+ private atPush(tps: number): void {
109
+ this.atSum += tps;
110
+ if (this.atLen >= TpsMeter.ALLTIME_CAP) this.atSum -= this.atBuf[this.atHead];
111
+ this.atBuf[this.atHead] = tps;
112
+ this.atHead = (this.atHead + 1) % TpsMeter.ALLTIME_CAP;
113
+ if (this.atLen < TpsMeter.ALLTIME_CAP) this.atLen++;
114
+ }
115
+
116
+ private sparkPush(tps: number): void {
117
+ this.sparkBuf[this.sparkHead] = tps;
118
+ this.sparkHead = (this.sparkHead + 1) % TpsMeter.SPARK_LEN;
119
+ if (this.sparkLen < TpsMeter.SPARK_LEN) this.sparkLen++;
120
+ if (tps > this.sparkMax) this.sparkMax = tps;
121
+ if (this.sparkMax > 10) this.sparkMax *= 0.99;
122
+ this.sparkDirty = true;
123
+ }
124
+
125
+ winAvg(): number {
126
+ if (this.winLen === 0) return 0;
127
+ const cutoff = this.now() - TpsMeter.WINDOW_MS;
128
+ let sum = 0;
129
+ let n = 0;
130
+ const oldest = this.winLen < TpsMeter.WINDOW_SIZE ? 0 : this.winHead;
131
+ for (let i = 0; i < this.winLen; i++) {
132
+ const idx = (oldest + i) % TpsMeter.WINDOW_SIZE;
133
+ const b = idx * 2;
134
+ if (this.winBuf[b + 1] < cutoff) continue;
135
+ sum += this.winBuf[b];
136
+ n++;
137
+ }
138
+ return n === 0 ? 0 : sum / n;
139
+ }
140
+
141
+ atMean(): number {
142
+ return this.atLen === 0 ? 0 : this.atSum / this.atLen;
143
+ }
144
+
145
+ atP95(): number {
146
+ if (this.atLen === 0) return 0;
147
+ const tmp = new Float64Array(this.atLen);
148
+ const oldest = this.atLen < TpsMeter.ALLTIME_CAP ? 0 : this.atHead;
149
+ for (let i = 0; i < this.atLen; i++) tmp[i] = this.atBuf[(oldest + i) % TpsMeter.ALLTIME_CAP];
150
+ // Insertion sort
151
+ for (let i = 1; i < tmp.length; i++) {
152
+ const v = tmp[i];
153
+ let j = i - 1;
154
+ while (j >= 0 && tmp[j] > v) {
155
+ tmp[j + 1] = tmp[j];
156
+ j--;
157
+ }
158
+ tmp[j + 1] = v;
159
+ }
160
+ return tmp[Math.ceil(tmp.length * 0.95) - 1] || 0;
161
+ }
162
+
163
+ private fmt(v: number): string {
164
+ if (v < 10) return v.toFixed(1);
165
+ if (v < 100) return v.toFixed(0);
166
+ return `${Math.round(v)}`;
167
+ }
168
+
169
+ private speedColor(tps: number, text: string, theme: Theme): string {
170
+ if (tps >= TpsMeter.FAST) return theme.fg("success", text);
171
+ if (tps >= TpsMeter.MED) return theme.fg("warning", text);
172
+ if (tps === 0) return theme.fg("dim", text);
173
+ return theme.fg("error", text);
174
+ }
175
+
176
+ private spin(): string {
177
+ const s = TpsMeter.SPIN[this.spinI];
178
+ this.spinI = (this.spinI + 1) % TpsMeter.SPIN.length;
179
+ return s;
180
+ }
181
+
182
+ sparkline(theme: Theme, len = TpsMeter.SPARK_LEN): string {
183
+ len = Math.max(1, Math.min(TpsMeter.SPARK_LEN, Math.floor(len)));
184
+
185
+ if (this.sparkLen === 0) {
186
+ return theme.fg("dim", TpsMeter.TRACK.repeat(len));
187
+ }
188
+
189
+ const available = Math.min(this.sparkLen, len);
190
+ const vals = new Float64Array(available);
191
+ for (let i = 0; i < available; i++) {
192
+ const idx =
193
+ (this.sparkHead - available + i + TpsMeter.SPARK_LEN) %
194
+ TpsMeter.SPARK_LEN;
195
+ vals[i] = this.sparkBuf[idx];
196
+ }
197
+
198
+ let mn = Infinity;
199
+ let mx = 0;
200
+ for (let i = 0; i < available; i++) {
201
+ const v = vals[i];
202
+ if (v < mn) mn = v;
203
+ if (v > mx) mx = v;
204
+ }
205
+ const range = mx - mn;
206
+ const pad = len - available;
207
+ let result = pad > 0 ? theme.fg("dim", TpsMeter.TRACK.repeat(pad)) : "";
208
+
209
+ for (let i = 0; i < available; i++) {
210
+ const v = vals[i];
211
+ const norm =
212
+ range < 1e-6
213
+ ? mx > 0
214
+ ? 4
215
+ : 0
216
+ : Math.min(7, Math.max(0, Math.round(((v - mn) / range) * 7)));
217
+ const ch = TpsMeter.BLOCKS[norm];
218
+ result += this.speedColor(v, ch, theme);
219
+ }
220
+ return result;
221
+ }
222
+
223
+ gauge(tps: number, theme: Theme, gaugeLen = TpsMeter.GAUGE_LEN): string {
224
+ gaugeLen = Math.max(1, Math.floor(gaugeLen));
225
+ const scale = Math.max(this.sparkMax, TpsMeter.GAUGE_FLOOR);
226
+ let frac = scale > 0 ? tps / scale : 0;
227
+ if (frac < 0) frac = 0;
228
+ if (frac > 1) frac = 1;
229
+
230
+ const eighths = Math.round(frac * gaugeLen * 8);
231
+ const full = (eighths / 8) | 0;
232
+ const rem = eighths % 8;
233
+
234
+ let fill = "█".repeat(Math.min(gaugeLen, full));
235
+ let used = Math.min(gaugeLen, full);
236
+ if (full < gaugeLen && rem > 0) {
237
+ fill += TpsMeter.HBLOCKS[rem];
238
+ used++;
239
+ }
240
+ const track = TpsMeter.TRACK.repeat(Math.max(0, gaugeLen - used));
241
+
242
+ return (
243
+ theme.fg("dim", "▕") +
244
+ this.speedColor(tps, fill, theme) +
245
+ theme.fg("dim", track + "▏")
246
+ );
247
+ }
248
+
249
+ isStreaming(): boolean {
250
+ return this.streaming;
251
+ }
252
+
253
+ renderLive(theme: Theme, maxBudget?: number): string {
254
+ const ref = this.firstTokenMs > 0 ? this.firstTokenMs : this.streamStartMs;
255
+ const elapsed = (this.now() - ref) / 1000;
256
+ const tps = elapsed > 0.3 ? this.streamTokens / elapsed : 0;
257
+
258
+ const s = theme.fg("accent", this.spin());
259
+ const num = this.speedColor(tps, this.fmt(tps), theme);
260
+ const unit = theme.fg("dim", "tps");
261
+ const numUnit = `${num} ${unit}`;
262
+ const minCompact = `${s} ${numUnit}`;
263
+ const bareNumUnit = numUnit;
264
+
265
+ if (maxBudget == null) {
266
+ const g = this.gauge(tps, theme, TpsMeter.GAUGE_LEN);
267
+ return `${s} ${g} ${numUnit}`;
268
+ }
269
+
270
+ if (maxBudget < visibleWidth(bareNumUnit)) {
271
+ return "";
272
+ }
273
+
274
+ // Attempt full mode with gauge
275
+ const fixedOverhead = visibleWidth(s) + 1 + 2 + 1 + visibleWidth(numUnit);
276
+ const availableGaugeLen = maxBudget - fixedOverhead;
277
+
278
+ if (availableGaugeLen >= 3) {
279
+ const targetGaugeLen = Math.min(TpsMeter.GAUGE_LEN, availableGaugeLen);
280
+ const g = this.gauge(tps, theme, targetGaugeLen);
281
+ const fullStr = `${s} ${g} ${numUnit}`;
282
+ if (visibleWidth(fullStr) <= maxBudget) {
283
+ return fullStr;
284
+ }
285
+ }
286
+
287
+ if (maxBudget >= visibleWidth(minCompact)) {
288
+ return minCompact;
289
+ }
290
+
291
+ return bareNumUnit;
292
+ }
293
+
294
+ renderFinal(theme: Theme, maxBudget?: number): string {
295
+ const avg = this.winAvg();
296
+ const mu = this.atMean();
297
+ const p95 = this.atP95();
298
+
299
+ const a = this.speedColor(avg, this.fmt(avg), theme);
300
+ const label = theme.fg("dim", "tps");
301
+ const numUnit = `${a} ${label}`;
302
+
303
+ if (maxBudget == null) {
304
+ const sp = this.sparkline(theme, TpsMeter.SPARK_LEN);
305
+ const sep = theme.fg("dim", "·");
306
+ const m = `${theme.fg("dim", "μ")} ${this.speedColor(mu, this.fmt(mu), theme)}`;
307
+ const p = `${theme.fg("dim", "p95")} ${this.speedColor(p95, this.fmt(p95), theme)}`;
308
+ return `${sp} ${numUnit} ${sep} ${m} ${sep} ${p}`;
309
+ }
310
+
311
+ if (maxBudget < visibleWidth(numUnit)) {
312
+ return "";
313
+ }
314
+
315
+ // 1. Attempt full mode (sparkline 12 + numUnit + μ + p95)
316
+ const spFull = this.sparkline(theme, TpsMeter.SPARK_LEN);
317
+ const sep = theme.fg("dim", "·");
318
+ const m = `${theme.fg("dim", "μ")} ${this.speedColor(mu, this.fmt(mu), theme)}`;
319
+ const p = `${theme.fg("dim", "p95")} ${this.speedColor(p95, this.fmt(p95), theme)}`;
320
+ const l3 = `${spFull} ${numUnit} ${sep} ${m} ${sep} ${p}`;
321
+ if (visibleWidth(l3) <= maxBudget) {
322
+ return l3;
323
+ }
324
+
325
+ // 2. Dynamic sparkline (length 12 down to 2)
326
+ const fixedOverhead = 1 + visibleWidth(numUnit);
327
+ const availableSparkLen = maxBudget - fixedOverhead;
328
+
329
+ if (availableSparkLen >= 2) {
330
+ const targetSparkLen = Math.min(TpsMeter.SPARK_LEN, availableSparkLen);
331
+ const sp = this.sparkline(theme, targetSparkLen);
332
+ const l2 = `${sp} ${numUnit}`;
333
+ if (visibleWidth(l2) <= maxBudget) {
334
+ return l2;
335
+ }
336
+ }
337
+
338
+ // 3. Bare numUnit fallback
339
+ return numUnit;
340
+ }
341
+
342
+ renderAdaptive(theme: Theme, maxBudget: number): string {
343
+ if (!this.enabled) return "";
344
+ if (this.streaming) {
345
+ return this.renderLive(theme, maxBudget);
346
+ }
347
+ return this.renderFinal(theme, maxBudget);
348
+ }
349
+
350
+ private startTick(ctx: ExtensionContext): void {
351
+ if (this.tickTimer) return;
352
+ this.tickTimer = setInterval(() => {
353
+ if (!this.streaming || !this.enabled) {
354
+ this.stopTick(ctx);
355
+ return;
356
+ }
357
+ const theme = ctx.ui?.theme ?? safeFallbackTheme;
358
+ this.applyStatus(ctx, this.renderLive(theme));
359
+ }, TpsMeter.STREAM_INTERVAL_MS);
360
+ (this.tickTimer as any)?.unref?.();
361
+ }
362
+
363
+ stopTick(ctx?: ExtensionContext): void {
364
+ this.streaming = false;
365
+ if (this.tickTimer) {
366
+ clearInterval(this.tickTimer);
367
+ this.tickTimer = null;
368
+ }
369
+ if (ctx && this.enabled) {
370
+ const theme = ctx.ui?.theme ?? safeFallbackTheme;
371
+ this.applyStatus(ctx, this.renderFinal(theme));
372
+ }
373
+ }
374
+
375
+ onMessageStart(ctx: ExtensionContext): void {
376
+ if (!this.enabled) return;
377
+ this.streamStartMs = this.now();
378
+ this.firstTokenMs = 0;
379
+ this.streamChars = 0;
380
+ this.streamTokens = 0;
381
+ this.streaming = true;
382
+ this.spinI = 0;
383
+ this.startTick(ctx);
384
+ }
385
+
386
+ onMessageUpdate(evt?: { type: string; delta?: unknown }): void {
387
+ if (!this.enabled || !evt) return;
388
+ if (evt.type === "text_delta" || evt.type === "thinking_delta") {
389
+ const d = evt.delta as string;
390
+ if (!d) return;
391
+ if (this.firstTokenMs === 0) this.firstTokenMs = this.now();
392
+ this.streamChars += d.length;
393
+ this.streamTokens = this.tokEst(this.streamChars);
394
+ }
395
+ }
396
+
397
+ onMessageEnd(usageOutput: number | undefined, ctx: ExtensionContext): void {
398
+ if (!this.enabled) return;
399
+ this.streaming = false;
400
+ this.stopTick();
401
+
402
+ const realOut = usageOutput;
403
+ const tokens =
404
+ typeof realOut === "number" && realOut > 0 ? realOut : this.streamTokens;
405
+
406
+ const ref = this.firstTokenMs > 0 ? this.firstTokenMs : this.streamStartMs;
407
+ const elapsed = (this.now() - ref) / 1000;
408
+ if (elapsed >= 0.1 && tokens > 0) {
409
+ const tps = tokens / elapsed;
410
+ this.winPush(tps, this.now());
411
+ this.atPush(tps);
412
+ this.sparkPush(tps);
413
+ }
414
+
415
+ const theme = ctx.ui?.theme ?? safeFallbackTheme;
416
+ const txt = this.renderFinal(theme);
417
+ this.applyStatus(ctx, txt || undefined);
418
+ }
419
+
420
+ onToolStart(ctx: ExtensionContext): void {
421
+ this.stopTick(ctx);
422
+ }
423
+
424
+ onTurnEnd(ctx?: ExtensionContext): void {
425
+ this.stopTick(ctx);
426
+ }
427
+
428
+ onAgentEnd(ctx?: ExtensionContext): void {
429
+ this.stopTick(ctx);
430
+ }
431
+
432
+ onAgentSettled(ctx?: ExtensionContext): void {
433
+ this.stopTick(ctx);
434
+ }
435
+ }
436
+
437
+ export const safeFallbackTheme: Theme = {
438
+ fg: (_color: string, text: string) => text,
439
+ bg: (_color: string, text: string) => text,
440
+ bold: (text: string) => text,
441
+ } as unknown as Theme;
442
+
@@ -0,0 +1,35 @@
1
+ # subagent
2
+
3
+ Delegate tasks to independent, full-powered Pi workers. The `subagent` tool returns after launch; completion or questions are delivered back to the parent session asynchronously.
4
+
5
+ ## Install
6
+
7
+ ```sh
8
+ pi install npm:@rol1n/subagent
9
+ ```
10
+
11
+ Also included in `@rol1n/pi-kit`. The parent and workers require the `pi` executable on PATH and their normal Pi authentication.
12
+
13
+ ## Use
14
+
15
+ ```json
16
+ {"action":"spawn","name":"reviewer","cwd":"/existing/project","task":"Review the changed files and report findings."}
17
+ {"action":"send","target":"reviewer","task":"Also inspect the error handling."}
18
+ {"action":"stop","target":"reviewer"}
19
+ ```
20
+
21
+ `model` optionally selects a worker model. `/subagent` shows active workers. Each worker inherits its own normal Pi capabilities; this is a delegation runtime, not a restricted execution environment.
22
+
23
+ ## Backends
24
+
25
+ | Variable | Behavior |
26
+ |---|---|
27
+ | `PI_SUBAGENT_BACKEND=process` | Run silent background processes |
28
+ | `PI_SUBAGENT_MUX=tmux` | Prefer tmux when running inside an available tmux session |
29
+ | `PI_SUBAGENT_MUX=zellij` | Prefer Zellij when running inside an available Zellij session |
30
+
31
+ Without a usable multiplexer, the worker backend falls back to a background process. Native Windows supports PowerShell launch commands and Zellij panes; tmux is for POSIX environments. Session transcripts and task artifacts are stored under `~/.pi/agent/`.
32
+
33
+ ## Other delegation packages
34
+
35
+ Use [multi-harness](https://github.com/RolinShmily/pi-kit/tree/main/extensions/multi-harness) for Codex, Claude, or OpenCode peers. That package shares multiplexer helpers but provides a separate tool and does not automatically enable this extension's entrypoint. The bundled subagent skill file is reference material and is not registered by the package manifest.
@@ -0,0 +1,171 @@
1
+ /**
2
+ * subagent/bridge.ts — Subagent Worker Bridge
3
+ *
4
+ * Loaded exclusively inside the spawned worker Pi process via `-e <bridgePath>`.
5
+ * - Grants the worker an `ask_question` tool to consult the parent orchestrator.
6
+ * - Handles auto-exit on task completion (calling ctx.shutdown() so the pane closes cleanly).
7
+ * - Suppresses auto-exit while waiting for the parent's answer to `ask_question`.
8
+ * - Prevents recursive subagent spawning.
9
+ */
10
+
11
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
12
+ import { Box, Text } from "@earendil-works/pi-tui";
13
+ import { Type } from "typebox";
14
+ import { existsSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
15
+
16
+ export default function subagentBridge(pi: ExtensionAPI) {
17
+ if (process.env.PI_SUBAGENT_ACTIVE !== "1") {
18
+ return;
19
+ }
20
+
21
+ const workerName = process.env.PI_SUBAGENT_NAME || "worker";
22
+ const sessionFile = process.env.PI_SUBAGENT_SESSION;
23
+
24
+ let awaitingAnswer = false;
25
+
26
+ // Render a minimal indicator in the child worker's terminal
27
+ pi.on("session_start", (_event, ctx) => {
28
+ ctx.ui.setWidget(
29
+ "subagent-worker-banner",
30
+ (_tui, theme) => {
31
+ const box = new Box(1, 0, (text: string) => {
32
+ try {
33
+ return theme.bg("toolSuccessBg", text);
34
+ } catch {
35
+ return text;
36
+ }
37
+ });
38
+ const badge = theme.bold(theme.fg("accent", `[Worker: ${workerName}]`));
39
+ const hint = theme.fg("dim", " · Full Pi worker · ask_question available");
40
+ box.addChild(new Text(`${badge}${hint}`, 0, 0));
41
+ return box;
42
+ },
43
+ { placement: "aboveEditor" },
44
+ );
45
+ });
46
+
47
+ // When input arrives (e.g. parent reply or manual steer), answer is received
48
+ pi.on("input", () => {
49
+ awaitingAnswer = false;
50
+ });
51
+
52
+ pi.on("agent_start", () => {
53
+ awaitingAnswer = false;
54
+ });
55
+
56
+ pi.on("agent_end", (event, ctx) => {
57
+ if (awaitingAnswer) {
58
+ // Park in waiting state, do not exit
59
+ return;
60
+ }
61
+
62
+ const messages = (event as any).messages as any[] | undefined;
63
+ let errorMessage: string | undefined;
64
+
65
+ if (messages && messages.length > 0) {
66
+ for (let i = messages.length - 1; i >= 0; i--) {
67
+ const msg = messages[i];
68
+ if (msg?.role === "assistant") {
69
+ if (msg.stopReason === "error") {
70
+ errorMessage = typeof msg.errorMessage === "string" ? msg.errorMessage : "Agent ended with error";
71
+ }
72
+ break;
73
+ }
74
+ }
75
+ }
76
+
77
+ if (sessionFile) {
78
+ try {
79
+ writeFileSync(
80
+ `${sessionFile}.exit`,
81
+ JSON.stringify({
82
+ type: errorMessage ? "error" : "done",
83
+ ...(errorMessage ? { errorMessage } : {}),
84
+ }),
85
+ );
86
+ } catch {
87
+ // best effort
88
+ }
89
+ }
90
+
91
+ ctx.shutdown();
92
+ });
93
+
94
+ // Register `ask_question` tool
95
+ pi.registerTool({
96
+ name: "ask_question",
97
+ label: "Ask Orchestrator",
98
+ description:
99
+ "Ask the orchestrator (the parent agent that spawned you) a clarifying question and pause until they reply. " +
100
+ "Use when requirements are ambiguous, a critical decision needs confirmation, or you are blocked. " +
101
+ "Your session stays open while you wait — the answer will arrive as your next turn message.",
102
+ parameters: Type.Object({
103
+ question: Type.String({
104
+ description: "The question for the parent orchestrator. Include enough context for them to decide.",
105
+ }),
106
+ }),
107
+ async execute(_toolCallId, params, signal) {
108
+ if (!sessionFile) {
109
+ throw new Error("ask_question is only available within a managed subagent session.");
110
+ }
111
+
112
+ awaitingAnswer = true;
113
+ const askFile = `${sessionFile}.ask`;
114
+ const replyFile = `${sessionFile}.reply`;
115
+
116
+ const askData = {
117
+ name: workerName,
118
+ question: params.question,
119
+ timestamp: new Date().toISOString(),
120
+ };
121
+
122
+ writeFileSync(askFile, JSON.stringify(askData));
123
+
124
+ try {
125
+ while (!existsSync(replyFile)) {
126
+ if (signal?.aborted) {
127
+ throw new Error("Cancelled while waiting for parent reply");
128
+ }
129
+ await new Promise<void>((resolve, reject) => {
130
+ const timer = setTimeout(resolve, 200);
131
+ if (signal) {
132
+ const onAbort = () => {
133
+ clearTimeout(timer);
134
+ signal.removeEventListener("abort", onAbort);
135
+ reject(new Error("Cancelled while waiting for parent reply"));
136
+ };
137
+ signal.addEventListener("abort", onAbort, { once: true });
138
+ }
139
+ });
140
+ }
141
+
142
+ const reply = readFileSync(replyFile, "utf-8");
143
+ try {
144
+ unlinkSync(replyFile);
145
+ } catch {}
146
+ awaitingAnswer = false;
147
+
148
+ return {
149
+ content: [
150
+ {
151
+ type: "text",
152
+ text: `Orchestrator answer:\n\n${reply}`,
153
+ },
154
+ ],
155
+ details: { question: params.question, reply },
156
+ };
157
+ } finally {
158
+ awaitingAnswer = false;
159
+ try {
160
+ if (existsSync(askFile)) unlinkSync(askFile);
161
+ } catch {}
162
+ }
163
+ },
164
+ renderCall(args, theme) {
165
+ const text =
166
+ theme.fg("toolTitle", theme.bold("ask_question ")) +
167
+ theme.fg("muted", String((args as any).question ?? ""));
168
+ return new Text(text, 0, 0);
169
+ },
170
+ });
171
+ }