killeros 1.4.8 → 1.5.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/CHANGELOG.md +21 -4
- package/Killeros.ts +25 -2542
- package/README.md +23 -8
- package/killeros/commands.ts +164 -0
- package/killeros/concise.ts +23 -0
- package/killeros/display.ts +39 -0
- package/killeros/errors.ts +6 -0
- package/killeros/footer.ts +209 -0
- package/killeros/goals.ts +720 -0
- package/killeros/hooks.ts +219 -0
- package/killeros/init.ts +470 -0
- package/killeros/personal-instructions.ts +66 -0
- package/killeros/question.ts +468 -0
- package/killeros/runtime.ts +61 -0
- package/killeros/shell-ui.ts +270 -0
- package/killeros/subagent-lifecycle.ts +532 -0
- package/killeros/subagent-process.ts +601 -0
- package/killeros/subagent-ui.ts +227 -0
- package/killeros/subagents.ts +1917 -0
- package/killeros/variants.ts +136 -0
- package/package.json +7 -6
- package/subagent-lifecycle.ts +1 -506
- package/subagent-process.ts +1 -595
- package/subagent-ui.ts +1 -227
- package/subagents.ts +1 -1556
package/README.md
CHANGED
|
@@ -33,7 +33,7 @@ pi install git:github.com/KyrosHendrix/pi-KillerOS
|
|
|
33
33
|
Pin an install to a release:
|
|
34
34
|
|
|
35
35
|
```bash
|
|
36
|
-
pi install git:github.com/KyrosHendrix/pi-KillerOS@v1.
|
|
36
|
+
pi install git:github.com/KyrosHendrix/pi-KillerOS@v1.5.0
|
|
37
37
|
```
|
|
38
38
|
|
|
39
39
|
Add `-l` to either command for a project-only install. Restart Pi after installing.
|
|
@@ -97,13 +97,28 @@ KillerOS ships `planner`, `reviewer`, `scout`, and `security` as read-only roles
|
|
|
97
97
|
|
|
98
98
|
The default `agentScope: "user"` uses bundled and personal roles. Use `"project"` or `"both"` to opt into trusted project roles; a selected project override requires interactive confirmation. Role frontmatter requires `name`, `description`, `access`, and an explicit `tools` list. Optional fields are `model`, `thinking`, and `timeoutMs`. Every bundled role shows `model: inherit` and `thinking: inherit` as editable placeholders. Replace them with an available `provider/model` and a separate thinking level when you want to pin a role; `off`, `minimal`, `low`, `medium`, `high`, `xhigh`, and `max` are checked against that model’s supported capabilities.
|
|
99
99
|
|
|
100
|
-
The tool supports a single `agent` + `task`, parallel `tasks`, or a sequential `chain` whose task text may include `{previous}`. Read-only-only batches run concurrently, up to four at a time. Batches with write-capable roles use one shared
|
|
100
|
+
The tool supports a single `agent` + `task`, parallel `tasks`, or a sequential `chain` whose task text may include `{previous}`. Read-only-only batches run concurrently, up to four at a time. Batches with write-capable roles use one shared slot by default; set `writerConcurrency` above `1` only after proving path ownership in the shared worktree. Reader-only batches reject `writerConcurrency` because it does not apply. All children share the parent worktree, so concurrent writers must avoid file conflicts. A call can also set `model` and `thinking` for every task, overriding role settings; use `inherit` to fall back to each role and then the active parent model.
|
|
101
|
+
|
|
102
|
+
| Action | Required fields | Allowed optional fields |
|
|
103
|
+
|---|---|---|
|
|
104
|
+
| omitted / `spawn` single | `agent`, `task` | `model`, `thinking`, `agentScope` |
|
|
105
|
+
| omitted / `spawn` parallel | `tasks` | `writerConcurrency`, `model`, `thinking`, `agentScope` |
|
|
106
|
+
| omitted / `spawn` chain | `chain` | `model`, `thinking`, `agentScope` |
|
|
107
|
+
| `list` | none | none |
|
|
108
|
+
| `inspect` | `threadId` | none |
|
|
109
|
+
| `steer` | `threadId`, `message` | none |
|
|
110
|
+
| `interrupt` one | `threadId` | none |
|
|
111
|
+
| `interrupt` all | `all: true` | none |
|
|
112
|
+
| `collect` | `threadId` | none |
|
|
113
|
+
| `close` | `threadId` | none |
|
|
114
|
+
|
|
115
|
+
The three spawn shapes cannot be mixed. The `message` field is only valid with `action: "steer"`, and lifecycle actions reject spawn fields. KillerOS rejects malformed requests before role discovery, project confirmation, thread creation, or child launch. The TUI shows a parallel or shared-pool schedule only after shape validation; malformed calls show `invalid request` instead of queued work. For example:
|
|
101
116
|
|
|
102
117
|
```json
|
|
103
118
|
{"agent":"reviewer","task":"Review the change","model":"provider/model","thinking":"high"}
|
|
104
119
|
```
|
|
105
120
|
|
|
106
|
-
Use the separate `model` and `thinking` fields for new configuration. The older `provider/model:thinking` model form remains accepted. Children run as isolated `pi --mode json -p` processes with a private `--session-dir` and `--session-id`, plus explicit local tools and `web_search`, `source_check`, `fetch_content`, and `get_search_content`. Steering restarts the same child session, so the child keeps its prior conversation. Each child explicitly loads `npm:pi-web-access`, discovers available skills, and keeps arbitrary extensions and prompt templates disabled; project-local skills load only when the parent project is trusted. Every bundled role is instructed to load the most relevant `SKILL.md` and report useful evidence. Children have no default token, dollar, turn, tool-call, research, wall-time,
|
|
121
|
+
Use the separate `model` and `thinking` fields for new configuration. The older `provider/model:thinking` model form remains accepted. Children run as isolated `pi --mode json -p` processes with a private `--session-dir` and `--session-id`, plus explicit local tools and `web_search`, `source_check`, `fetch_content`, and `get_search_content`. Steering restarts the same child session, so the child keeps its prior conversation. Each child explicitly loads `npm:pi-web-access`, discovers available skills, and keeps arbitrary extensions and prompt templates disabled; project-local skills load only when the parent project is trusted. Every bundled role is instructed to load the most relevant `SKILL.md` and report useful evidence. Children have no default token, dollar, turn, tool-call, research, wall-time, trace, stderr, or returned-output execution quota; each JSONL record still has a bounded 8 MiB parser ceiling. KillerOS bounds retained trace, stderr, and returned text and spills a large JSONL line to temporary storage; retention never stops a child or marks it `limited`. The parent limits each request to ten tasks, read-only-only batches to four concurrent readers, and bounds role files, task input, and combined parent output. An embedding caller may opt into named child resource guards. A parent tool-call abort cancels queued tasks but lets already-running children finish; explicit `interrupt` actions and session shutdown terminate active children and escalate after five seconds.
|
|
107
122
|
|
|
108
123
|
### Thread lifecycle
|
|
109
124
|
|
|
@@ -111,9 +126,9 @@ Each delegated task creates a named child thread. Its contract records the paren
|
|
|
111
126
|
|
|
112
127
|
Threads move through `queued`, `active`, `done`, `failed`, `stopped`, and `closed`. The parent renders separate **Active** and **Done** lists. Active threads show their name, task, model, usage, and direct controls. Done threads keep their handoff and trace available until the parent closes them.
|
|
113
128
|
|
|
114
|
-
The parent can inspect a thread’s prompt, role, model, tools, trace, usage, and handoff; steer an active thread with one bounded follow-up; interrupt one child or all active children; collect a concise handoff into parent context; and close a finished or stopped thread. An interrupt preserves the partial trace, states the reason, and reports the handoff as partial rather than successful.
|
|
129
|
+
The parent can inspect a thread’s prompt, role, model, tools, trace, usage, and handoff; steer an active thread with one bounded follow-up; interrupt one child or all active children; collect a concise handoff into parent context; and close a finished or stopped thread. An interrupt preserves the partial trace, states the reason, and reports the handoff as partial rather than successful. Closing removes a thread from the active workspace; heavy trace and result payloads are evicted as needed under the bounded retention budget, leaving a small inspectable tombstone.
|
|
115
130
|
|
|
116
|
-
A child completes naturally when it returns a final answer. The default path has no per-child execution quota. Explicit embedding options can add wall-time, output, trace, stderr, JSONL, token, or cost guards; those guards report their cause and return partial work clearly. The parent still bounds task count, reader concurrency, role files, task input, and combined parent output. Parent tool-call aborts leave active children running while queued work is settled as cancelled; explicit `interrupt` actions and real child-process failures remain visible. Session shutdown still terminates active children and escalates after five seconds.
|
|
131
|
+
A child completes naturally when it returns a final answer. The default path has no per-child execution quota, while every JSONL record has an 8 MiB parser ceiling. Explicit embedding options can add wall-time, output, trace, stderr, JSONL, token, or cost guards; those guards report their cause and return partial work clearly. The parent still bounds task count, reader concurrency, role files, task input, and combined parent output. Parent tool-call aborts leave active children running while queued work is settled as cancelled; explicit `interrupt` actions and real child-process failures remain visible. Session shutdown still terminates active children and escalates after five seconds.
|
|
117
132
|
|
|
118
133
|
The replacement lifecycle has nine phases:
|
|
119
134
|
|
|
@@ -124,8 +139,8 @@ The replacement lifecycle has nine phases:
|
|
|
124
139
|
5. **Interrupt:** stop one or all active children while preserving partial work.
|
|
125
140
|
6. **Collect:** return a concise handoff while retaining the expanded trace.
|
|
126
141
|
7. **Guard:** honor only explicitly configured child resource guards; do not impose a routine turn stop.
|
|
127
|
-
8. **Close:** remove a finished or stopped thread from the workspace
|
|
128
|
-
9. **Prove:** test identity, visibility, controls, natural completion, guards, partial handoffs, and closure.
|
|
142
|
+
8. **Close:** remove a finished or stopped thread from the workspace while retaining a small inspectable tombstone; heavy payloads may be evicted under the retention budget.
|
|
143
|
+
9. **Prove:** test identity, visibility, controls, natural completion, guards, partial handoffs, bounded retention, and closure.
|
|
129
144
|
|
|
130
145
|
## Configuration
|
|
131
146
|
|
|
@@ -163,7 +178,7 @@ The package manifest lists Pi’s built-in modules as peer dependencies, so npm
|
|
|
163
178
|
|
|
164
179
|
The [`pi-package`](https://github.com/earendil-works/pi/blob/main/packages/coding-agent/docs/packages.md) keyword makes a published npm release visible in Pi’s package catalog.
|
|
165
180
|
|
|
166
|
-
For release `1.
|
|
181
|
+
For release `1.5.0`, publish after the validation checks pass:
|
|
167
182
|
|
|
168
183
|
```bash
|
|
169
184
|
npm login
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import { type ExtensionAPI, type ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import type { AutocompleteItem } from "@earendil-works/pi-tui";
|
|
3
|
+
|
|
4
|
+
async function confirmNewSession(ctx: ExtensionCommandContext): Promise<boolean> {
|
|
5
|
+
if (!ctx.hasUI) return true;
|
|
6
|
+
return ctx.ui.confirm("Start new session", "Start a new session and leave the current history?");
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function registerAliases(pi: ExtensionAPI): void {
|
|
10
|
+
const startNewSession = async (_args: string, ctx: ExtensionCommandContext): Promise<void> => {
|
|
11
|
+
await ctx.waitForIdle();
|
|
12
|
+
if (!await confirmNewSession(ctx)) return;
|
|
13
|
+
await ctx.newSession();
|
|
14
|
+
};
|
|
15
|
+
pi.registerCommand("clear", { description: "Start a new session after confirmation", handler: startNewSession });
|
|
16
|
+
pi.registerCommand("exit", {
|
|
17
|
+
description: "Quit Pi gracefully",
|
|
18
|
+
handler: async (_args, ctx) => ctx.shutdown(),
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
interface CommandInfo {
|
|
23
|
+
name: string;
|
|
24
|
+
description?: string;
|
|
25
|
+
category: "Built-in" | "Extension" | "Prompt" | "Skill";
|
|
26
|
+
syntaxHint?: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const BUILTIN_COMMANDS: ReadonlyArray<{ name: string; description: string }> = [
|
|
30
|
+
{ name: "settings", description: "Open settings menu" },
|
|
31
|
+
{ name: "model", description: "Select model" },
|
|
32
|
+
{ name: "scoped-models", description: "Configure models for Ctrl+P cycling" },
|
|
33
|
+
{ name: "export", description: "Export the current session" },
|
|
34
|
+
{ name: "import", description: "Import and resume a JSONL session" },
|
|
35
|
+
{ name: "share", description: "Share the session as a secret GitHub gist" },
|
|
36
|
+
{ name: "copy", description: "Copy the last agent message" },
|
|
37
|
+
{ name: "name", description: "Set the session display name" },
|
|
38
|
+
{ name: "session", description: "Show session usage and stats" },
|
|
39
|
+
{ name: "changelog", description: "Show changelog entries" },
|
|
40
|
+
{ name: "hotkeys", description: "Show keyboard shortcuts" },
|
|
41
|
+
{ name: "fork", description: "Fork from a previous user message" },
|
|
42
|
+
{ name: "clone", description: "Duplicate the session at the current position" },
|
|
43
|
+
{ name: "tree", description: "Navigate the session tree" },
|
|
44
|
+
{ name: "trust", description: "Save the project trust decision" },
|
|
45
|
+
{ name: "login", description: "Configure provider authentication" },
|
|
46
|
+
{ name: "logout", description: "Remove provider authentication" },
|
|
47
|
+
{ name: "new", description: "Start a new session" },
|
|
48
|
+
{ name: "compact", description: "Compact the session context" },
|
|
49
|
+
{ name: "resume", description: "Resume a different session" },
|
|
50
|
+
{ name: "reload", description: "Reload extensions and resources" },
|
|
51
|
+
{ name: "quit", description: "Quit Pi" },
|
|
52
|
+
];
|
|
53
|
+
|
|
54
|
+
const COMMAND_SYNTAX_HINTS: Readonly<Record<string, string>> = {
|
|
55
|
+
goal: "/goal [objective|clear|edit|pause|resume]",
|
|
56
|
+
variants: "/variants [level]",
|
|
57
|
+
model: "/model [provider/model]",
|
|
58
|
+
"scoped-models": "/scoped-models",
|
|
59
|
+
login: "/login [provider]",
|
|
60
|
+
export: "/export [filename]",
|
|
61
|
+
import: "/import [path]",
|
|
62
|
+
name: "/name [session-name]",
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
interface TaggedAutocompleteItem extends AutocompleteItem {
|
|
66
|
+
killerosCommand?: string;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function scoreCommandMatch(name: string, prefix: string): number {
|
|
70
|
+
if (!prefix) return 1;
|
|
71
|
+
const normalizedName = name.toLocaleLowerCase();
|
|
72
|
+
const normalizedPrefix = prefix.toLocaleLowerCase();
|
|
73
|
+
if (normalizedName.startsWith(normalizedPrefix)) return 100;
|
|
74
|
+
if (normalizedName.split(/[:\-_]/).some((token) => token.startsWith(normalizedPrefix))) return 80;
|
|
75
|
+
if (normalizedName.includes(normalizedPrefix)) return 50;
|
|
76
|
+
return 0;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function registerSlashAutocomplete(pi: ExtensionAPI): void {
|
|
80
|
+
const usage = new Map<string, number>();
|
|
81
|
+
pi.on("session_start", (_event, ctx) => {
|
|
82
|
+
if (ctx.mode !== "tui") return;
|
|
83
|
+
ctx.ui.addAutocompleteProvider((current) => ({
|
|
84
|
+
triggerCharacters: ["/"],
|
|
85
|
+
async getSuggestions(lines, cursorLine, cursorCol, options) {
|
|
86
|
+
const line = lines[cursorLine] ?? "";
|
|
87
|
+
const beforeCursor = line.slice(0, cursorCol);
|
|
88
|
+
const match = beforeCursor.match(/(?:^|[ \t])\/([^\s/]*)$/);
|
|
89
|
+
if (!match) return current.getSuggestions(lines, cursorLine, cursorCol, options);
|
|
90
|
+
|
|
91
|
+
const prefix = (match[1] ?? "").toLocaleLowerCase();
|
|
92
|
+
const baseSuggestions = await current.getSuggestions(lines, cursorLine, cursorCol, options);
|
|
93
|
+
const commands = new Map<string, CommandInfo>();
|
|
94
|
+
BUILTIN_COMMANDS.forEach((command) => commands.set(command.name, {
|
|
95
|
+
...command,
|
|
96
|
+
category: "Built-in",
|
|
97
|
+
syntaxHint: COMMAND_SYNTAX_HINTS[command.name],
|
|
98
|
+
}));
|
|
99
|
+
|
|
100
|
+
for (const command of pi.getCommands()) {
|
|
101
|
+
const category: CommandInfo["category"] = command.source === "skill"
|
|
102
|
+
? "Skill"
|
|
103
|
+
: command.source === "prompt"
|
|
104
|
+
? "Prompt"
|
|
105
|
+
: "Extension";
|
|
106
|
+
commands.set(command.name, {
|
|
107
|
+
name: command.name,
|
|
108
|
+
description: command.description,
|
|
109
|
+
category,
|
|
110
|
+
syntaxHint: COMMAND_SYNTAX_HINTS[command.name],
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
for (const item of baseSuggestions?.items ?? []) {
|
|
115
|
+
const name = (item.value || item.label).replace(/^\//, "").trim().split(/\s+/)[0] ?? "";
|
|
116
|
+
if (name && !commands.has(name)) {
|
|
117
|
+
commands.set(name, { name, description: item.description, category: "Built-in" });
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const ranked = [...commands.values()]
|
|
122
|
+
.map((command) => ({
|
|
123
|
+
command,
|
|
124
|
+
score: scoreCommandMatch(command.name, prefix) + Math.min((usage.get(command.name) ?? 0) * 2, 15),
|
|
125
|
+
}))
|
|
126
|
+
.filter(({ command }) => scoreCommandMatch(command.name, prefix) > 0)
|
|
127
|
+
.sort((left, right) => right.score - left.score || left.command.name.localeCompare(right.command.name));
|
|
128
|
+
if (!ranked.length) return baseSuggestions;
|
|
129
|
+
|
|
130
|
+
return {
|
|
131
|
+
prefix: `/${prefix}`,
|
|
132
|
+
items: ranked.map(({ command }): TaggedAutocompleteItem => {
|
|
133
|
+
const syntax = command.syntaxHint ? `${command.syntaxHint} — ` : "";
|
|
134
|
+
return {
|
|
135
|
+
value: `/${command.name} `,
|
|
136
|
+
label: `/${command.name}`,
|
|
137
|
+
description: `[${command.category}] ${syntax}${command.description ?? ""}`.trim(),
|
|
138
|
+
killerosCommand: command.name,
|
|
139
|
+
};
|
|
140
|
+
}),
|
|
141
|
+
};
|
|
142
|
+
},
|
|
143
|
+
applyCompletion(lines, cursorLine, cursorCol, item, prefix) {
|
|
144
|
+
const tagged = item as TaggedAutocompleteItem;
|
|
145
|
+
if (!tagged.killerosCommand) return current.applyCompletion(lines, cursorLine, cursorCol, item, prefix);
|
|
146
|
+
usage.set(tagged.killerosCommand, (usage.get(tagged.killerosCommand) ?? 0) + 1);
|
|
147
|
+
const line = lines[cursorLine] ?? "";
|
|
148
|
+
const beforeCursor = line.slice(0, cursorCol);
|
|
149
|
+
let afterCursor = line.slice(cursorCol);
|
|
150
|
+
const match = beforeCursor.match(/(?:^|[ \t])\/([^\s/]*)$/);
|
|
151
|
+
if (!match || match.index === undefined) return current.applyCompletion(lines, cursorLine, cursorCol, item, prefix);
|
|
152
|
+
const slashIndex = match.index + (match[0].startsWith("/") ? 0 : 1);
|
|
153
|
+
const newBefore = beforeCursor.slice(0, slashIndex) + item.value;
|
|
154
|
+
if (item.value.endsWith(" ") && afterCursor.startsWith(" ")) afterCursor = afterCursor.trimStart();
|
|
155
|
+
const nextLines = [...lines];
|
|
156
|
+
nextLines[cursorLine] = newBefore + afterCursor;
|
|
157
|
+
return { lines: nextLines, cursorLine, cursorCol: newBefore.length };
|
|
158
|
+
},
|
|
159
|
+
shouldTriggerFileCompletion(lines, cursorLine, cursorCol) {
|
|
160
|
+
return current.shouldTriggerFileCompletion?.(lines, cursorLine, cursorCol) ?? true;
|
|
161
|
+
},
|
|
162
|
+
}));
|
|
163
|
+
});
|
|
164
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
|
|
3
|
+
export const CONCISE_SYSTEM_PROMPT = `
|
|
4
|
+
# Concise output rules
|
|
5
|
+
1. Start with the answer or next action; omit conversational preambles.
|
|
6
|
+
2. Use numbered steps only when order matters, with one bounded action per step.
|
|
7
|
+
3. Finish the primary task before mentioning optional follow-up work.
|
|
8
|
+
4. State failures directly and include the recovery action.
|
|
9
|
+
5. Keep lists focused; group long inventories under clear headings.
|
|
10
|
+
6. Do not invent time estimates, completion claims, or facts.
|
|
11
|
+
7. Preserve exact code, commands, paths, quoted text, warnings, and user-requested formats.
|
|
12
|
+
8. Omit recap sections and generic closing pleasantries.
|
|
13
|
+
`.trim();
|
|
14
|
+
|
|
15
|
+
export function isConcisedEnabled(): boolean {
|
|
16
|
+
return true;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function registerConcisePrompt(pi: ExtensionAPI): void {
|
|
20
|
+
pi.on("before_agent_start", (event) => ({
|
|
21
|
+
systemPrompt: `${event.systemPrompt}\n\n${CONCISE_SYSTEM_PROMPT}`,
|
|
22
|
+
}));
|
|
23
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import os from "node:os";
|
|
2
|
+
import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
3
|
+
|
|
4
|
+
export function formatCwd(cwd: string): string {
|
|
5
|
+
const home = process.env.HOME || process.env.USERPROFILE || os.homedir();
|
|
6
|
+
if (!home) return cwd;
|
|
7
|
+
const normalizedHome = home.replace(/[\\/]+$/, "");
|
|
8
|
+
const normalizedCwd = cwd.replace(/[\\/]+$/, "");
|
|
9
|
+
if (normalizedCwd === normalizedHome) return "~";
|
|
10
|
+
const separator = normalizedCwd.slice(normalizedHome.length, normalizedHome.length + 1);
|
|
11
|
+
return normalizedCwd.startsWith(normalizedHome) && (separator === "/" || separator === "\\")
|
|
12
|
+
? `~${normalizedCwd.slice(normalizedHome.length)}`
|
|
13
|
+
: cwd;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function padRight(text: string, width: number): string {
|
|
17
|
+
if (width <= 0) return "";
|
|
18
|
+
const clipped = truncateToWidth(text, width, "");
|
|
19
|
+
return clipped + " ".repeat(Math.max(0, width - visibleWidth(clipped)));
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function formatTime(milliseconds: number): string {
|
|
23
|
+
const totalSeconds = Math.max(0, Math.floor(milliseconds / 1_000));
|
|
24
|
+
if (totalSeconds < 60) return `${totalSeconds}s`;
|
|
25
|
+
const minutes = Math.floor(totalSeconds / 60);
|
|
26
|
+
if (minutes < 60) return `${minutes}m`;
|
|
27
|
+
return `${Math.floor(minutes / 60)}h${minutes % 60}m`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function formatTokens(value: number): string {
|
|
31
|
+
const amount = Math.max(0, value);
|
|
32
|
+
if (amount < 1_000) return `${Math.round(amount)}`;
|
|
33
|
+
if (amount >= 1_000_000) {
|
|
34
|
+
const precision = amount >= 10_000_000 ? 0 : 1;
|
|
35
|
+
return `${Number((amount / 1_000_000).toFixed(precision))}M`;
|
|
36
|
+
}
|
|
37
|
+
const precision = amount >= 100_000 ? 0 : 1;
|
|
38
|
+
return `${Number((amount / 1_000).toFixed(precision))}k`;
|
|
39
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
|
|
3
|
+
export function reportError(ctx: ExtensionContext, area: string, error: unknown): void {
|
|
4
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
5
|
+
ctx.ui.notify(`${area}: ${message}`, "error");
|
|
6
|
+
}
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
import { DynamicBorder, type ExtensionAPI, type ExtensionContext, type Theme, type ThemeColor } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { Container, Text, truncateToWidth, visibleWidth, type TUI } from "@earendil-works/pi-tui";
|
|
3
|
+
import { formatCwd, formatTime, formatTokens, padRight } from "./display.ts";
|
|
4
|
+
import { goalElapsedMilliseconds } from "./goals.ts";
|
|
5
|
+
import type { GoalRuntime, GoalState } from "./runtime.ts";
|
|
6
|
+
import { LEVEL_COLORS, type ThinkingLevel } from "./variants.ts";
|
|
7
|
+
|
|
8
|
+
const FOOTER_REFRESH_INTERVAL_MS = 1_000;
|
|
9
|
+
|
|
10
|
+
export function formatCost(usd: number): string {
|
|
11
|
+
if (!Number.isFinite(usd)) return "$—";
|
|
12
|
+
return `$${usd.toFixed(2)}`;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function formatContextProgress(tokensUsed: number | null, contextWindow: number, theme: Theme): string {
|
|
16
|
+
if (tokensUsed === null) return theme.fg("dim", "—% left (—)");
|
|
17
|
+
const windowSize = contextWindow > 0 ? contextWindow : 128_000;
|
|
18
|
+
const remaining = Math.max(0, Math.min(windowSize, windowSize - Math.max(0, tokensUsed)));
|
|
19
|
+
const percentLeft = Math.max(0, Math.min(100, Math.round((remaining / windowSize) * 100)));
|
|
20
|
+
const color: ThemeColor = percentLeft < 20 ? "error" : percentLeft <= 50 ? "warning" : "success";
|
|
21
|
+
const action = percentLeft < 15 ? " · /compact" : "";
|
|
22
|
+
return theme.fg(color, `${percentLeft}% left (${formatTokens(remaining)})${action}`);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function sumSessionCost(ctx: ExtensionContext): number {
|
|
26
|
+
let total = 0;
|
|
27
|
+
for (const entry of ctx.sessionManager.getEntries()) {
|
|
28
|
+
if (entry.type === "message" && entry.message.role === "assistant") total += entry.message.usage.cost.total;
|
|
29
|
+
else if (entry.type === "message" && entry.message.role === "toolResult" && entry.message.usage) {
|
|
30
|
+
total += entry.message.usage.cost.total;
|
|
31
|
+
} else if ((entry.type === "compaction" || entry.type === "branch_summary") && entry.usage) {
|
|
32
|
+
total += entry.usage.cost.total;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return total;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const PROVIDER_LABELS: Readonly<Record<string, string>> = {
|
|
39
|
+
"amazon-bedrock": "Amazon Bedrock",
|
|
40
|
+
"azure-openai-responses": "Azure OpenAI",
|
|
41
|
+
"github-copilot": "GitHub Copilot",
|
|
42
|
+
"google-vertex": "Google Vertex",
|
|
43
|
+
"openai-codex": "OpenAI",
|
|
44
|
+
anthropic: "Anthropic",
|
|
45
|
+
deepseek: "DeepSeek",
|
|
46
|
+
google: "Google",
|
|
47
|
+
ollama: "Ollama",
|
|
48
|
+
openai: "OpenAI",
|
|
49
|
+
openrouter: "OpenRouter",
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const PROVIDER_WORDS: Readonly<Record<string, string>> = {
|
|
53
|
+
ai: "AI",
|
|
54
|
+
api: "API",
|
|
55
|
+
deepseek: "DeepSeek",
|
|
56
|
+
github: "GitHub",
|
|
57
|
+
llm: "LLM",
|
|
58
|
+
openai: "OpenAI",
|
|
59
|
+
openrouter: "OpenRouter",
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
function formatProviderName(provider: string): string {
|
|
63
|
+
const normalized = provider.trim();
|
|
64
|
+
const known = PROVIDER_LABELS[normalized.toLocaleLowerCase()];
|
|
65
|
+
if (known) return known;
|
|
66
|
+
return normalized
|
|
67
|
+
.split(/[-_]+/u)
|
|
68
|
+
.filter(Boolean)
|
|
69
|
+
.map((word) => PROVIDER_WORDS[word.toLocaleLowerCase()] ?? `${word.charAt(0).toLocaleUpperCase()}${word.slice(1)}`)
|
|
70
|
+
.join(" ") || "Unknown provider";
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function modelDisplayName(model: NonNullable<ExtensionContext["model"]>): string {
|
|
74
|
+
return model.name?.trim() || model.id;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function formatModel(model: ExtensionContext["model"], theme: Theme, includeProvider = true): string {
|
|
78
|
+
if (!model) return theme.fg("dim", "No model");
|
|
79
|
+
const name = theme.fg("text", theme.bold(modelDisplayName(model)));
|
|
80
|
+
return includeProvider ? `${name} ${theme.fg("dim", formatProviderName(model.provider))}` : name;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function compactDirectory(cwd: string): string {
|
|
84
|
+
if (cwd === "~" || cwd === "/" || /^[A-Za-z]:[\\/]?$/u.test(cwd)) return cwd;
|
|
85
|
+
const normalized = cwd.replace(/\\/gu, "/").replace(/\/$/u, "");
|
|
86
|
+
const finalSegment = normalized.split("/").at(-1);
|
|
87
|
+
return finalSegment ? `…/${finalSegment}` : cwd;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function joinFooterParts(parts: string[], theme: Theme): string {
|
|
91
|
+
return parts.filter(Boolean).join(theme.fg("dim", " · "));
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function footerRowFits(left: string, right: string, width: number): boolean {
|
|
95
|
+
const contentWidth = visibleWidth(left) + (right ? visibleWidth(right) + 1 : 0);
|
|
96
|
+
return contentWidth + 2 <= width;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function renderFooterRow(left: string, right: string, width: number): string {
|
|
100
|
+
if (width <= 0) return "";
|
|
101
|
+
if (width < 3) return " ".repeat(width);
|
|
102
|
+
|
|
103
|
+
const innerWidth = width - 2;
|
|
104
|
+
if (!right) return ` ${padRight(left, innerWidth)} `;
|
|
105
|
+
|
|
106
|
+
const clippedRight = truncateToWidth(right, innerWidth, "");
|
|
107
|
+
const rightWidth = visibleWidth(clippedRight);
|
|
108
|
+
const leftBudget = Math.max(0, innerWidth - rightWidth - 1);
|
|
109
|
+
const clippedLeft = truncateToWidth(left, leftBudget, "…");
|
|
110
|
+
const gap = " ".repeat(Math.max(0, innerWidth - visibleWidth(clippedLeft) - rightWidth));
|
|
111
|
+
return ` ${clippedLeft}${gap}${clippedRight} `;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function formatGoalFooter(state: GoalState | undefined, theme: Theme): string {
|
|
115
|
+
if (!state) return "";
|
|
116
|
+
if (state.status === "active") return theme.fg("accent", `✻ goal · ${formatTime(goalElapsedMilliseconds(state))}`);
|
|
117
|
+
if (state.status === "paused") return theme.fg("warning", "Ⅱ goal paused");
|
|
118
|
+
if (state.status === "blocked") return theme.fg("error", "! goal blocked");
|
|
119
|
+
return theme.fg("success", "✓ goal complete");
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function registerFooter(pi: ExtensionAPI, goalRuntime: GoalRuntime): void {
|
|
123
|
+
let currentModel: ExtensionContext["model"];
|
|
124
|
+
let thinkingLevel: ThinkingLevel = "off";
|
|
125
|
+
let activeTui: TUI | undefined;
|
|
126
|
+
goalRuntime.requestRender = () => activeTui?.requestRender();
|
|
127
|
+
|
|
128
|
+
pi.on("session_start", (_event, ctx) => {
|
|
129
|
+
if (ctx.mode !== "tui") return;
|
|
130
|
+
const sessionStart = Date.now();
|
|
131
|
+
currentModel = ctx.model;
|
|
132
|
+
thinkingLevel = pi.getThinkingLevel() as ThinkingLevel;
|
|
133
|
+
const cwd = formatCwd(ctx.cwd);
|
|
134
|
+
|
|
135
|
+
ctx.ui.setFooter((tui, theme, footerData) => {
|
|
136
|
+
activeTui = tui;
|
|
137
|
+
const unsubscribe = footerData.onBranchChange(() => tui.requestRender());
|
|
138
|
+
const refreshTimer = setInterval(() => tui.requestRender(), FOOTER_REFRESH_INTERVAL_MS);
|
|
139
|
+
refreshTimer.unref?.();
|
|
140
|
+
return {
|
|
141
|
+
dispose() {
|
|
142
|
+
unsubscribe();
|
|
143
|
+
clearInterval(refreshTimer);
|
|
144
|
+
if (activeTui === tui) activeTui = undefined;
|
|
145
|
+
},
|
|
146
|
+
invalidate() {},
|
|
147
|
+
render(width: number): string[] {
|
|
148
|
+
if (width <= 0) return [];
|
|
149
|
+
const model = currentModel ?? ctx.model;
|
|
150
|
+
const level = model?.reasoning === false
|
|
151
|
+
? theme.fg("thinkingOff", "no reasoning")
|
|
152
|
+
: theme.fg(LEVEL_COLORS[thinkingLevel], thinkingLevel);
|
|
153
|
+
const usage = ctx.getContextUsage();
|
|
154
|
+
const contextWindow = usage?.contextWindow ?? model?.contextWindow ?? 128_000;
|
|
155
|
+
const context = formatContextProgress(usage?.tokens ?? null, contextWindow, theme);
|
|
156
|
+
const branch = footerData.getGitBranch();
|
|
157
|
+
const signature = formatModel(model, theme);
|
|
158
|
+
const fullDirectory = theme.fg("dim", cwd);
|
|
159
|
+
const focusedDirectory = theme.fg("dim", compactDirectory(cwd));
|
|
160
|
+
const goal = formatGoalFooter(goalRuntime.state, theme);
|
|
161
|
+
const rich = joinFooterParts([
|
|
162
|
+
signature,
|
|
163
|
+
level,
|
|
164
|
+
context,
|
|
165
|
+
goal,
|
|
166
|
+
branch ? theme.fg("dim", branch) : "",
|
|
167
|
+
theme.fg("dim", formatTime(Date.now() - sessionStart)),
|
|
168
|
+
theme.fg("dim", formatCost(sumSessionCost(ctx))),
|
|
169
|
+
], theme);
|
|
170
|
+
const focused = joinFooterParts([signature, context, goal], theme);
|
|
171
|
+
|
|
172
|
+
if (footerRowFits(rich, fullDirectory, width)) {
|
|
173
|
+
return [renderFooterRow(rich, fullDirectory, width)];
|
|
174
|
+
}
|
|
175
|
+
if (footerRowFits(rich, focusedDirectory, width)) {
|
|
176
|
+
return [renderFooterRow(rich, focusedDirectory, width)];
|
|
177
|
+
}
|
|
178
|
+
if (footerRowFits(focused, focusedDirectory, width)) {
|
|
179
|
+
return [renderFooterRow(focused, focusedDirectory, width)];
|
|
180
|
+
}
|
|
181
|
+
if (footerRowFits(focused, "", width)) {
|
|
182
|
+
return [renderFooterRow(focused, "", width)];
|
|
183
|
+
}
|
|
184
|
+
if (goal) {
|
|
185
|
+
const essentialGoal = joinFooterParts([context, goal], theme);
|
|
186
|
+
if (footerRowFits(essentialGoal, "", width)) return [renderFooterRow(essentialGoal, "", width)];
|
|
187
|
+
return [renderFooterRow(goal, context, width)];
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const essentialModel = formatModel(model, theme, false);
|
|
191
|
+
return [renderFooterRow(essentialModel, context, width)];
|
|
192
|
+
},
|
|
193
|
+
};
|
|
194
|
+
});
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
pi.on("model_select", (event) => {
|
|
198
|
+
currentModel = event.model;
|
|
199
|
+
activeTui?.requestRender();
|
|
200
|
+
});
|
|
201
|
+
pi.on("thinking_level_select", (event) => {
|
|
202
|
+
thinkingLevel = event.level;
|
|
203
|
+
activeTui?.requestRender();
|
|
204
|
+
});
|
|
205
|
+
pi.on("session_shutdown", () => {
|
|
206
|
+
activeTui = undefined;
|
|
207
|
+
goalRuntime.requestRender = undefined;
|
|
208
|
+
});
|
|
209
|
+
}
|