pi-jar 0.1.6 → 0.1.8
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/README.md +19 -7
- package/docs/DIFF-REVIEW.md +14 -0
- package/docs/SUBAGENT-TOOLS.md +24 -0
- package/docs/WORKFLOWS.md +2 -2
- package/extensions/index.ts +21 -23
- package/package.json +1 -1
- package/src/advisor.ts +17 -8
- package/src/async-process.ts +144 -0
- package/src/changes.ts +235 -28
- package/src/commit.ts +5 -6
- package/src/delegate-worktree.ts +135 -58
- package/src/delegate.ts +291 -88
- package/src/diff-inline.ts +80 -0
- package/src/diff-view.ts +292 -44
- package/src/discussion.ts +59 -21
- package/src/quota.ts +12 -1
- package/src/shell-notifications.ts +104 -0
- package/src/shells.ts +130 -26
- package/src/subagent-tools.ts +45 -0
- package/src/working.ts +3 -5
package/src/discussion.ts
CHANGED
|
@@ -88,9 +88,10 @@ async function withPaperLock<T>(file: string, run: () => T): Promise<T> {
|
|
|
88
88
|
throw new Error("discussion paper is busy");
|
|
89
89
|
}
|
|
90
90
|
|
|
91
|
-
/** Append under the lock; `accept` validates against the same snapshot, so there is one read per write.
|
|
91
|
+
/** Append under the lock; `accept` validates against the same snapshot, so there is one read per write.
|
|
92
|
+
* The written snapshot is returned too, so the caller can piggyback unseen entries without a second read. */
|
|
92
93
|
function append(file: string, actor: string, entry: Omit<DiscussionEntry, "id" | "from" | "at">,
|
|
93
|
-
accept: (paper: DiscussionPaper) => string | undefined = () => undefined): Promise<DiscussionEntry | string> {
|
|
94
|
+
accept: (paper: DiscussionPaper) => string | undefined = () => undefined): Promise<{ entry: DiscussionEntry; paper: DiscussionPaper } | string> {
|
|
94
95
|
return withPaperLock(file, () => {
|
|
95
96
|
const paper = readPaper(file);
|
|
96
97
|
const rejected = accept(paper);
|
|
@@ -106,7 +107,7 @@ function append(file: string, actor: string, entry: Omit<DiscussionEntry, "id" |
|
|
|
106
107
|
paper.entries.push(next);
|
|
107
108
|
if (paper.entries.length > MAX_DISCUSSION_ENTRIES) paper.entries.splice(0, paper.entries.length - MAX_DISCUSSION_ENTRIES);
|
|
108
109
|
writePaper(file, paper);
|
|
109
|
-
return next;
|
|
110
|
+
return { entry: next, paper };
|
|
110
111
|
});
|
|
111
112
|
}
|
|
112
113
|
|
|
@@ -133,57 +134,94 @@ const render = (entries: readonly DiscussionEntry[], limit: number): string => {
|
|
|
133
134
|
}
|
|
134
135
|
const omitted = entries.length - lines.length;
|
|
135
136
|
return (omitted ? `(${omitted} older entries omitted; use questionId to read one thread)\n` : "") + lines.join("\n")
|
|
136
|
-
+ `\n(
|
|
137
|
+
+ `\n(latest ${entries.at(-1)!.id})`;
|
|
137
138
|
};
|
|
138
139
|
|
|
140
|
+
/** Unseen entries piggybacked on an ask/answer reply never exceed this. */
|
|
141
|
+
const MAX_PIGGYBACK_CHARS = 1000;
|
|
142
|
+
|
|
139
143
|
export function registerDiscussionTool(pi: ExtensionAPI, file: () => string | undefined,
|
|
140
144
|
actor: () => string = () => safeActor(process.env[SUBAGENT_NAME_ENV] ?? process.env[SUBAGENT_KEY_ENV])): void {
|
|
145
|
+
// Every Pi process (moderator or child) registers its own tool, so this closure is one actor's read
|
|
146
|
+
// cursor: the highest seq it has been shown on the current paper. A different path, or a paper whose
|
|
147
|
+
// seq went backwards (recreated), starts over at zero.
|
|
148
|
+
let cursor = { path: "", seq: 0 };
|
|
149
|
+
const unseen = (path: string, paper: DiscussionPaper, me: string) => {
|
|
150
|
+
if (cursor.path !== path || paper.seq < cursor.seq) cursor = { path, seq: 0 };
|
|
151
|
+
const after = cursor.seq;
|
|
152
|
+
return paper.entries.filter((entry) => (seqOf(entry.id) ?? 0) > after && entry.from !== me);
|
|
153
|
+
};
|
|
154
|
+
const advance = (seq: number) => { cursor.seq = Math.max(cursor.seq, seq); };
|
|
155
|
+
// The cursor is one max-seq, so it may only move past everything unseen. An ask/answer reply therefore
|
|
156
|
+
// attaches unseen entries (and advances) only when every unseen non-own entry is relevant to the caller
|
|
157
|
+
// (a question addressed to it, or an answer to one of its questions) and all of them fit
|
|
158
|
+
// MAX_PIGGYBACK_CHARS; otherwise it adds a one-line count and leaves them for `list`. The question being
|
|
159
|
+
// answered (`known`) counts as seen: the caller already has it.
|
|
160
|
+
const piggyback = (path: string, paper: DiscussionPaper, me: string, known?: string): string => {
|
|
161
|
+
const fresh = unseen(path, paper, me).filter((entry) => entry.id !== known);
|
|
162
|
+
if (!fresh.length) { advance(paper.seq); return ""; }
|
|
163
|
+
const asked = new Set(paper.entries.filter((entry) => entry.kind === "question" && entry.from === me).map((entry) => entry.id));
|
|
164
|
+
const relevant = fresh.filter((entry) => entry.kind === "question" ? entry.to === me : asked.has(entry.questionId ?? ""));
|
|
165
|
+
const shown = relevant.map((entry) => entryLine(entry, MAX_LISTED_TEXT)).join("\n");
|
|
166
|
+
if (relevant.length === fresh.length && shown.length <= MAX_PIGGYBACK_CHARS) { advance(paper.seq); return "\n" + shown; }
|
|
167
|
+
return `\n(${fresh.length} new ${fresh.length === 1 ? "entry" : "entries"}${relevant.length ? `, ${relevant.length} for you` : ""}; list to read)`;
|
|
168
|
+
};
|
|
141
169
|
const parameters = Type.Object({
|
|
142
170
|
action: Type.Union([Type.Literal("list"), Type.Literal("ask"), Type.Literal("answer")]),
|
|
143
171
|
text: Type.Optional(Type.String()),
|
|
144
172
|
to: Type.Optional(Type.String()),
|
|
145
173
|
questionId: Type.Optional(Type.String({ description: "answer: the question being answered. list: read only that question and its answers, in full." })),
|
|
146
|
-
since: Type.Optional(Type.String({ description: "list:
|
|
174
|
+
since: Type.Optional(Type.String({ description: "list: entries newer than this id (e.g. d12) instead of only unseen ones; \"d0\" rereads everything." }))
|
|
147
175
|
});
|
|
148
176
|
pi.registerTool?.<typeof parameters, { path?: string; entry?: DiscussionEntry }>({
|
|
149
177
|
name: "jar_discuss",
|
|
150
178
|
label: "discuss",
|
|
151
|
-
description: `
|
|
152
|
-
promptSnippet: "
|
|
179
|
+
description: `Shared bounded Q/A paper between this session's agents; not a transcript. ask/answer reply with the new id only. list returns only entries you have not seen (≤${MAX_LIST_CHARS} chars).`,
|
|
180
|
+
promptSnippet: "Ask or answer one concrete cross-agent question on the shared discussion paper.",
|
|
153
181
|
promptGuidelines: [
|
|
154
|
-
"
|
|
155
|
-
"
|
|
156
|
-
"Do not poll list while waiting for an answer: continue your task and check again later with since set to the newest id you saw; use questionId to read one thread."
|
|
182
|
+
"jar_discuss: one precise question or answer per call; answer only the given questionId. Replies never echo your text.",
|
|
183
|
+
"Answers to your questions also arrive on your next ask/answer. list shows only unseen entries; questionId reads one thread. Don't poll."
|
|
157
184
|
],
|
|
158
185
|
parameters,
|
|
159
186
|
async execute(_id, params) {
|
|
160
187
|
const path = file();
|
|
161
188
|
if (!path) return { content: [{ type: "text", text: "No shared discussion paper is active." }], details: {} };
|
|
189
|
+
const me = safeActor(actor());
|
|
162
190
|
if (params.action === "list") {
|
|
163
|
-
const
|
|
191
|
+
const paper = readPaper(path);
|
|
164
192
|
const thread = params.questionId ? cleanText(params.questionId, 24) : undefined;
|
|
165
193
|
if (thread) {
|
|
166
|
-
const items = entries.filter((entry) => entry.id === thread || entry.questionId === thread);
|
|
194
|
+
const items = paper.entries.filter((entry) => entry.id === thread || entry.questionId === thread);
|
|
167
195
|
return { content: [{ type: "text", text: render(items, MAX_DISCUSSION_TEXT) }], details: { path } };
|
|
168
196
|
}
|
|
169
197
|
const since = seqOf(params.since);
|
|
170
|
-
const
|
|
171
|
-
|
|
172
|
-
|
|
198
|
+
const fresh = unseen(path, paper, me);
|
|
199
|
+
advance(paper.seq);
|
|
200
|
+
if (since !== undefined) {
|
|
201
|
+
const newer = paper.entries.filter((entry) => (seqOf(entry.id) ?? 0) > since);
|
|
202
|
+
const text = newer.length ? render(newer, MAX_LISTED_TEXT) : `No entries newer than ${params.since}.`;
|
|
203
|
+
return { content: [{ type: "text", text }], details: { path } };
|
|
204
|
+
}
|
|
205
|
+
const latest = paper.entries.at(-1)?.id;
|
|
206
|
+
const text = fresh.length ? render(fresh, MAX_LISTED_TEXT) : `No new entries${latest ? ` (latest ${latest})` : ""}.`;
|
|
207
|
+
return { content: [{ type: "text", text }], details: { path } };
|
|
173
208
|
}
|
|
174
209
|
const text = cleanText(params.text ?? "", MAX_DISCUSSION_TEXT);
|
|
175
210
|
if (!text) return { content: [{ type: "text", text: "Discussion text is required." }], details: {}, isError: true };
|
|
176
211
|
if (params.action === "ask") {
|
|
177
|
-
const
|
|
178
|
-
if (typeof
|
|
179
|
-
|
|
212
|
+
const added = await append(path, me, { kind: "question", text, ...(params.to ? { to: safeActor(params.to) } : {}) });
|
|
213
|
+
if (typeof added === "string") return { content: [{ type: "text", text: added }], details: {}, isError: true };
|
|
214
|
+
const { entry, paper } = added;
|
|
215
|
+
const reply = `Added ${entry.id}${entry.to ? ` (→ ${entry.to})` : ""}.` + piggyback(path, paper, me);
|
|
216
|
+
return { content: [{ type: "text", text: reply }], details: { path, entry } };
|
|
180
217
|
}
|
|
181
218
|
const questionId = cleanText(params.questionId ?? "", 24);
|
|
182
219
|
if (!questionId) return { content: [{ type: "text", text: "questionId is required for an answer." }], details: {}, isError: true };
|
|
183
|
-
const
|
|
220
|
+
const added = await append(path, me, { kind: "answer", questionId, text }, (paper) =>
|
|
184
221
|
paper.entries.some((item) => item.kind === "question" && item.id === questionId) ? undefined : "Unknown discussion question: " + questionId);
|
|
185
|
-
if (typeof
|
|
186
|
-
|
|
222
|
+
if (typeof added === "string") return { content: [{ type: "text", text: added }], details: {}, isError: true };
|
|
223
|
+
const { entry, paper } = added;
|
|
224
|
+
return { content: [{ type: "text", text: `Answered ${questionId} as ${entry.id}.` + piggyback(path, paper, me, questionId) }], details: { path, entry } };
|
|
187
225
|
}
|
|
188
226
|
});
|
|
189
227
|
}
|
package/src/quota.ts
CHANGED
|
@@ -84,7 +84,18 @@ export async function fetchQuota(provider: QuotaProvider, resolveAuth: (provider
|
|
|
84
84
|
const item = window as Record<string, unknown>;
|
|
85
85
|
return windowValue({ used: item.used_percent, resetsAt: typeof item.reset_at === "number" ? item.reset_at * 1000 : undefined });
|
|
86
86
|
};
|
|
87
|
-
|
|
87
|
+
// Window slots are not fixed: Plus accounts get a single weekly `primary_window` and a null
|
|
88
|
+
// secondary. Classify by `limit_window_seconds` (≤ 1 day is the short window), falling back
|
|
89
|
+
// to position only when the duration is absent.
|
|
90
|
+
const quota: Quota = {};
|
|
91
|
+
for (const [window, fallback] of [[rate.primary_window, "fiveHour"], [rate.secondary_window, "week"]] as const) {
|
|
92
|
+
const value = parse(window);
|
|
93
|
+
if (!value) continue;
|
|
94
|
+
const seconds = (window as Record<string, unknown>).limit_window_seconds;
|
|
95
|
+
const slot = typeof seconds === "number" && seconds > 0 ? (seconds <= 24 * 60 * 60 ? "fiveHour" : "week") : fallback;
|
|
96
|
+
if (!quota[slot]) quota[slot] = value;
|
|
97
|
+
}
|
|
98
|
+
return parseQuota(quota);
|
|
88
99
|
}
|
|
89
100
|
const parse = (window: unknown): QuotaWindow | undefined => {
|
|
90
101
|
if (!window || typeof window !== "object") return undefined;
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import type { ExtensionAPI, CustomMessageEntryDraft } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { Text } from "@earendil-works/pi-tui";
|
|
3
|
+
import { cleanText } from "./status.ts";
|
|
4
|
+
import { describe, SHELL_MESSAGE, type ShellEvent, type ShellManager } from "./shells.ts";
|
|
5
|
+
|
|
6
|
+
export interface ShellNotificationDetails {
|
|
7
|
+
summary: string;
|
|
8
|
+
ids: string[];
|
|
9
|
+
events: Array<{ id: string; name: string; kind: ShellEvent["kind"]; status: string; exitCode?: number | null }>;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** A compact transport payload; logs are fetched explicitly, never copied into wake-up messages. */
|
|
13
|
+
export function shellNotification(events: readonly ShellEvent[]): CustomMessageEntryDraft {
|
|
14
|
+
const failed = events.filter(({ kind, job }) => kind === "exit" && (job.status === "failed" || (job.status === "exited" && job.exitCode !== 0))).length;
|
|
15
|
+
const ready = events.filter(event => event.kind === "match").length;
|
|
16
|
+
const completed = events.length - ready;
|
|
17
|
+
const summary = `Background shells · ${completed} completed${failed ? ` · ${failed} failed` : ""}${ready ? ` · ${ready} ready` : ""}`;
|
|
18
|
+
const details: ShellNotificationDetails = {
|
|
19
|
+
summary, ids: events.map(event => event.job.id),
|
|
20
|
+
events: events.map(({ kind, job }) => ({ id: job.id, name: job.name, kind, status: job.status, exitCode: job.exitCode }))
|
|
21
|
+
};
|
|
22
|
+
return {
|
|
23
|
+
type: "custom_message", customType: SHELL_MESSAGE, display: true, details,
|
|
24
|
+
content: [summary, ...events.map(({ kind, job }) => kind === "match" ? `${job.id} · ${job.name} · ready: ${cleanText(job.matched ?? "", 120)}` : describe(job)),
|
|
25
|
+
"Use jar_shell output for relevant diagnostics only; observed completions do not replay."].join("\n")
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Keep events in our own queue while the model runs, not Pi's irrevocable follow-up queue.
|
|
30
|
+
* Boundary entries are consumed before final settlement; output/peek/wait can acknowledge events
|
|
31
|
+
* before delivery. Idle events wake once after a debounce, and session replacement drops old work. */
|
|
32
|
+
export function registerShellNotifications(pi: ExtensionAPI, manager: () => ShellManager | undefined): { notify(): void; dispose(): void } {
|
|
33
|
+
let active = false;
|
|
34
|
+
let interrupted = false;
|
|
35
|
+
let deliveryFailures = 0;
|
|
36
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
37
|
+
const warned = new Set<string>();
|
|
38
|
+
const cancelTimer = () => { clearTimeout(timer); timer = undefined; };
|
|
39
|
+
const take = () => {
|
|
40
|
+
cancelTimer();
|
|
41
|
+
const events = manager()?.takeNotifications() ?? [];
|
|
42
|
+
return events.length ? shellNotification(events) : undefined;
|
|
43
|
+
};
|
|
44
|
+
const notify = () => {
|
|
45
|
+
if (active || interrupted || timer || !manager()?.pendingNotifications()) return;
|
|
46
|
+
const owner = manager();
|
|
47
|
+
timer = setTimeout(() => {
|
|
48
|
+
timer = undefined;
|
|
49
|
+
if (active || interrupted || owner !== manager()) return;
|
|
50
|
+
const events = owner?.takeNotifications() ?? [];
|
|
51
|
+
if (!events.length) return;
|
|
52
|
+
const { type: _type, ...message } = shellNotification(events);
|
|
53
|
+
try { pi.sendMessage(message, { triggerTurn: true, deliverAs: "nextTurn" }); deliveryFailures = 0; }
|
|
54
|
+
catch (error) {
|
|
55
|
+
owner?.restoreNotifications(events);
|
|
56
|
+
console.error("pi-jar: could not deliver shell summary", error);
|
|
57
|
+
if (++deliveryFailures < 3) notify();
|
|
58
|
+
}
|
|
59
|
+
}, 300);
|
|
60
|
+
timer.unref?.();
|
|
61
|
+
};
|
|
62
|
+
const reset = () => { cancelTimer(); active = false; interrupted = false; deliveryFailures = 0; warned.clear(); };
|
|
63
|
+
|
|
64
|
+
pi.registerMessageRenderer?.<ShellNotificationDetails>(SHELL_MESSAGE, (message, { expanded }, theme) => {
|
|
65
|
+
const text = typeof message.content === "string" ? message.content : "Background shells";
|
|
66
|
+
const summary = message.details?.summary ?? text.split("\n", 1)[0] ?? "Background shells";
|
|
67
|
+
return new Text(expanded ? text : theme.fg("accent", cleanText(summary, 160)) + theme.fg("dim", " · expand for status"), 0, 0);
|
|
68
|
+
});
|
|
69
|
+
pi.on?.("session_start", reset);
|
|
70
|
+
pi.on?.("session_shutdown", reset);
|
|
71
|
+
pi.on?.("before_agent_start", () => {
|
|
72
|
+
active = true;
|
|
73
|
+
interrupted = false;
|
|
74
|
+
const entry = take();
|
|
75
|
+
if (!entry) return;
|
|
76
|
+
const { type: _type, ...message } = entry;
|
|
77
|
+
return { message };
|
|
78
|
+
});
|
|
79
|
+
pi.on?.("agent_start", () => { active = true; cancelTimer(); });
|
|
80
|
+
pi.on?.("turn_end", event => {
|
|
81
|
+
if (event.outcome !== "completed" || event.context?.canContinue === false) return;
|
|
82
|
+
const entry = take();
|
|
83
|
+
if (entry) return { entries: [entry], continue: true };
|
|
84
|
+
});
|
|
85
|
+
pi.on?.("agent_before_settle", event => {
|
|
86
|
+
interrupted = event.outcome !== "completed";
|
|
87
|
+
if (interrupted || event.context?.canContinue === false) return;
|
|
88
|
+
const entry = take();
|
|
89
|
+
if (entry) return { entries: [entry], continue: true };
|
|
90
|
+
// One reminder per outstanding finite job, never a self-repeating continuation loop.
|
|
91
|
+
const owner = manager();
|
|
92
|
+
const known = new Set(owner?.summaries().map(job => job.id) ?? []);
|
|
93
|
+
for (const id of warned) if (!known.has(id)) warned.delete(id);
|
|
94
|
+
const pending = (owner?.verificationPending() ?? []).filter(job => !warned.has(job.id));
|
|
95
|
+
if (!pending.length) return;
|
|
96
|
+
for (const job of pending) warned.add(job.id);
|
|
97
|
+
return { entries: [{ type: "custom_message" as const, customType: SHELL_MESSAGE, display: true,
|
|
98
|
+
content: `Verification still pending: ${pending.map(job => `${job.id} (${job.name})`).join(", ")}. Use jar_shell wait/peek for these ids before claiming success, or clearly report them as pending. Do not wait for long-lived services.`,
|
|
99
|
+
details: { summary: `${pending.length} background check(s) still pending`, ids: pending.map(job => job.id) }
|
|
100
|
+
}], continue: true };
|
|
101
|
+
});
|
|
102
|
+
pi.on?.("agent_settled", () => { active = false; notify(); });
|
|
103
|
+
return { notify, dispose: reset };
|
|
104
|
+
}
|
package/src/shells.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { spawn, type ChildProcess } from "node:child_process";
|
|
2
|
+
import { signalProcessTree } from "./async-process.ts";
|
|
2
3
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
3
4
|
import { Text } from "@earendil-works/pi-tui";
|
|
4
5
|
import { Type } from "typebox";
|
|
@@ -30,6 +31,8 @@ export interface ShellJob {
|
|
|
30
31
|
signal?: string | null;
|
|
31
32
|
error?: string;
|
|
32
33
|
watch?: string;
|
|
34
|
+
purpose?: "task" | "service";
|
|
35
|
+
complete?: boolean;
|
|
33
36
|
matched?: string;
|
|
34
37
|
/** Wake the agent when the pattern matches or the process ends. */
|
|
35
38
|
notify: boolean;
|
|
@@ -38,14 +41,27 @@ export interface ShellJob {
|
|
|
38
41
|
dropped: number;
|
|
39
42
|
}
|
|
40
43
|
export type ShellEvent = { kind: "match" | "exit"; job: ShellJob };
|
|
41
|
-
export interface StartOptions { command: string; cwd: string; name?: string; watch?: string; notify?: boolean }
|
|
44
|
+
export interface StartOptions { command: string; cwd: string; name?: string; watch?: string; notify?: boolean; purpose?: "task" | "service" }
|
|
42
45
|
|
|
43
46
|
const ANSI = /\x1b\][^\x07]*(?:\x07|\x1b\\)?|\x1b\[[0-?]*[ -/]*[@-~]|\x1b./g;
|
|
44
47
|
|
|
48
|
+
interface ShellEntry {
|
|
49
|
+
job: ShellJob;
|
|
50
|
+
child?: ChildProcess;
|
|
51
|
+
pattern?: RegExp;
|
|
52
|
+
partial: string;
|
|
53
|
+
timer?: ReturnType<typeof setTimeout>;
|
|
54
|
+
closeTimer?: ReturnType<typeof setTimeout>;
|
|
55
|
+
finished?: boolean;
|
|
56
|
+
}
|
|
57
|
+
|
|
45
58
|
/** Long-running shell commands with bounded output and optional pattern watches. */
|
|
46
59
|
export class ShellManager {
|
|
47
|
-
private jobs = new Map<string,
|
|
60
|
+
private jobs = new Map<string, ShellEntry>();
|
|
48
61
|
private next = 1;
|
|
62
|
+
private pending = new Map<string, ShellEvent>();
|
|
63
|
+
private listeners = new Set<() => void>();
|
|
64
|
+
private disposed = false;
|
|
49
65
|
private lineChars = new Map<string, number>();
|
|
50
66
|
private readonly onEvent: (event: ShellEvent) => void;
|
|
51
67
|
private readonly spawnShell: typeof spawn;
|
|
@@ -61,6 +77,44 @@ export class ShellManager {
|
|
|
61
77
|
});
|
|
62
78
|
}
|
|
63
79
|
get(id: string): ShellJob | undefined { const entry = this.jobs.get(id); return entry && { ...entry.job, lines: [...entry.job.lines] }; }
|
|
80
|
+
acknowledge(ids: readonly string[]): void { for (const id of ids) this.pending.delete(id); }
|
|
81
|
+
takeNotifications(): ShellEvent[] { const events = [...this.pending.values()]; this.pending.clear(); return events; }
|
|
82
|
+
restoreNotifications(events: readonly ShellEvent[]): void {
|
|
83
|
+
for (const event of events) if (this.jobs.has(event.job.id) && !this.pending.has(event.job.id)) this.pending.set(event.job.id, event);
|
|
84
|
+
}
|
|
85
|
+
pendingNotifications(): number { return this.pending.size; }
|
|
86
|
+
verificationPending(): Array<Omit<ShellJob, "lines">> {
|
|
87
|
+
return [...this.jobs.values()].filter(entry => !entry.finished && entry.job.purpose !== "service").map(({ job }) => {
|
|
88
|
+
const { lines: _lines, ...summary } = job;
|
|
89
|
+
return summary;
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
private emitEvent(kind: ShellEvent["kind"], job: ShellJob): void {
|
|
93
|
+
if (!this.jobs.has(job.id) || this.disposed) return;
|
|
94
|
+
// Terminal state supersedes readiness; queue metadata only, not duplicated log buffers.
|
|
95
|
+
if (job.notify) this.pending.set(job.id, { kind, job: { ...job, lines: [] } });
|
|
96
|
+
this.onEvent({ kind, job: { ...job, lines: job.lines.slice(-20) } });
|
|
97
|
+
}
|
|
98
|
+
async wait(ids: readonly string[], timeoutMs = 1000, signal?: AbortSignal): Promise<boolean> {
|
|
99
|
+
if (!Number.isFinite(timeoutMs) || timeoutMs < 0 || timeoutMs > 30_000) throw new Error("waitMs must be between 0 and 30000");
|
|
100
|
+
for (const id of ids) if (!this.jobs.has(id)) throw new Error("no shell " + id);
|
|
101
|
+
if (this.disposed) throw new Error("shell session disposed");
|
|
102
|
+
if (signal?.aborted) throw signal.reason ?? new Error("wait cancelled");
|
|
103
|
+
const ready = () => ids.every(id => this.jobs.get(id)?.finished === true);
|
|
104
|
+
if (ready() || timeoutMs === 0) return ready();
|
|
105
|
+
return new Promise<boolean>((resolve, reject) => {
|
|
106
|
+
const cleanup = () => { clearTimeout(timer); this.listeners.delete(check); signal?.removeEventListener("abort", abort); };
|
|
107
|
+
const check = () => {
|
|
108
|
+
if (this.disposed || ids.some(id => !this.jobs.has(id))) { cleanup(); reject(new Error("shell session disposed or result expired")); }
|
|
109
|
+
else if (ready()) { cleanup(); resolve(true); }
|
|
110
|
+
};
|
|
111
|
+
const abort = () => { cleanup(); reject(signal?.reason ?? new Error("wait cancelled")); };
|
|
112
|
+
const timer = setTimeout(() => { cleanup(); resolve(false); }, timeoutMs);
|
|
113
|
+
this.listeners.add(check);
|
|
114
|
+
signal?.addEventListener("abort", abort, { once: true });
|
|
115
|
+
if (signal?.aborted) abort(); else check();
|
|
116
|
+
});
|
|
117
|
+
}
|
|
64
118
|
running(): number {
|
|
65
119
|
let count = 0;
|
|
66
120
|
for (const { job } of this.jobs.values()) if (job.status === "running") count++;
|
|
@@ -68,6 +122,7 @@ export class ShellManager {
|
|
|
68
122
|
}
|
|
69
123
|
|
|
70
124
|
start(options: StartOptions): ShellJob {
|
|
125
|
+
if (this.disposed) throw new Error("shell session disposed");
|
|
71
126
|
const command = options.command.trim();
|
|
72
127
|
if (!command) throw new Error("command is required");
|
|
73
128
|
if (this.running() >= MAX_RUNNING_SHELLS) throw new Error(`at most ${MAX_RUNNING_SHELLS} shells can run at once; kill one first`);
|
|
@@ -79,15 +134,17 @@ export class ShellManager {
|
|
|
79
134
|
this.prune();
|
|
80
135
|
const id = "s" + this.next++;
|
|
81
136
|
const job: ShellJob = { id, name: cleanText(options.name || command, 40), command, cwd: options.cwd, startedAt: Date.now(), status: "running",
|
|
82
|
-
notify: options.notify ?? true, lines: [], dropped: 0, ...(options.watch ? { watch: options.watch } : {}) };
|
|
83
|
-
const entry:
|
|
137
|
+
notify: options.notify ?? true, complete: false, purpose: options.purpose ?? (options.watch ? "service" : "task"), lines: [], dropped: 0, ...(options.watch ? { watch: options.watch } : {}) };
|
|
138
|
+
const entry: ShellEntry = { job, partial: "", ...(pattern ? { pattern } : {}) };
|
|
84
139
|
this.jobs.set(id, entry);
|
|
85
140
|
this.lineChars.set(id, 0);
|
|
86
141
|
// Own process group so kill() stops the whole tree (dev servers spawn children).
|
|
87
142
|
const child = this.spawnShell("/bin/sh", ["-c", command], { cwd: options.cwd, detached: process.platform !== "win32", stdio: ["ignore", "pipe", "pipe"], env: process.env });
|
|
88
143
|
entry.child = child;
|
|
89
144
|
if (child.pid !== undefined) job.pid = child.pid;
|
|
90
|
-
const receive = (chunk: Buffer | string) => this.receive(entry, String(chunk));
|
|
145
|
+
const receive = (chunk: Buffer | string) => { if (!entry.finished) this.receive(entry, String(chunk)); };
|
|
146
|
+
child.stdout?.setEncoding?.("utf8");
|
|
147
|
+
child.stderr?.setEncoding?.("utf8");
|
|
91
148
|
child.stdout?.on("data", receive);
|
|
92
149
|
child.stderr?.on("data", receive);
|
|
93
150
|
child.on("error", (error) => {
|
|
@@ -97,13 +154,31 @@ export class ShellManager {
|
|
|
97
154
|
job.endedAt = Date.now();
|
|
98
155
|
this.finish(entry);
|
|
99
156
|
});
|
|
100
|
-
|
|
157
|
+
const complete = (code: number | null, signal: NodeJS.Signals | null) => {
|
|
158
|
+
if (entry.finished) return;
|
|
101
159
|
if (entry.partial) { this.push(entry, entry.partial); entry.partial = ""; }
|
|
102
160
|
if (job.status === "running") job.status = "exited";
|
|
103
161
|
job.exitCode = code;
|
|
104
162
|
job.signal = signal;
|
|
105
163
|
job.endedAt ??= Date.now();
|
|
106
164
|
this.finish(entry);
|
|
165
|
+
};
|
|
166
|
+
child.once("close", complete);
|
|
167
|
+
child.once("exit", (code, signal) => {
|
|
168
|
+
if (entry.finished) return;
|
|
169
|
+
if (job.status === "running") job.status = "exited";
|
|
170
|
+
job.exitCode = code;
|
|
171
|
+
job.signal = signal;
|
|
172
|
+
job.endedAt ??= Date.now();
|
|
173
|
+
// Grandchildren may keep stdio open after the shell exits. Drain briefly, then reclaim
|
|
174
|
+
// the entire group and emit exactly one terminal event even if close never arrives.
|
|
175
|
+
entry.closeTimer = setTimeout(() => {
|
|
176
|
+
signalProcessTree(child);
|
|
177
|
+
child.stdout?.destroy?.();
|
|
178
|
+
child.stderr?.destroy?.();
|
|
179
|
+
complete(code, signal);
|
|
180
|
+
}, 250);
|
|
181
|
+
entry.closeTimer.unref();
|
|
107
182
|
});
|
|
108
183
|
this.onChange?.();
|
|
109
184
|
return { ...job, lines: [] };
|
|
@@ -133,13 +208,22 @@ export class ShellManager {
|
|
|
133
208
|
}
|
|
134
209
|
if (entry.pattern && !job.matched && entry.pattern.test(line)) {
|
|
135
210
|
job.matched = line;
|
|
136
|
-
this.
|
|
211
|
+
this.emitEvent("match", job);
|
|
137
212
|
}
|
|
138
213
|
}
|
|
139
214
|
|
|
140
|
-
private finish(entry:
|
|
141
|
-
if (entry.
|
|
142
|
-
|
|
215
|
+
private finish(entry: ShellEntry): void {
|
|
216
|
+
if (entry.finished) return;
|
|
217
|
+
entry.finished = true;
|
|
218
|
+
entry.job.complete = true;
|
|
219
|
+
entry.job.endedAt ??= Date.now();
|
|
220
|
+
clearTimeout(entry.timer);
|
|
221
|
+
clearTimeout(entry.closeTimer);
|
|
222
|
+
if (entry.child) signalProcessTree(entry.child);
|
|
223
|
+
entry.child?.stdout?.destroy?.();
|
|
224
|
+
entry.child?.stderr?.destroy?.();
|
|
225
|
+
this.emitEvent("exit", entry.job);
|
|
226
|
+
for (const listener of [...this.listeners]) listener();
|
|
143
227
|
this.onChange?.();
|
|
144
228
|
}
|
|
145
229
|
|
|
@@ -159,13 +243,13 @@ export class ShellManager {
|
|
|
159
243
|
if (entry.job.status !== "running" || !entry.child) return false;
|
|
160
244
|
entry.job.status = "killed";
|
|
161
245
|
entry.job.endedAt = Date.now();
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
246
|
+
signalProcessTree(entry.child, "SIGTERM");
|
|
247
|
+
entry.timer = setTimeout(() => {
|
|
248
|
+
signalProcessTree(entry.child!);
|
|
249
|
+
entry.child?.stdout?.destroy?.();
|
|
250
|
+
entry.child?.stderr?.destroy?.();
|
|
251
|
+
this.finish(entry);
|
|
252
|
+
}, KILL_GRACE_MS);
|
|
169
253
|
entry.timer.unref?.();
|
|
170
254
|
this.onChange?.();
|
|
171
255
|
return true;
|
|
@@ -177,10 +261,14 @@ export class ShellManager {
|
|
|
177
261
|
for (const { job } of finished.slice(0, Math.max(0, this.jobs.size - MAX_KEPT_SHELLS + 1))) {
|
|
178
262
|
this.jobs.delete(job.id);
|
|
179
263
|
this.lineChars.delete(job.id);
|
|
264
|
+
this.pending.delete(job.id);
|
|
180
265
|
}
|
|
181
266
|
}
|
|
182
267
|
|
|
183
268
|
dispose(): void {
|
|
269
|
+
this.disposed = true;
|
|
270
|
+
this.pending.clear();
|
|
271
|
+
for (const listener of [...this.listeners]) listener();
|
|
184
272
|
for (const id of this.jobs.keys()) { try { this.kill(id); } catch { /* already gone */ } }
|
|
185
273
|
this.jobs.clear();
|
|
186
274
|
this.lineChars.clear();
|
|
@@ -194,7 +282,7 @@ export const elapsed = (span: { startedAt: number; endedAt?: number }, now = Dat
|
|
|
194
282
|
return seconds < 60 ? `${seconds}s` : `${Math.floor(seconds / 60)}m ${seconds % 60}s`;
|
|
195
283
|
};
|
|
196
284
|
export const shellState = (job: ShellMeta | ShellJob) => job.status === "running" ? "running"
|
|
197
|
-
: job.status === "exited" ? `exited ${job.exitCode ?? "?"}` : job.status === "failed" ? `failed: ${cleanText(job.error ?? "", 60)}` : "killed";
|
|
285
|
+
: job.status === "exited" ? `exited ${job.exitCode ?? "?"}${job.complete === false ? " (draining)" : ""}` : job.status === "failed" ? `failed: ${cleanText(job.error ?? "", 60)}` : "killed";
|
|
198
286
|
export const describe = (job: ShellMeta | ShellJob) => `${job.id} · ${job.name} · ${shellState(job)} · ${elapsed(job)}${job.watch ? ` · watch /${job.watch}/${job.matched ? " matched" : ""}` : ""}`;
|
|
199
287
|
const boundedTail = (lines: readonly string[], limit = MAX_TOOL_OUTPUT_CHARS): string => {
|
|
200
288
|
let size = 0;
|
|
@@ -222,12 +310,15 @@ export function shellEventMessage(event: ShellEvent): string {
|
|
|
222
310
|
}
|
|
223
311
|
|
|
224
312
|
const Parameters = Type.Object({
|
|
225
|
-
action: Type.Unsafe<"start" | "list" | "output" | "kill">({ type: "string", enum: ["start", "list", "output", "kill"] }),
|
|
313
|
+
action: Type.Unsafe<"start" | "list" | "peek" | "wait" | "output" | "kill">({ type: "string", enum: ["start", "list", "peek", "wait", "output", "kill"] }),
|
|
226
314
|
command: Type.Optional(Type.String({ description: "Shell command to run in the background (start)." })),
|
|
227
315
|
name: Type.Optional(Type.String({ description: "Short label, e.g. \"dev server\"." })),
|
|
228
316
|
watch: Type.Optional(Type.String({ description: "Regex; when a line matches you are woken once, e.g. \"ready on|error\"." })),
|
|
229
317
|
notify: Type.Optional(Type.Boolean({ description: "Wake you when the pattern matches or the process ends (default true)." })),
|
|
230
|
-
|
|
318
|
+
purpose: Type.Optional(Type.Union([Type.Literal("task"), Type.Literal("service")], { description: "task is finite and must be checked before claiming completion; service is intentionally long-lived (default with watch)." })),
|
|
319
|
+
id: Type.Optional(Type.String({ description: "Shell id for output/kill/wait/peek, e.g. s1." })),
|
|
320
|
+
ids: Type.Optional(Type.Array(Type.String(), { maxItems: 20, description: "Selected ids for wait/peek. Omit to inspect all jobs or wait for finite tasks only." })),
|
|
321
|
+
waitMs: Type.Optional(Type.Number({ minimum: 0, maximum: 30000, description: "Bounded event-driven wait (default 1000ms, maximum 30000). Esc cancels the wait, not the shell." })),
|
|
231
322
|
lines: Type.Optional(Type.Number({ description: "How many trailing output lines to return (default 40, max 400)." }))
|
|
232
323
|
});
|
|
233
324
|
|
|
@@ -236,36 +327,49 @@ export function registerShells(pi: ExtensionAPI, shells: () => ShellManager | un
|
|
|
236
327
|
pi.registerTool({
|
|
237
328
|
name: SHELL_TOOL,
|
|
238
329
|
label: "shell",
|
|
239
|
-
description: "Run
|
|
330
|
+
description: "Run background jobs. start returns immediately; notifications are compact and coalesced. peek reads status, wait checks selected tasks with a bounded event-driven wait, output loads logs and kill stops a process. Observed results are acknowledged so stale notifications do not replay. Verify relevant checks before claiming completion; do not wait for long-lived services.",
|
|
240
331
|
promptSnippet: "Use jar_shell for long-running or never-ending commands instead of blocking bash.",
|
|
241
332
|
promptGuidelines: [
|
|
242
333
|
"Use jar_shell start for servers, watchers and slow commands; set watch to a regex for the line you are waiting for (e.g. \"listening|ready|error\").",
|
|
243
|
-
"Do not poll:
|
|
334
|
+
"Do not poll: work while commands run, then use wait with explicit ids for relevant checks before the final summary. A bounded wait returns pending jobs honestly; never claim success without their exit codes. Do not wait for services/watchers. output, peek, list and wait acknowledge observed events, preventing stale replay.",
|
|
335
|
+
"Notifications contain coalesced status, not log dumps. Inspect output only for relevant diagnostics. Mark never-ending jobs purpose: service and kill shells you no longer need."
|
|
244
336
|
],
|
|
245
337
|
parameters: Parameters,
|
|
246
|
-
async execute(_id, params,
|
|
338
|
+
async execute(_id, params, signal, _update, ctx) {
|
|
247
339
|
const manager = shells();
|
|
248
340
|
const reply = (text: string) => ({ content: [{ type: "text" as const, text: text.slice(0, MAX_TOOL_OUTPUT_CHARS) }], details: {
|
|
249
|
-
jobs: (manager?.summaries() ?? []).map((job) => ({ id: job.id, name: job.name, status: job.status, pid: job.pid, exitCode: job.exitCode }))
|
|
341
|
+
jobs: (manager?.summaries() ?? []).map((job) => ({ id: job.id, name: job.name, status: job.status, purpose: job.purpose, complete: job.complete, pid: job.pid, exitCode: job.exitCode })),
|
|
342
|
+
pendingNotifications: manager?.pendingNotifications() ?? 0
|
|
250
343
|
} });
|
|
251
344
|
if (!manager) return reply("Background shells are unavailable before a Pi session starts.");
|
|
252
345
|
try {
|
|
253
346
|
switch (params.action) {
|
|
254
347
|
case "start": {
|
|
255
348
|
const job = manager.start({ command: params.command ?? "", cwd: ctx.cwd, ...(params.name ? { name: params.name } : {}), ...(params.watch ? { watch: params.watch } : {}),
|
|
256
|
-
...(params.notify !== undefined ? { notify: params.notify } : {}) });
|
|
349
|
+
...(params.notify !== undefined ? { notify: params.notify } : {}), ...(params.purpose ? { purpose: params.purpose } : {}) });
|
|
257
350
|
return reply(`Started ${describe(job)} (pid ${job.pid ?? "?"}). ${job.notify ? "You will be woken when it " + (job.watch ? "matches or " : "") + "exits." : "Notifications are off; check it with output."}`);
|
|
258
351
|
}
|
|
259
352
|
case "output": {
|
|
260
353
|
if (!params.id) return reply("output needs an id.");
|
|
261
354
|
const lines = manager.output(params.id, params.lines ?? 40);
|
|
355
|
+
manager.acknowledge([params.id]);
|
|
262
356
|
return reply(`${describe(manager.get(params.id)!)}\n${lines.length ? boundedTail(lines) : "(no output yet)"}`);
|
|
263
357
|
}
|
|
264
358
|
case "kill":
|
|
265
359
|
if (!params.id) return reply("kill needs an id.");
|
|
266
360
|
return reply(manager.kill(params.id) ? `Stopping ${params.id}.` : `${params.id} is not running.`);
|
|
361
|
+
case "wait": {
|
|
362
|
+
const ids = params.ids ?? (params.id ? [params.id] : manager.summaries().filter(job => job.purpose !== "service").map(job => job.id));
|
|
363
|
+
const completed = await manager.wait(ids, params.waitMs ?? 1000, signal);
|
|
364
|
+
const jobs = manager.summaries().filter(job => ids.includes(job.id));
|
|
365
|
+
manager.acknowledge(ids);
|
|
366
|
+
return reply((completed ? "Selected jobs completed." : "Wait timed out; jobs still pending. Do not claim they passed.") + (jobs.length ? "\n" + jobs.map(describe).join("\n") : " No finite tasks selected."));
|
|
367
|
+
}
|
|
267
368
|
default: {
|
|
268
|
-
const
|
|
369
|
+
const ids = params.ids ?? (params.id ? [params.id] : undefined);
|
|
370
|
+
const jobs = manager.summaries().filter(job => !ids || ids.includes(job.id));
|
|
371
|
+
if (ids?.some(id => !jobs.some(job => job.id === id))) return reply("Unknown shell id in selection.");
|
|
372
|
+
manager.acknowledge(jobs.map(job => job.id));
|
|
269
373
|
return reply(jobs.length ? jobs.map(describe).join("\n") : "No background shells.");
|
|
270
374
|
}
|
|
271
375
|
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { workspacePathAllowed } from "./delegate-worktree.ts";
|
|
2
|
+
|
|
3
|
+
export const CHILD_TOOLS_ENV = "PI_JAR_CHILD_TOOLS";
|
|
4
|
+
export const WEB_TOOLS = ["web_enable", "web_search", "fetch_content", "get_search_content", "source_check"] as const;
|
|
5
|
+
export const RECURSIVE_TOOLS = new Set(["jar_delegate", "jar_subagent", "jar_democracy"]);
|
|
6
|
+
|
|
7
|
+
/** Multi-file tools must validate each effective target, not just the top-level default path. */
|
|
8
|
+
export function worktreeToolViolation(root: string, cwd: string, name: string, args: unknown): string | undefined {
|
|
9
|
+
if (["bash", "powershell", "jar_shell"].includes(name)) return "Sandboxed worktree subagents cannot run shell tools.";
|
|
10
|
+
const pathTools = new Set(["read", "edit", "write", "grep", "find", "ls", "multi_file_edit"]);
|
|
11
|
+
if (!pathTools.has(name)) return;
|
|
12
|
+
if (!workspacePathAllowed(root, cwd, ".")) return "Sandboxed worktree subagent working directory is outside its workspace.";
|
|
13
|
+
const input = args && typeof args === "object" ? args as Record<string, unknown> : {};
|
|
14
|
+
const validate = (value: unknown): string | undefined => {
|
|
15
|
+
if (typeof value !== "string" || !value || !workspacePathAllowed(root, cwd, value, name === "read")) {
|
|
16
|
+
return "Sandboxed worktree subagent cannot access outside its workspace or use an invalid path: " + String(value);
|
|
17
|
+
}
|
|
18
|
+
};
|
|
19
|
+
for (const key of ["path", "file_path", "cwd"]) if (input[key] !== undefined) {
|
|
20
|
+
const violation = validate(input[key]);
|
|
21
|
+
if (violation) return violation;
|
|
22
|
+
}
|
|
23
|
+
if (name === "multi_file_edit") {
|
|
24
|
+
if (!Array.isArray(input.edits) || !input.edits.length) return "multi_file_edit needs a non-empty edits array.";
|
|
25
|
+
for (const raw of input.edits) {
|
|
26
|
+
if (!raw || typeof raw !== "object") return "Invalid multi_file_edit entry.";
|
|
27
|
+
const entry = raw as Record<string, unknown>;
|
|
28
|
+
for (const key of ["path", "file_path", "cwd"]) if (entry[key] !== undefined) {
|
|
29
|
+
const violation = validate(entry[key]);
|
|
30
|
+
if (violation) return violation;
|
|
31
|
+
}
|
|
32
|
+
const violation = validate(entry.path ?? entry.file_path ?? input.path ?? input.file_path);
|
|
33
|
+
if (violation) return violation;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function childToolAllowlist(value: string | undefined): Set<string> | undefined {
|
|
39
|
+
if (value === undefined) return;
|
|
40
|
+
try {
|
|
41
|
+
const parsed: unknown = JSON.parse(value);
|
|
42
|
+
if (!Array.isArray(parsed) || parsed.length > 64 || parsed.some(name => typeof name !== "string" || !name || RECURSIVE_TOOLS.has(name))) return new Set();
|
|
43
|
+
return new Set(parsed as string[]);
|
|
44
|
+
} catch { return new Set(); } // invalid capability data fails closed
|
|
45
|
+
}
|
package/src/working.ts
CHANGED
|
@@ -41,14 +41,12 @@ export class WorkingState {
|
|
|
41
41
|
prompt(open: boolean): void { this.waiting = open; }
|
|
42
42
|
toolStart(id: string, name: string): void { this.tools.set(id, cleanText(name, 24) || "tool"); }
|
|
43
43
|
toolEnd(id: string): void { this.tools.delete(id); }
|
|
44
|
-
/**
|
|
45
|
-
view(animations: boolean, fg: (color: WorkingColor, text: string) => string, now = Date.now(), effort?: string,
|
|
44
|
+
/** Keep pi-jar's light-inspired activity phrases; task titles belong in the task widget. */
|
|
45
|
+
view(animations: boolean, fg: (color: WorkingColor, text: string) => string, now = Date.now(), effort?: string, _task?: string): WorkingView {
|
|
46
46
|
const phase = this.phase;
|
|
47
|
-
const activity = task ? cleanText(task, 60).replace(/[.…]+$/, "") : "";
|
|
48
47
|
const elapsed = this.startedAt === undefined ? 0 : Math.max(0, Math.floor((now - this.startedAt) / 1000));
|
|
49
48
|
const stage = STAGES.reduce<number>((index, threshold, next) => elapsed >= threshold ? next : index, 0);
|
|
50
|
-
const word =
|
|
51
|
-
: phase === "tool" ? `Tracing light through ${[...this.tools.values()][0] ?? "tool"}…`
|
|
49
|
+
const word = phase === "tool" ? `Tracing light through ${[...this.tools.values()][0] ?? "tool"}…`
|
|
52
50
|
: phase === "generating" ? PROGRESSION[Math.max(0, stage)]
|
|
53
51
|
: phase === "waiting" ? "Holding the lantern for your answer…" : undefined;
|
|
54
52
|
const color: WorkingColor = phase === "tool" ? "warning" : phase === "waiting" ? "muted" : "accent";
|