@gavin-hu/my-pi-agent 0.16.0 → 0.18.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/README.md +29 -15
- package/README.zh-CN.md +27 -15
- package/extensions/bytefly/README.md +195 -115
- package/extensions/bytefly/footer/collect/entries.ts +1 -6
- package/extensions/bytefly/footer/collect/snapshot.ts +23 -9
- package/extensions/bytefly/footer/price/price.ts +12 -8
- package/extensions/bytefly/footer/register.ts +13 -1
- package/extensions/bytefly/footer/types.ts +11 -0
- package/extensions/bytefly/footer/unpriced.ts +36 -0
- package/extensions/bytefly/header/component.ts +15 -29
- package/extensions/bytefly/header/identity.ts +71 -0
- package/extensions/bytefly/header/register.ts +9 -3
- package/extensions/bytefly/index.ts +13 -5
- package/extensions/bytefly/message/format.ts +20 -13
- package/extensions/bytefly/message/register.ts +8 -7
- package/extensions/bytefly/notify/types.ts +1 -1
- package/extensions/bytefly/panel/commands.ts +46 -25
- package/extensions/bytefly/panel/format.ts +49 -14
- package/extensions/bytefly/panel/join.ts +35 -9
- package/extensions/bytefly/panel/settings.ts +29 -39
- package/extensions/bytefly/panel/skills.ts +51 -0
- package/extensions/bytefly/panel/tui/extensions-view.ts +7 -61
- package/extensions/bytefly/panel/tui/frame.ts +9 -1
- package/extensions/bytefly/panel/tui/list-view.ts +72 -0
- package/extensions/bytefly/panel/tui/nav.ts +71 -3
- package/extensions/bytefly/panel/tui/settings-section.ts +250 -0
- package/extensions/bytefly/panel/tui/skills-view.ts +50 -0
- package/extensions/bytefly/panel/tui.ts +223 -331
- package/extensions/bytefly/panel/types.ts +26 -8
- package/extensions/bytefly/picker/discovery.ts +32 -27
- package/extensions/bytefly/picker/resources.ts +16 -8
- package/extensions/bytefly/picker/runtime.ts +62 -34
- package/extensions/bytefly/picker/toggle.ts +16 -10
- package/extensions/bytefly/picker/types.ts +31 -13
- package/extensions/bytefly/update/format.ts +66 -8
- package/extensions/bytefly/update/patch.ts +66 -0
- package/extensions/bytefly/update/register.ts +4 -11
- package/extensions/bytefly/update/release.ts +65 -0
- package/extensions/bytefly/update/state.ts +14 -0
- package/extensions/bytefly/update/suppress.ts +47 -29
- package/extensions/bytefly/update/types.ts +15 -5
- package/extensions/check/tui.ts +2 -0
- package/extensions/context/README.md +21 -11
- package/extensions/context/format.ts +10 -8
- package/extensions/context/report.ts +21 -8
- package/extensions/context/tui.ts +26 -10
- package/extensions/context/types.ts +5 -3
- package/extensions/file-browse/README.md +89 -22
- package/extensions/file-browse/chrome.ts +83 -0
- package/extensions/file-browse/client.ts +74 -459
- package/extensions/file-browse/config.ts +12 -3
- package/extensions/file-browse/diff-html.ts +94 -0
- package/extensions/file-browse/html.ts +26 -152
- package/extensions/file-browse/live-client.ts +47 -1
- package/extensions/file-browse/live.ts +35 -14
- package/extensions/file-browse/palette.ts +224 -0
- package/extensions/file-browse/rev.ts +42 -0
- package/extensions/file-browse/router.ts +27 -6
- package/extensions/file-browse/server.ts +7 -0
- package/extensions/file-browse/styles.ts +10 -3
- package/extensions/file-browse/tree.ts +381 -0
- package/extensions/file-read/README.md +4 -1
- package/extensions/file-read/extract/odt.ts +7 -5
- package/extensions/goal/plan-route.ts +8 -2
- package/extensions/goal/tools.ts +6 -11
- package/extensions/job/README.md +7 -5
- package/extensions/job/index.ts +35 -52
- package/extensions/job/tools.ts +51 -77
- package/extensions/memory/README.md +3 -3
- package/extensions/memory/tools.ts +4 -6
- package/extensions/memory/tui.ts +5 -1
- package/extensions/plan/README.md +333 -84
- package/extensions/plan/commands.ts +73 -7
- package/extensions/plan/feedback.ts +300 -63
- package/extensions/plan/index.ts +1 -1
- package/extensions/plan/plan-index.ts +4 -2
- package/extensions/plan/plan-text.ts +18 -1
- package/extensions/plan/plans.ts +184 -58
- package/extensions/plan/review-prompt.ts +93 -0
- package/extensions/plan/review.ts +95 -95
- package/extensions/plan/runtime.ts +4 -2
- package/extensions/plan/split.ts +87 -0
- package/extensions/plan/steps.ts +123 -107
- package/extensions/plan/tools/cancel.ts +79 -0
- package/extensions/plan/tools/enter.ts +17 -12
- package/extensions/plan/tools/exit.ts +148 -59
- package/extensions/plan/tools/steps.ts +7 -19
- package/extensions/plan/tools/write.ts +29 -31
- package/extensions/plan/tools.ts +6 -3
- package/extensions/plan/tui.ts +60 -15
- package/extensions/plan/types.ts +45 -3
- package/extensions/plan/web-chrome.ts +102 -0
- package/extensions/plan/web-client.ts +177 -446
- package/extensions/plan/web-collab.ts +207 -0
- package/extensions/plan/web-comments.ts +269 -0
- package/extensions/plan/web-markdown.ts +222 -0
- package/extensions/plan/web-page.ts +131 -19
- package/extensions/plan/web-router.ts +148 -32
- package/extensions/plan/web-share-session.ts +197 -0
- package/extensions/plan/web-share.ts +169 -0
- package/extensions/plan/web-streams.ts +124 -0
- package/extensions/plan/web-tokens.ts +63 -0
- package/extensions/plan/web.ts +193 -45
- package/extensions/question/README.md +3 -0
- package/extensions/question/dialogs.ts +91 -1
- package/extensions/question/tools.ts +2 -1
- package/extensions/rewind/checklist.ts +4 -0
- package/extensions/rewind/review-tui.ts +3 -1
- package/extensions/rewind/rewind-tui.ts +4 -0
- package/extensions/session-bus/README.md +247 -0
- package/extensions/session-bus/commands.ts +165 -0
- package/extensions/session-bus/format.ts +124 -0
- package/extensions/session-bus/index.ts +69 -0
- package/extensions/session-bus/notice.ts +58 -0
- package/extensions/session-bus/protocol.ts +134 -0
- package/extensions/session-bus/result.ts +243 -0
- package/extensions/session-bus/rows.ts +169 -0
- package/extensions/session-bus/runtime.ts +575 -0
- package/extensions/session-bus/store.ts +329 -0
- package/extensions/session-bus/team.ts +102 -0
- package/extensions/session-bus/tools.ts +227 -0
- package/extensions/session-bus/tui.ts +394 -0
- package/extensions/session-bus/types.ts +98 -0
- package/extensions/subagent/README.md +26 -13
- package/extensions/subagent/format.ts +72 -25
- package/extensions/subagent/index.ts +21 -47
- package/extensions/subagent/orchestrate.ts +18 -41
- package/extensions/subagent/render.ts +29 -42
- package/extensions/subagent/stream.ts +24 -18
- package/extensions/subagent/types.ts +7 -0
- package/extensions/todo/tools.ts +5 -6
- package/extensions/todo/tui.ts +5 -1
- package/extensions/watch/README.md +9 -5
- package/extensions/watch/index.ts +25 -44
- package/extensions/watch/tools.ts +31 -59
- package/extensions/wechat/README.md +45 -36
- package/extensions/wechat/bridge.ts +46 -48
- package/extensions/wechat/client.ts +1 -4
- package/extensions/wechat/commands.ts +3 -3
- package/extensions/wechat/deliver.ts +57 -37
- package/extensions/wechat/interaction.ts +54 -18
- package/extensions/wechat/login-screen.ts +2 -3
- package/extensions/wechat/notices.ts +9 -7
- package/extensions/wechat/peer-worker.ts +19 -34
- package/extensions/wechat/presence.ts +3 -4
- package/extensions/wechat/prompt.ts +53 -23
- package/extensions/wechat/reply-pipeline.ts +12 -4
- package/extensions/wechat/slash.ts +14 -0
- package/extensions/wechat/status-screen.ts +2 -2
- package/extensions/wechat/tools.ts +3 -11
- package/extensions/worktree/README.md +48 -12
- package/extensions/worktree/commands.ts +109 -65
- package/extensions/worktree/enter.ts +1 -1
- package/extensions/worktree/exit.ts +11 -1
- package/extensions/worktree/git.ts +5 -0
- package/extensions/worktree/lifecycle.ts +9 -1
- package/extensions/worktree/registry.ts +11 -5
- package/extensions/worktree/screen.ts +263 -0
- package/extensions/worktree/status.ts +84 -40
- package/lib/README.md +12 -7
- package/lib/claims/overlap.ts +43 -0
- package/lib/config.ts +12 -0
- package/lib/format.ts +6 -2
- package/lib/http/router.ts +40 -13
- package/lib/http/server.ts +27 -13
- package/lib/interaction.ts +28 -1
- package/lib/notice.ts +29 -2
- package/lib/plan-entry.ts +7 -2
- package/lib/plans/sections.ts +172 -0
- package/lib/plans/setting.ts +52 -0
- package/lib/plans/split.ts +215 -0
- package/lib/plans/store.ts +19 -0
- package/lib/plans/team.ts +151 -0
- package/lib/qr.ts +64 -0
- package/lib/repo-key.ts +74 -0
- package/lib/report.ts +117 -25
- package/lib/requirements.ts +1 -2
- package/lib/tool-result.ts +54 -4
- package/lib/tui/README.md +30 -8
- package/lib/tui/chrome.ts +27 -6
- package/lib/tui/dock-screen.ts +91 -19
- package/lib/tui/gauge.ts +9 -5
- package/lib/tui/line-pane.ts +15 -4
- package/lib/tui/tool.ts +23 -4
- package/lib/usage.ts +26 -0
- package/lib/web/README.md +131 -0
- package/lib/web/base.ts +19 -2
- package/lib/web/components.ts +10 -6
- package/lib/web/layout.ts +2 -0
- package/lib/web/markup.ts +2 -1
- package/lib/web/runtime.ts +103 -25
- package/lib/web/tokens.ts +23 -7
- package/package.json +5 -2
- package/skills/changelog/SKILL.md +57 -45
- package/skills/init/SKILL.md +80 -45
- package/skills/init/references/agents-md-quality.md +26 -2
- package/skills/init/references/interview-questions.md +36 -14
- package/skills/next/SKILL.md +209 -0
- package/skills/parallel-dev/SKILL.md +150 -0
- package/extensions/wechat/qrcode.ts +0 -46
|
@@ -1,46 +1,93 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Pure formatting helpers shared by the transcript renderers.
|
|
3
|
+
*
|
|
4
|
+
* The usage line keeps tokens, turns, model, and context — the facts the child
|
|
5
|
+
* reported — and never money: a price needs a currency and a tariff, which the
|
|
6
|
+
* bytefly footer owns for the whole session. See `usageLine` / `totalUsageLine`.
|
|
3
7
|
*/
|
|
4
8
|
|
|
5
9
|
import { stripTerminalSequences, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
6
10
|
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
7
11
|
import { formatDuration, formatTokens, sanitizeLine, shortenPath } from "../../lib/format.ts";
|
|
12
|
+
import { USAGE_COUNTERS, zeroUsage } from "../../lib/usage.ts";
|
|
8
13
|
import type { BackgroundRun } from "./background.ts";
|
|
9
|
-
import type { UsageStats } from "./types.ts";
|
|
14
|
+
import type { UsageSource, UsageStats } from "./types.ts";
|
|
10
15
|
|
|
11
|
-
/**
|
|
12
|
-
function
|
|
13
|
-
if (
|
|
14
|
-
|
|
15
|
-
|
|
16
|
+
/** `2 turns` / `1 turn`, or "" when no turn was counted. */
|
|
17
|
+
function turnPart(turns: number | undefined): string {
|
|
18
|
+
if (!turns) return "";
|
|
19
|
+
return `${turns} turn${turns > 1 ? "s" : ""}`;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** A prefix and a compact count, or "" when the counter is zero. */
|
|
23
|
+
function tokenPart(prefix: string, count: number | undefined): string {
|
|
24
|
+
return count ? `${prefix}${formatTokens(count)}` : "";
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** `ctx:18.2k`, or "" when no context was reported. */
|
|
28
|
+
function contextPart(tokens: number | undefined): string {
|
|
29
|
+
const count = tokens ?? 0;
|
|
30
|
+
return count > 0 ? `ctx:${formatTokens(count)}` : "";
|
|
16
31
|
}
|
|
17
32
|
|
|
18
33
|
/** A space-separated usage line, omitting zero fields. */
|
|
19
34
|
export function formatUsageStats(usage: Partial<UsageStats>, model?: string): string {
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
35
|
+
return [
|
|
36
|
+
turnPart(usage.turns),
|
|
37
|
+
tokenPart("↑", usage.input),
|
|
38
|
+
tokenPart("↓", usage.output),
|
|
39
|
+
tokenPart("R", usage.cacheRead),
|
|
40
|
+
tokenPart("W", usage.cacheWrite),
|
|
41
|
+
contextPart(usage.contextTokens),
|
|
42
|
+
model ?? "",
|
|
43
|
+
]
|
|
44
|
+
.filter(Boolean)
|
|
45
|
+
.join(" ");
|
|
30
46
|
}
|
|
31
47
|
|
|
32
48
|
/** Sum usage across results, for the chain/parallel totals line. */
|
|
33
|
-
export function aggregateUsage(results: { usage: UsageStats }[]): Partial<UsageStats> {
|
|
34
|
-
const
|
|
49
|
+
export function aggregateUsage(results: readonly { usage: UsageStats }[]): Partial<UsageStats> {
|
|
50
|
+
const counters = zeroUsage();
|
|
51
|
+
let turns = 0;
|
|
35
52
|
for (const { usage } of results) {
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
total.cacheRead += usage.cacheRead;
|
|
39
|
-
total.cacheWrite += usage.cacheWrite;
|
|
40
|
-
total.cost += usage.cost;
|
|
41
|
-
total.turns += usage.turns;
|
|
53
|
+
for (const counter of USAGE_COUNTERS) counters[counter] += usage[counter];
|
|
54
|
+
turns += usage.turns;
|
|
42
55
|
}
|
|
43
|
-
return
|
|
56
|
+
return { ...counters, turns };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** "2 tool errors", "1 tool error", or "" when the run hit none. */
|
|
60
|
+
function toolErrorsLabel(count: number | undefined): string {
|
|
61
|
+
if (!count) return "";
|
|
62
|
+
return `${count} tool error${count > 1 ? "s" : ""}`;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* The one model every result ran, or undefined when they differ.
|
|
67
|
+
*
|
|
68
|
+
* A total may name a model only when the name is true of all of it: a batch whose
|
|
69
|
+
* tasks pinned different models gets no label rather than one task's.
|
|
70
|
+
*/
|
|
71
|
+
function sharedModel(sources: readonly UsageSource[]): string | undefined {
|
|
72
|
+
const models = new Set(sources.map((source) => source.model).filter((model): model is string => Boolean(model)));
|
|
73
|
+
return models.size === 1 ? [...models][0] : undefined;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** One result's usage line, with its tool-error count when it hit any. */
|
|
77
|
+
export function usageLine(source: UsageSource): string {
|
|
78
|
+
return [formatUsageStats(source.usage, source.model), toolErrorsLabel(source.toolErrors)].filter(Boolean).join(" ");
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* The `Total:` line for a multi run: summed counters, the shared model when there
|
|
83
|
+
* is one, and summed tool errors. A total carries no `ctx:` — every child has its
|
|
84
|
+
* own context window, so a sum of them would mean nothing.
|
|
85
|
+
*/
|
|
86
|
+
export function totalUsageLine(sources: readonly UsageSource[]): string {
|
|
87
|
+
const usage = formatUsageStats(aggregateUsage(sources), sharedModel(sources));
|
|
88
|
+
const errors = sources.reduce((sum, source) => sum + (source.toolErrors ?? 0), 0);
|
|
89
|
+
const line = [usage, toolErrorsLabel(errors)].filter(Boolean).join(" ");
|
|
90
|
+
return line ? `Total: ${line}` : "";
|
|
44
91
|
}
|
|
45
92
|
|
|
46
93
|
/** Display-width-aware truncation with a trailing ellipsis (keeps wide chars/surrogates intact). */
|
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
|
|
18
18
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
19
19
|
import { isExtensionEnabled, resolveEffectiveCwd } from "../../lib/env.ts";
|
|
20
|
+
import { textResult } from "../../lib/tool-result.ts";
|
|
20
21
|
import {
|
|
21
22
|
type AgentConfig,
|
|
22
23
|
type AgentScope,
|
|
@@ -158,12 +159,10 @@ export default function subagent(pi: ExtensionAPI, deps: SubagentDeps = {}): voi
|
|
|
158
159
|
const resolution = resolveMode(args);
|
|
159
160
|
if ("error" in resolution) {
|
|
160
161
|
const roster = discoverAgents(root, args.agentScope ?? "user").agents;
|
|
161
|
-
return {
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
details: { mode: "single", results: [] } satisfies SubagentDetails,
|
|
166
|
-
};
|
|
162
|
+
return textResult(`${resolution.error} Available agents: ${formatAgentList(roster)}`, {
|
|
163
|
+
mode: "single",
|
|
164
|
+
results: [],
|
|
165
|
+
} satisfies SubagentDetails);
|
|
167
166
|
}
|
|
168
167
|
|
|
169
168
|
const mode = resolution.mode;
|
|
@@ -195,17 +194,11 @@ export default function subagent(pi: ExtensionAPI, deps: SubagentDeps = {}): voi
|
|
|
195
194
|
.filter((agent): agent is AgentConfig => agent?.source === "project");
|
|
196
195
|
if (projectAgents.length > 0) {
|
|
197
196
|
if (!ctx.hasUI) {
|
|
198
|
-
return
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
"Refusing to run project-local agents without confirmation. " +
|
|
204
|
-
'Trust the project or use agentScope: "user".',
|
|
205
|
-
},
|
|
206
|
-
],
|
|
207
|
-
details: makeDetails([]),
|
|
208
|
-
};
|
|
197
|
+
return textResult(
|
|
198
|
+
"Refusing to run project-local agents without confirmation. " +
|
|
199
|
+
'Trust the project or use agentScope: "user".',
|
|
200
|
+
makeDetails([]),
|
|
201
|
+
);
|
|
209
202
|
}
|
|
210
203
|
const names = projectAgents.map((agent) => agent.name).join(", ");
|
|
211
204
|
const dir = discovery.projectAgentsDir ?? "(unknown)";
|
|
@@ -214,31 +207,20 @@ export default function subagent(pi: ExtensionAPI, deps: SubagentDeps = {}): voi
|
|
|
214
207
|
`Agents: ${names}\nSource: ${dir}\n\nProject agents are repo-controlled. Only continue for trusted repositories.`,
|
|
215
208
|
);
|
|
216
209
|
if (!approved) {
|
|
217
|
-
return
|
|
218
|
-
content: [{ type: "text" as const, text: "Canceled: project-local agents not approved." }],
|
|
219
|
-
details: makeDetails([]),
|
|
220
|
-
};
|
|
210
|
+
return textResult("Canceled: project-local agents not approved.", makeDetails([]));
|
|
221
211
|
}
|
|
222
212
|
}
|
|
223
213
|
}
|
|
224
214
|
|
|
225
215
|
if (args.background) {
|
|
226
216
|
if (mode !== "single") {
|
|
227
|
-
return
|
|
228
|
-
content: [{ type: "text" as const, text: "`background` supports single mode only." }],
|
|
229
|
-
details: makeDetails([]),
|
|
230
|
-
};
|
|
217
|
+
return textResult("`background` supports single mode only.", makeDetails([]));
|
|
231
218
|
}
|
|
232
219
|
if (!agents.some((candidate) => candidate.name === args.agent)) {
|
|
233
|
-
return
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
text: `Unknown agent: "${args.agent ?? ""}". Available agents: ${formatAgentList(agents)}`,
|
|
238
|
-
},
|
|
239
|
-
],
|
|
240
|
-
details: makeDetails([]),
|
|
241
|
-
};
|
|
220
|
+
return textResult(
|
|
221
|
+
`Unknown agent: "${args.agent ?? ""}". Available agents: ${formatAgentList(agents)}`,
|
|
222
|
+
makeDetails([]),
|
|
223
|
+
);
|
|
242
224
|
}
|
|
243
225
|
const started = background.start(
|
|
244
226
|
{
|
|
@@ -259,19 +241,11 @@ export default function subagent(pi: ExtensionAPI, deps: SubagentDeps = {}): voi
|
|
|
259
241
|
},
|
|
260
242
|
);
|
|
261
243
|
if (!started.ok) {
|
|
262
|
-
return
|
|
263
|
-
content: [{ type: "text" as const, text: started.error }],
|
|
264
|
-
details: makeDetails([]),
|
|
265
|
-
};
|
|
244
|
+
return textResult(started.error, makeDetails([]));
|
|
266
245
|
}
|
|
267
|
-
return
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
type: "text" as const,
|
|
271
|
-
text: `Started ${started.run.agent} as ${started.run.id} in the background. Use /agents to inspect, collect, or cancel it.`,
|
|
272
|
-
},
|
|
273
|
-
],
|
|
274
|
-
details: {
|
|
246
|
+
return textResult(
|
|
247
|
+
`Started ${started.run.agent} as ${started.run.id} in the background. Use /agents to inspect, collect, or cancel it.`,
|
|
248
|
+
{
|
|
275
249
|
...makeDetails([]),
|
|
276
250
|
background: {
|
|
277
251
|
runId: started.run.id,
|
|
@@ -281,7 +255,7 @@ export default function subagent(pi: ExtensionAPI, deps: SubagentDeps = {}): voi
|
|
|
281
255
|
startedAt: started.run.startedAt,
|
|
282
256
|
},
|
|
283
257
|
} satisfies SubagentDetails,
|
|
284
|
-
|
|
258
|
+
);
|
|
285
259
|
}
|
|
286
260
|
|
|
287
261
|
const context: ModeContext = {
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import type { AgentToolResult } from "@earendil-works/pi-agent-core";
|
|
8
|
+
import { failedResult, textResult } from "../../lib/tool-result.ts";
|
|
8
9
|
import type { AgentConfig } from "./agents.ts";
|
|
9
10
|
import type { TranscriptMode } from "./config.ts";
|
|
10
11
|
import type { RunOptions } from "./run.ts";
|
|
@@ -73,25 +74,16 @@ export async function runChainMode(ctx: ModeContext): Promise<AgentToolResult<Su
|
|
|
73
74
|
results.push(result);
|
|
74
75
|
|
|
75
76
|
if (isFailedResult(result)) {
|
|
76
|
-
return
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
text: `Chain stopped at step ${i + 1} (${step.agent}): ${getResultOutput(result)}`,
|
|
81
|
-
},
|
|
82
|
-
],
|
|
83
|
-
details: makeDetails(results),
|
|
84
|
-
isError: true,
|
|
85
|
-
};
|
|
77
|
+
return failedResult(
|
|
78
|
+
`Chain stopped at step ${i + 1} (${step.agent}): ${getResultOutput(result)}`,
|
|
79
|
+
makeDetails(results),
|
|
80
|
+
);
|
|
86
81
|
}
|
|
87
82
|
previous = getFinalOutput(result.messages);
|
|
88
83
|
}
|
|
89
84
|
|
|
90
85
|
const last = results[results.length - 1];
|
|
91
|
-
return
|
|
92
|
-
content: [{ type: "text" as const, text: last ? getFinalOutput(last.messages) || "(no output)" : "(no output)" }],
|
|
93
|
-
details: makeDetails(results),
|
|
94
|
-
};
|
|
86
|
+
return textResult(last ? getFinalOutput(last.messages) || "(no output)" : "(no output)", makeDetails(results));
|
|
95
87
|
}
|
|
96
88
|
|
|
97
89
|
/** Independent tasks with bounded concurrency, reporting running/done progress. */
|
|
@@ -104,10 +96,9 @@ export async function runParallelMode(ctx: ModeContext): Promise<AgentToolResult
|
|
|
104
96
|
if (!onUpdate) return;
|
|
105
97
|
const running = allResults.filter((r) => r.exitCode === -1).length;
|
|
106
98
|
const done = allResults.length - running;
|
|
107
|
-
onUpdate(
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
});
|
|
99
|
+
onUpdate(
|
|
100
|
+
textResult(`Parallel: ${done}/${allResults.length} done, ${running} running…`, makeDetails([...allResults])),
|
|
101
|
+
);
|
|
111
102
|
};
|
|
112
103
|
|
|
113
104
|
const results = await mapWithConcurrencyLimit(tasks, MAX_CONCURRENCY, async (task, index) => {
|
|
@@ -146,15 +137,10 @@ export async function runParallelMode(ctx: ModeContext): Promise<AgentToolResult
|
|
|
146
137
|
return `### [${result.agent}] ${status}\n\n${output}`;
|
|
147
138
|
});
|
|
148
139
|
|
|
149
|
-
return
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
text: `Parallel: ${successCount}/${results.length} succeeded\n\n${summaries.join("\n\n---\n\n")}`,
|
|
154
|
-
},
|
|
155
|
-
],
|
|
156
|
-
details: makeDetails(results),
|
|
157
|
-
};
|
|
140
|
+
return textResult(
|
|
141
|
+
`Parallel: ${successCount}/${results.length} succeeded\n\n${summaries.join("\n\n---\n\n")}`,
|
|
142
|
+
makeDetails(results),
|
|
143
|
+
);
|
|
158
144
|
}
|
|
159
145
|
|
|
160
146
|
/** One agent + task. */
|
|
@@ -176,20 +162,11 @@ export async function runSingleMode(ctx: ModeContext): Promise<AgentToolResult<S
|
|
|
176
162
|
});
|
|
177
163
|
|
|
178
164
|
if (isFailedResult(result)) {
|
|
179
|
-
return
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
text: `Agent ${result.agent} ${result.stopReason ?? "failed"}: ${getResultOutput(result)}`,
|
|
184
|
-
},
|
|
185
|
-
],
|
|
186
|
-
details: makeDetails([result]),
|
|
187
|
-
isError: true,
|
|
188
|
-
};
|
|
165
|
+
return failedResult(
|
|
166
|
+
`Agent ${result.agent} ${result.stopReason ?? "failed"}: ${getResultOutput(result)}`,
|
|
167
|
+
makeDetails([result]),
|
|
168
|
+
);
|
|
189
169
|
}
|
|
190
170
|
|
|
191
|
-
return
|
|
192
|
-
content: [{ type: "text" as const, text: getFinalOutput(result.messages) || "(no output)" }],
|
|
193
|
-
details: makeDetails([result]),
|
|
194
|
-
};
|
|
171
|
+
return textResult(getFinalOutput(result.messages) || "(no output)", makeDetails([result]));
|
|
195
172
|
}
|
|
@@ -8,6 +8,11 @@
|
|
|
8
8
|
*
|
|
9
9
|
* Status and error rendering is shared across single- and multi-result modes so
|
|
10
10
|
* a failed task never loses its message, regardless of how many tasks ran.
|
|
11
|
+
*
|
|
12
|
+
* The usage footnote follows the same grammar in every mode: a blank line, the
|
|
13
|
+
* faint metrics row, then the expand hint, so the affordance is always the
|
|
14
|
+
* block's last line. `format.ts` owns the row's text; it carries no money,
|
|
15
|
+
* because a price needs the footer's currency and tariff.
|
|
11
16
|
*/
|
|
12
17
|
|
|
13
18
|
import { getMarkdownTheme, type Theme } from "@earendil-works/pi-coding-agent";
|
|
@@ -20,7 +25,7 @@ import { expandHint, GLYPHS, SEPARATORS } from "../../lib/tui/glyphs.ts";
|
|
|
20
25
|
import { centeredRule } from "../../lib/tui/rules.ts";
|
|
21
26
|
import { BODY_INDENT } from "../../lib/tui/indent.ts";
|
|
22
27
|
import { paint, type TuiRole } from "../../lib/tui/tokens.ts";
|
|
23
|
-
import {
|
|
28
|
+
import { clip, formatToolCall, totalUsageLine, usageLine } from "./format.ts";
|
|
24
29
|
import { COLLAPSED_ERROR_MAX, COLLAPSED_ITEM_COUNT, COLLAPSED_TEXT_LINES, type SubagentArgs } from "./schema.ts";
|
|
25
30
|
import { getFinalOutput, isFailedResult } from "./stream.ts";
|
|
26
31
|
import type { SingleResult, SubagentDetails } from "./types.ts";
|
|
@@ -132,27 +137,6 @@ function isExpandable(result: SingleResult, status: ResultStatus, items: Display
|
|
|
132
137
|
return hasExpandableContent(items) || hasClippedError(result, status);
|
|
133
138
|
}
|
|
134
139
|
|
|
135
|
-
/** "2 tool errors" / "1 tool error". */
|
|
136
|
-
function toolErrorsLabel(count: number): string {
|
|
137
|
-
return `${count} tool error${count > 1 ? "s" : ""}`;
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
/** Usage plus a tool-error count when the subagent hit any. */
|
|
141
|
-
function usageLine(result: SingleResult): string {
|
|
142
|
-
const base = formatUsageStats(result.usage, result.model);
|
|
143
|
-
if (!result.toolErrors) return base;
|
|
144
|
-
const label = toolErrorsLabel(result.toolErrors);
|
|
145
|
-
return base ? `${base} ${label}` : label;
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
/** The `Total:` usage line for a multi run, including summed tool errors. */
|
|
149
|
-
function totalUsageLine(results: SingleResult[]): string {
|
|
150
|
-
const usage = formatUsageStats(aggregateUsage(results));
|
|
151
|
-
const errors = results.reduce((sum, result) => sum + (result.toolErrors ?? 0), 0);
|
|
152
|
-
const label = errors ? toolErrorsLabel(errors) : "";
|
|
153
|
-
return [usage, label].filter(Boolean).join(" ");
|
|
154
|
-
}
|
|
155
|
-
|
|
156
140
|
/** " 12s" while running or once finished, or "" when no start time was recorded. */
|
|
157
141
|
function durationSuffix(result: SingleResult, status: ResultStatus, now: number): string {
|
|
158
142
|
if (!result.startedAt) return "";
|
|
@@ -306,6 +290,22 @@ function transcriptLine(result: SingleResult, theme: Theme): string | undefined
|
|
|
306
290
|
return paint(theme, "faint", `transcript: ${sanitizeLine(result.artifactPath)}${cut}`);
|
|
307
291
|
}
|
|
308
292
|
|
|
293
|
+
/** A blank line and one faint row: the shape every expanded footnote takes. */
|
|
294
|
+
function addFaintRow(container: Container, theme: Theme, text: string): void {
|
|
295
|
+
container.addChild(new Spacer(1));
|
|
296
|
+
container.addChild(new Text(paint(theme, "faint", text), 0, 0));
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* The collapsed block's footnote: a blank line, the faint metrics row, then the
|
|
301
|
+
* expand hint. Both collapsed views end through here, so the metrics row and the
|
|
302
|
+
* affordance cannot swap places again.
|
|
303
|
+
*/
|
|
304
|
+
function collapsedFooter(theme: Theme, metrics: string, hinted: boolean): string {
|
|
305
|
+
const rows = metrics ? `\n\n${paint(theme, "faint", metrics)}` : "";
|
|
306
|
+
return hinted ? `${rows}\n${expandHint(theme)}` : rows;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
309
|
/** Append one result's expanded block: header, error, task, output, usage. */
|
|
310
310
|
function addExpandedResult(
|
|
311
311
|
container: Container,
|
|
@@ -359,15 +359,12 @@ function addExpandedResult(
|
|
|
359
359
|
}
|
|
360
360
|
|
|
361
361
|
const usage = usageLine(result);
|
|
362
|
-
if (usage)
|
|
363
|
-
container.addChild(new Spacer(1));
|
|
364
|
-
container.addChild(new Text(paint(theme, "faint", usage), 0, 0));
|
|
365
|
-
}
|
|
362
|
+
if (usage) addFaintRow(container, theme, usage);
|
|
366
363
|
const transcript = transcriptLine(result, theme);
|
|
367
364
|
if (transcript) container.addChild(new Text(transcript, 0, 0));
|
|
368
365
|
}
|
|
369
366
|
|
|
370
|
-
/** The collapsed single result: header, a word, body,
|
|
367
|
+
/** The collapsed single result: header, a word, body, and the shared footer. */
|
|
371
368
|
function collapsedSingleText(result: SingleResult, theme: Theme, isPartial: boolean, now: number): string {
|
|
372
369
|
const status = resultStatus(result, isPartial);
|
|
373
370
|
// The leading blank line separates the result block from the call line, matching
|
|
@@ -377,10 +374,7 @@ function collapsedSingleText(result: SingleResult, theme: Theme, isPartial: bool
|
|
|
377
374
|
const displayItems = getDisplayItems(result.messages);
|
|
378
375
|
// A blank line separates the header from the body, matching the multi view.
|
|
379
376
|
text += `\n\n${collapsedBody(result, status, displayItems, theme)}`;
|
|
380
|
-
|
|
381
|
-
const usage = usageLine(result);
|
|
382
|
-
if (usage) text += `\n${paint(theme, "faint", usage)}`;
|
|
383
|
-
return text;
|
|
377
|
+
return text + collapsedFooter(theme, usageLine(result), isExpandable(result, status, displayItems));
|
|
384
378
|
}
|
|
385
379
|
|
|
386
380
|
function summarizeResults(results: SingleResult[]) {
|
|
@@ -445,12 +439,8 @@ function collapsedMultiText(details: SubagentDetails, theme: Theme, now: number)
|
|
|
445
439
|
text += statusSuffix(result, status, theme, now);
|
|
446
440
|
text += `\n${collapsedBody(result, status, displayItems, theme)}`;
|
|
447
441
|
}
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
if (usage) text += `\n\n${paint(theme, "faint", `Total: ${usage}`)}`;
|
|
451
|
-
}
|
|
452
|
-
if (running > 0 || hasHidden) text += `\n${expandHint(theme)}`;
|
|
453
|
-
return text;
|
|
442
|
+
const metrics = running === 0 ? totalUsageLine(results) : "";
|
|
443
|
+
return text + collapsedFooter(theme, metrics, running > 0 || hasHidden);
|
|
454
444
|
}
|
|
455
445
|
|
|
456
446
|
function fillExpandedMulti(container: Container, details: SubagentDetails, theme: Theme, now: number): void {
|
|
@@ -464,11 +454,8 @@ function fillExpandedMulti(container: Container, details: SubagentDetails, theme
|
|
|
464
454
|
|
|
465
455
|
const { running } = summarizeResults(details.results);
|
|
466
456
|
if (running === 0) {
|
|
467
|
-
const
|
|
468
|
-
if (
|
|
469
|
-
container.addChild(new Spacer(1));
|
|
470
|
-
container.addChild(new Text(paint(theme, "faint", `Total: ${usage}`), 0, 0));
|
|
471
|
-
}
|
|
457
|
+
const total = totalUsageLine(details.results);
|
|
458
|
+
if (total) addFaintRow(container, theme, total);
|
|
472
459
|
}
|
|
473
460
|
}
|
|
474
461
|
|
|
@@ -6,11 +6,13 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import type { Message } from "@earendil-works/pi-ai";
|
|
9
|
+
import { asNumber, asRecord } from "../../lib/config.ts";
|
|
10
|
+
import { USAGE_COUNTERS, type UsageCounts, zeroUsage } from "../../lib/usage.ts";
|
|
9
11
|
import type { SingleResult, UsageStats } from "./types.ts";
|
|
10
12
|
|
|
11
13
|
/** A zeroed usage accumulator. */
|
|
12
14
|
export function emptyUsage(): UsageStats {
|
|
13
|
-
return {
|
|
15
|
+
return { ...zeroUsage(), contextTokens: 0, turns: 0 };
|
|
14
16
|
}
|
|
15
17
|
|
|
16
18
|
/** A fresh result for one subagent run; `exitCode: -1` marks it still running. */
|
|
@@ -29,6 +31,23 @@ export function parseJsonLine(line: string): Record<string, unknown> | undefined
|
|
|
29
31
|
}
|
|
30
32
|
}
|
|
31
33
|
|
|
34
|
+
/**
|
|
35
|
+
* The counters and context size one assistant message reported.
|
|
36
|
+
*
|
|
37
|
+
* The payload is a child process's JSON, so every number is read defensively: a
|
|
38
|
+
* string, `NaN`, or a missing field contributes nothing instead of concatenating
|
|
39
|
+
* into the accumulator or publishing a bogus recorded cost. `contextTokens` is
|
|
40
|
+
* the message's `totalTokens`, the occupancy of the child's window at that turn.
|
|
41
|
+
*/
|
|
42
|
+
function readUsage(raw: unknown): { counters: UsageCounts; contextTokens?: number } {
|
|
43
|
+
const usage = asRecord(raw);
|
|
44
|
+
const counters = zeroUsage();
|
|
45
|
+
for (const counter of USAGE_COUNTERS) counters[counter] = asNumber(usage?.[counter]) ?? 0;
|
|
46
|
+
// The wire nests the dollar amount one level down, as `{ total }`.
|
|
47
|
+
counters.cost = asNumber(asRecord(usage?.cost)?.total) ?? 0;
|
|
48
|
+
return { counters, contextTokens: asNumber(usage?.totalTokens) };
|
|
49
|
+
}
|
|
50
|
+
|
|
32
51
|
/**
|
|
33
52
|
* Fold one parsed event into `result`.
|
|
34
53
|
*
|
|
@@ -51,25 +70,12 @@ export function applyEvent(result: SingleResult, event: unknown): boolean {
|
|
|
51
70
|
model?: string;
|
|
52
71
|
stopReason?: string;
|
|
53
72
|
errorMessage?: string;
|
|
54
|
-
usage?:
|
|
55
|
-
input?: number;
|
|
56
|
-
output?: number;
|
|
57
|
-
cacheRead?: number;
|
|
58
|
-
cacheWrite?: number;
|
|
59
|
-
totalTokens?: number;
|
|
60
|
-
cost?: { total?: number };
|
|
61
|
-
};
|
|
73
|
+
usage?: unknown;
|
|
62
74
|
};
|
|
63
75
|
result.usage.turns++;
|
|
64
|
-
const
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
result.usage.output += usage.output ?? 0;
|
|
68
|
-
result.usage.cacheRead += usage.cacheRead ?? 0;
|
|
69
|
-
result.usage.cacheWrite += usage.cacheWrite ?? 0;
|
|
70
|
-
result.usage.cost += usage.cost?.total ?? 0;
|
|
71
|
-
result.usage.contextTokens = usage.totalTokens ?? result.usage.contextTokens;
|
|
72
|
-
}
|
|
76
|
+
const reported = readUsage(assistant.usage);
|
|
77
|
+
for (const counter of USAGE_COUNTERS) result.usage[counter] += reported.counters[counter];
|
|
78
|
+
if (reported.contextTokens !== undefined) result.usage.contextTokens = reported.contextTokens;
|
|
73
79
|
if (!result.provider && assistant.provider) result.provider = assistant.provider;
|
|
74
80
|
if (!result.model && assistant.model) result.model = assistant.model;
|
|
75
81
|
if (assistant.stopReason) result.stopReason = assistant.stopReason;
|
|
@@ -24,6 +24,13 @@ export interface UsageStats {
|
|
|
24
24
|
turns: number;
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
+
/** The fields a usage line reads from one result. */
|
|
28
|
+
export interface UsageSource {
|
|
29
|
+
usage: UsageStats;
|
|
30
|
+
model?: string;
|
|
31
|
+
toolErrors?: number;
|
|
32
|
+
}
|
|
33
|
+
|
|
27
34
|
/** Everything captured from one subagent subprocess. */
|
|
28
35
|
export interface SingleResult {
|
|
29
36
|
agent: string;
|
package/extensions/todo/tools.ts
CHANGED
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
import type { JsonValue } from "@earendil-works/pi-ai";
|
|
14
14
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
15
15
|
import { TODO_TOOL } from "../../lib/tool-names.ts";
|
|
16
|
+
import { failedResult, textResult } from "../../lib/tool-result.ts";
|
|
16
17
|
import { GLYPHS } from "../../lib/tui/glyphs.ts";
|
|
17
18
|
import { normalizeTodos } from "../../lib/todo-state.ts";
|
|
18
19
|
import { callLine, detailsOrLine, rejectionLine } from "../../lib/tui/tool.ts";
|
|
@@ -54,12 +55,10 @@ function toStructuredContent(todos: Todo[], action: "write" | "clear", error?: s
|
|
|
54
55
|
*/
|
|
55
56
|
function todoResult(todos: Todo[], action: "write" | "clear", error?: string) {
|
|
56
57
|
const details: TodoDetails = error === undefined ? { todos, action } : { todos, action, error };
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
details,
|
|
60
|
-
|
|
61
|
-
...(error === undefined ? {} : { isError: true as const }),
|
|
62
|
-
};
|
|
58
|
+
const structuredContent = toStructuredContent(todos, action, error);
|
|
59
|
+
return error === undefined
|
|
60
|
+
? textResult(formatTodoText(todos), details, structuredContent)
|
|
61
|
+
: failedResult(`Error: ${error}`, details, structuredContent);
|
|
63
62
|
}
|
|
64
63
|
|
|
65
64
|
export function registerTools(pi: ExtensionAPI, runtime: TodoRuntime): void {
|
package/extensions/todo/tui.ts
CHANGED
|
@@ -161,13 +161,15 @@ export class TodoListComponent implements Component {
|
|
|
161
161
|
|
|
162
162
|
if (this.todos.length === 0) {
|
|
163
163
|
return dockListFrame({
|
|
164
|
+
theme: this.theme,
|
|
165
|
+
width: w,
|
|
164
166
|
header,
|
|
165
167
|
body: emptyState(this.theme, w, { label: "No todos yet. Ask the agent to plan some work." }),
|
|
166
168
|
hint: screenHint(this.theme, w, ["Esc close"]),
|
|
167
169
|
});
|
|
168
170
|
}
|
|
169
171
|
|
|
170
|
-
// header, summary, blank, blank-after-list, hint,
|
|
172
|
+
// header, summary, blank, blank-after-list, hint, closing rule
|
|
171
173
|
const chrome = 6;
|
|
172
174
|
this.visible = fitRows(this.viewportRowsSource, this.todos.length, chrome, SCREEN_DEFAULT_ITEMS);
|
|
173
175
|
this.scrollTop = clampScroll(this.scrollTop, this.todos.length, this.visible);
|
|
@@ -177,6 +179,8 @@ export class TodoListComponent implements Component {
|
|
|
177
179
|
for (let i = this.scrollTop; i < end; i++) body.push(todoRow(this.todos[i], this.theme, w));
|
|
178
180
|
|
|
179
181
|
return dockListFrame({
|
|
182
|
+
theme: this.theme,
|
|
183
|
+
width: w,
|
|
180
184
|
header,
|
|
181
185
|
summary: clip(` ${paint(this.theme, "meta", progressSummary(this.todos))}`, w),
|
|
182
186
|
body,
|
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
Tail a file or a command's merged output, match each new line against a regular
|
|
4
4
|
expression, and keep the matching lines. Matches are sanitized and alerted in
|
|
5
5
|
the TUI, reported to the model at the next turn, or — when a watcher opts into
|
|
6
|
-
`wake` — raise one triggered turn
|
|
6
|
+
`wake` — raise one triggered turn as soon as the agent is idle, including right
|
|
7
|
+
after a running turn settles.
|
|
7
8
|
|
|
8
9
|
```
|
|
9
10
|
pi --extension ./extensions/watch # load just this extension
|
|
@@ -29,7 +30,9 @@ How the `watch` call and its result look in the transcript — the call line as
|
|
|
29
30
|
- Shows a `⊚ N` running-watcher chip and a `! N` unreported-match chip, raises a
|
|
30
31
|
`ctx.ui.notify` alert once per unreported burst, and offers a `/watch` screen.
|
|
31
32
|
- Reports pending matches to the model at the start of the next turn, or wakes a
|
|
32
|
-
turn for a watcher added with `wake: true
|
|
33
|
+
turn for a watcher added with `wake: true` as soon as the agent is idle. A wake
|
|
34
|
+
that arrives mid-turn is raised once that run settles. A wake also leaves the
|
|
35
|
+
terminal: the
|
|
33
36
|
match is announced on `my-pi-agent/watch-match`, which bytefly's
|
|
34
37
|
[notify](../bytefly/README.md#notifications) capability turns into a desktop
|
|
35
38
|
notification when `notify.enabled` and `notify.matchFound` are on.
|
|
@@ -172,8 +175,8 @@ other.
|
|
|
172
175
|
- Matching is per line; a pattern cannot span a newline.
|
|
173
176
|
- Only a **wake** match raises an OS notification, and only when bytefly's
|
|
174
177
|
`notify.enabled` and `notify.matchFound` are on. A match collected while the
|
|
175
|
-
agent was busy
|
|
176
|
-
|
|
178
|
+
agent was busy notifies once that run settles, and `watch` itself never sends
|
|
179
|
+
one — it announces the match and bytefly owns delivery.
|
|
177
180
|
|
|
178
181
|
## Non-goals
|
|
179
182
|
|
|
@@ -213,7 +216,8 @@ UTF-8 sequence split across a read boundary is never mangled. State is
|
|
|
213
216
|
session-only by design: a watcher is a live process or file position, not
|
|
214
217
|
transcript state, so it cannot be faithfully rebuilt from a `/tree` fork. Match
|
|
215
218
|
delivery mirrors `job` — reported at the next turn, with `wake` triggering one
|
|
216
|
-
turn — so a match is never dropped when the wake
|
|
219
|
+
turn as soon as the agent is idle — so a match is never dropped when the wake
|
|
220
|
+
window is busy; a mid-turn wake is raised once that run settles. Stopping a
|
|
217
221
|
command sends SIGTERM to its process group and escalates to SIGKILL after a 2 s
|
|
218
222
|
grace; shutdown waits the same grace, so a command that ignores SIGTERM cannot
|
|
219
223
|
outlive the session.
|