killeros 2.0.12 → 2.0.14
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 -0
- package/Killeros.ts +2 -0
- package/README.md +86 -52
- package/killeros/codex-fast-state.ts +38 -0
- package/killeros/codex-fast.ts +31 -0
- package/killeros/footer.ts +20 -5
- package/killeros/goals.ts +34 -9
- package/killeros/hooks.ts +16 -5
- package/killeros/question.ts +24 -14
- package/killeros/runtime.ts +5 -0
- package/killeros/safe-terminal-text.ts +6 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,27 @@ All notable changes to KillerOS are documented here.
|
|
|
4
4
|
|
|
5
5
|
## [Unreleased]
|
|
6
6
|
|
|
7
|
+
## [2.0.14] - 2026-08-22
|
|
8
|
+
|
|
9
|
+
### Fixed
|
|
10
|
+
|
|
11
|
+
- Prevented direct tag pushes from publishing commits that have not passed CI on `main`.
|
|
12
|
+
- Kept oversized hook payloads valid JSON and marked their bounded preview as truncated.
|
|
13
|
+
- Stripped terminal escape sequences and unsafe controls from model-controlled question and goal text.
|
|
14
|
+
- Aligned hook timeout validation and execution on the documented five-minute maximum.
|
|
15
|
+
- Required file-backed goals to create or change their deliverable after the goal starts, including after session restore.
|
|
16
|
+
- Removed a CI test dependency on an intentionally untracked internal document.
|
|
17
|
+
|
|
18
|
+
## [2.0.13] - 2026-08-21
|
|
19
|
+
|
|
20
|
+
### Added
|
|
21
|
+
|
|
22
|
+
- Added the process-local bare `/codex-fast` toggle for Codex priority requests, with a bold inline `Fast` footer indicator while an active Codex model uses it.
|
|
23
|
+
|
|
24
|
+
### Changed
|
|
25
|
+
|
|
26
|
+
- Converted all tracked test suites from JavaScript to strict TypeScript while keeping Node's built-in test runner.
|
|
27
|
+
|
|
7
28
|
## [2.0.12] - 2026-08-18
|
|
8
29
|
|
|
9
30
|
### Fixed
|
package/Killeros.ts
CHANGED
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
registerCompletionNotifications,
|
|
15
15
|
type CompletionNotificationDependencies,
|
|
16
16
|
} from "./killeros/notifications.ts";
|
|
17
|
+
import { registerCodexFastMode } from "./killeros/codex-fast.ts";
|
|
17
18
|
import { registerPersonalInstructions } from "./killeros/personal-instructions.ts";
|
|
18
19
|
import { registerQuestionTool } from "./killeros/question.ts";
|
|
19
20
|
import { createGoalRuntime, createInitRuntime } from "./killeros/runtime.ts";
|
|
@@ -42,6 +43,7 @@ export default function Killeros(pi: ExtensionAPI, options: KillerosOptions = {}
|
|
|
42
43
|
registerSlashAutocomplete(pi, commandResolver);
|
|
43
44
|
registerFooter(pi, goalRuntime);
|
|
44
45
|
registerVariants(pi);
|
|
46
|
+
registerCodexFastMode(pi);
|
|
45
47
|
registerInitCommand(pi, initRuntime, goalRuntime);
|
|
46
48
|
registerLifecycleHooks(pi);
|
|
47
49
|
const goalCompaction = registerGoalSettlement(pi, goalRuntime, initRuntime);
|
package/README.md
CHANGED
|
@@ -1,57 +1,54 @@
|
|
|
1
1
|
# KillerOS
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
KillerOS is a TypeScript extension for the Pi coding agent. It adds a custom TUI, repository initialization, long-running goals, reasoning controls, interactive questions, lifecycle hooks, and a small set of command aliases.
|
|
4
4
|
|
|
5
5
|
## Requirements
|
|
6
6
|
|
|
7
7
|
- Node.js `22.19.0` or later
|
|
8
8
|
- Pi `0.84.2` or later
|
|
9
|
-
-
|
|
9
|
+
- An interactive TUI session for the custom header, editor, footer, `question`, and `/init`
|
|
10
10
|
|
|
11
|
-
|
|
11
|
+
KillerOS ships as TypeScript. Pi supplies the runtime modules listed as peer dependencies.
|
|
12
12
|
|
|
13
13
|
## Install
|
|
14
14
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
Install KillerOS:
|
|
15
|
+
Install the current npm release:
|
|
18
16
|
|
|
19
17
|
```bash
|
|
20
18
|
pi install npm:killeros
|
|
21
19
|
```
|
|
22
20
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
Install the latest commit:
|
|
21
|
+
Install from GitHub:
|
|
26
22
|
|
|
27
23
|
```bash
|
|
28
24
|
pi install git:github.com/KyrosHendrix/pi-KillerOS
|
|
29
25
|
```
|
|
30
26
|
|
|
31
|
-
Pin an install to
|
|
27
|
+
Pin an install to version `v2.0.14`:
|
|
32
28
|
|
|
33
29
|
```bash
|
|
34
|
-
pi install git:github.com/KyrosHendrix/pi-KillerOS@v2.0.
|
|
30
|
+
pi install git:github.com/KyrosHendrix/pi-KillerOS@v2.0.14
|
|
35
31
|
```
|
|
36
32
|
|
|
37
|
-
Add `-l` to either command for
|
|
33
|
+
Add `-l` to either command to install only for the current project. Restart Pi after installing.
|
|
38
34
|
|
|
39
35
|
## Features
|
|
40
36
|
|
|
41
|
-
-
|
|
42
|
-
-
|
|
43
|
-
-
|
|
44
|
-
-
|
|
45
|
-
-
|
|
46
|
-
-
|
|
47
|
-
-
|
|
48
|
-
- Optional completion sounds
|
|
49
|
-
- `/variants`
|
|
50
|
-
-
|
|
51
|
-
-
|
|
52
|
-
- `
|
|
53
|
-
-
|
|
54
|
-
-
|
|
37
|
+
- A compact startup card with the extension version, model, provider, `/model`, working directory, Git branch, and a session-stable tip.
|
|
38
|
+
- A dark theme with coral accents and neutral tool-call containers for pending, successful, and failed calls.
|
|
39
|
+
- A 12-frame orange activity glyph with event-based status text and a quiet hidden-thinking label.
|
|
40
|
+
- A multiline editor with a single focus-aware prompt arrow, a session-stable empty-state suggestion, overflow-only scroll indicators, Shift+Enter support, and slash-command completion.
|
|
41
|
+
- A settled transcript line that reports `Done`, `Stopped`, or `Failed` with elapsed time, while preserving older `✻ Worked for ...` entries.
|
|
42
|
+
- A responsive footer that keeps model, context, and goal state visible as the terminal gets narrower.
|
|
43
|
+
- Automatic turn-boundary context compaction in TUI and RPC modes.
|
|
44
|
+
- Optional completion sounds for successful and failed settled requests.
|
|
45
|
+
- `/variants` for selecting a model reasoning level.
|
|
46
|
+
- `/codex-fast` for toggling the `priority` service tier on Codex requests.
|
|
47
|
+
- `/goal` for durable objectives with pause, resume, edit, clear, completion, continuation, and blocker audits.
|
|
48
|
+
- `/init` for generating a root `AGENTS.md` from a bounded, safe set of repository files.
|
|
49
|
+
- A `question` tool with single-select and opt-in multi-select controls.
|
|
50
|
+
- Slash completion based on Pi's registered commands, extensions, prompts, and skills.
|
|
51
|
+
- Goal-aware `/clear` and graceful `/exit` handling.
|
|
55
52
|
|
|
56
53
|
## Commands
|
|
57
54
|
|
|
@@ -65,34 +62,61 @@ Add `-l` to either command for a project-only install. Restart Pi after installi
|
|
|
65
62
|
/goal clear Stop current goal work and remove the goal
|
|
66
63
|
/variants Open the reasoning-level selector
|
|
67
64
|
/variants high Set a reasoning level directly
|
|
65
|
+
/codex-fast Toggle process-local Codex fast mode
|
|
68
66
|
/notification Configure the completion sound
|
|
69
67
|
/clear Start a new session after confirmation
|
|
70
68
|
/exit Quit Pi gracefully
|
|
71
69
|
```
|
|
72
70
|
|
|
73
|
-
|
|
71
|
+
### Codex fast mode
|
|
72
|
+
|
|
73
|
+
`/codex-fast` takes no arguments. It toggles a process-local setting. When it is enabled and the active model uses the `openai-codex` provider, KillerOS adds `service_tier: "priority"` to the provider request. The footer shows bold `Fast` between the model and provider.
|
|
74
|
+
|
|
75
|
+
The setting survives a session reload within the same process, does not change other providers, is not saved to KillerOS configuration, and starts disabled after Pi restarts. A provider failure leaves the setting enabled and follows Pi's normal error handling.
|
|
76
|
+
|
|
77
|
+
### Goals
|
|
78
|
+
|
|
79
|
+
`/goal` requires a saved session in TUI or RPC mode. Goal state is stored in versioned session entries on the active branch and restored after reload, resume, fork, and tree navigation.
|
|
80
|
+
|
|
81
|
+
An active goal injects its unchanged objective on each turn and continues one settled turn at a time. The model must use KillerOS's private goal tool to report completion. If the objective explicitly asks for a named file-like deliverable at one quoted absolute path, KillerOS records that path and checks that a regular file exists before accepting completion. Other objectives use the model's completion report.
|
|
82
|
+
|
|
83
|
+
KillerOS marks a goal blocked only after a stable lowercase blocker key recorded on three consecutive goal turns. A changed key, skipped turn, resume, or edit resets the streak. Final prose does not end the loop.
|
|
84
|
+
|
|
85
|
+
Active goals replace the footer path with warning-yellow `/goal is active (...)` and keep the exact elapsed time visible.
|
|
86
|
+
|
|
87
|
+
`/goal pause` and `/goal clear` save paused or cleared state before aborting current goal work, so settlement cannot restart it. Aborted turns, provider failures, and continuation failures pause safely. Failed edit and replacement writes dispatch no edited objective. Replacing unfinished work requires confirmation, and `/goal edit` works only in TUI mode.
|
|
74
88
|
|
|
75
|
-
|
|
89
|
+
### Repository initialization
|
|
76
90
|
|
|
77
|
-
`/init` freezes a safe project-file map and exposes only dedicated read and list operations while it generates root `AGENTS.md`. Git-ignored files, known secret paths, private-key formats, other guidance, dependencies, links, non-regular files, and files outside
|
|
91
|
+
`/init` freezes a safe project-file map and exposes only dedicated read and list operations while it generates the root `AGENTS.md`. Git-ignored files, known secret paths, private-key formats, other guidance files, dependencies, links, non-regular files, and files outside the map are unavailable to the generation step.
|
|
78
92
|
|
|
79
|
-
|
|
93
|
+
An existing root `AGENTS.md` is protected policy. Compatible rules are preserved. A real policy conflict leaves the file unchanged with a reason, and a concurrent target change aborts installation instead of replacing the newer file.
|
|
94
|
+
|
|
95
|
+
The generated file uses four behavioral sections adapted from `writing-great-guidelines`. `/init` does not require another skill installation, ask setup questions, start a second model process, or write another file. Pi resources reload only after a successful write.
|
|
80
96
|
|
|
81
97
|
### Interactive questions
|
|
82
98
|
|
|
83
|
-
Single-select remains the default. Explicit `minSelections: 1` and `maxSelections: 1` are
|
|
99
|
+
Single-select remains the default. Explicit `minSelections: 1` and `maxSelections: 1` are accepted; other single-select bounds are rejected. Use `mode: "multiple"` to opt into multi-select. The custom answer counts as one selection.
|
|
84
100
|
|
|
85
|
-
In multi-select, use Space or a visible number to toggle an option, `/` to filter, and Enter to submit. The filter accepts spaces
|
|
101
|
+
In multi-select mode, use Space or a visible number to toggle an option, `/` to filter, and Enter to submit. The filter accepts spaces. Enter applies the filter and Escape returns to the choices. Checked options remain selected when the filter changes. Select `Type a custom answer` to add or edit one custom item alongside the checked options.
|
|
86
102
|
|
|
87
|
-
Supported reasoning levels are `off`, `minimal`, `low`, `medium`, `high`, `xhigh`, and `max`. KillerOS limits
|
|
103
|
+
Supported reasoning levels are `off`, `minimal`, `low`, `medium`, `high`, `xhigh`, and `max`. KillerOS limits the selector to levels supported by the current model.
|
|
88
104
|
|
|
89
105
|
## Configuration
|
|
90
106
|
|
|
91
|
-
KillerOS activates its packaged `killeros` theme when a TUI session starts. Tool-call backgrounds stay neutral
|
|
107
|
+
KillerOS activates its packaged `killeros` theme when a TUI session starts. Tool-call backgrounds stay neutral in pending, successful, and failed states.
|
|
108
|
+
|
|
109
|
+
The completion sound is a global user preference in Pi's agent directory and is off by default. Run `/notification` in TUI mode to change it. Enabled TUI tabs append ``, which requires a Nerd Font. KillerOS uses the terminal's audible bell, so a terminal that disables the bell cannot play the sound.
|
|
110
|
+
|
|
111
|
+
### Automatic compaction
|
|
92
112
|
|
|
93
|
-
|
|
113
|
+
Automatic compaction is enabled by default when Pi's effective `compaction.enabled` setting is true. After each completed assistant turn, including tool execution, KillerOS reads the active model's context usage and calls Pi's public compaction API when:
|
|
114
|
+
|
|
115
|
+
```text
|
|
116
|
+
remainingTokens <= max(contextWindow * percentRemaining / 100, reserveTokens)
|
|
117
|
+
```
|
|
94
118
|
|
|
95
|
-
|
|
119
|
+
The default `percentRemaining` is `15`. Pi owns `reserveTokens` and `keepRecentTokens`. KillerOS reads the effective settings through Pi's public `SettingsManager` with `getAgentDir()` and stores its own preference in the global `killeros.json` file:
|
|
96
120
|
|
|
97
121
|
```json
|
|
98
122
|
{
|
|
@@ -103,29 +127,33 @@ Automatic compaction is enabled by default when Pi's effective `compaction.enabl
|
|
|
103
127
|
}
|
|
104
128
|
```
|
|
105
129
|
|
|
106
|
-
Missing context readings skip the check. Successful ordinary-prompt compaction queues one hidden continuation
|
|
130
|
+
Missing context readings skip the check. Successful ordinary-prompt compaction queues one hidden continuation. Active `/goal` runs use the existing session-compaction and goal-continuation path. Failed compaction does not retry automatically, and manual `/compact` behavior is unchanged.
|
|
107
131
|
|
|
108
|
-
|
|
132
|
+
Pi writes the summary, applies manual focus instructions, tracks files, retries summarization, and handles overflow recovery. KillerOS only decides when to request proactive compaction.
|
|
109
133
|
|
|
110
|
-
|
|
134
|
+
Manual `/compact` pauses the current goal turn before summarization. After Pi saves the summary, KillerOS resumes that goal revision automatically. A failed or cancelled manual compaction stays paused. Run `/goal pause` during the pause to cancel automatic recovery.
|
|
111
135
|
|
|
112
|
-
|
|
136
|
+
### Project instructions and hooks
|
|
113
137
|
|
|
114
138
|
For trusted projects, KillerOS loads `AGENTS.local.md` after Pi's shared repository context. A one-line `@path` or `@~/path` file imports personal guidance from another location.
|
|
115
139
|
|
|
116
|
-
Lifecycle hooks
|
|
140
|
+
Lifecycle hooks load from `.pi/killeros-hooks.json` at session start. Supported event keys are `tool_call`, `tool_result`, and `agent_settled`. Regular-expression matchers apply only to the first two events. KillerOS rejects a matcher on `agent_settled`.
|
|
141
|
+
|
|
142
|
+
Hook commands run from the repository root with `KILLEROS_EVENT`, `KILLEROS_TOOL`, and `KILLEROS_PAYLOAD` environment variables. A failed `tool_call` hook blocks the tool. Failures for later events notify the user. If the parent request is aborted, KillerOS stops the hook process tree with bounded graceful and forced cleanup without reporting cancellation as a hook failure.
|
|
117
143
|
|
|
118
144
|
## Behavior by mode
|
|
119
145
|
|
|
120
146
|
| Mode | Behavior |
|
|
121
|
-
|
|
122
|
-
| TUI | All features are available, including proactive compaction,
|
|
123
|
-
| RPC | Proactive compaction and goal set/view/pause/resume/clear work
|
|
124
|
-
| Print/JSON | Interactive questions, `/goal`, `/init`, and proactive compaction are disabled
|
|
147
|
+
| --- | --- |
|
|
148
|
+
| TUI | All features are available, including proactive compaction, completion sounds, and the tab-title indicator. |
|
|
149
|
+
| RPC | Proactive compaction and goal set/view/pause/resume/clear work. TUI components, `/goal edit`, `/init`, completion sounds, and the title indicator are disabled. |
|
|
150
|
+
| Print/JSON | Interactive questions, `/goal`, `/init`, and proactive compaction are disabled. Completion sounds and the title indicator are disabled. |
|
|
151
|
+
|
|
152
|
+
## Development and validation
|
|
125
153
|
|
|
126
|
-
|
|
154
|
+
Source and tests use strict TypeScript. Tests run with Node's built-in test runner and type stripping.
|
|
127
155
|
|
|
128
|
-
Before release, run:
|
|
156
|
+
Before a release, run:
|
|
129
157
|
|
|
130
158
|
```bash
|
|
131
159
|
npm ci
|
|
@@ -135,17 +163,23 @@ npm pack --dry-run
|
|
|
135
163
|
pi -ne -e . --mode rpc
|
|
136
164
|
```
|
|
137
165
|
|
|
138
|
-
The package manifest lists Pi
|
|
166
|
+
The package manifest lists Pi's built-in modules as peer dependencies, so npm does not bundle another copy.
|
|
167
|
+
|
|
168
|
+
## Releases
|
|
169
|
+
|
|
170
|
+
For a normal release:
|
|
139
171
|
|
|
140
|
-
|
|
172
|
+
1. Update the version in `package.json` and both matching version fields in `package-lock.json`.
|
|
173
|
+
2. Add a dated section with the same version to `CHANGELOG.md`.
|
|
174
|
+
3. Push the release commit to `main`.
|
|
141
175
|
|
|
142
|
-
|
|
176
|
+
After the full CI workflow passes on `main`, the release workflow validates the commit and changelog, publishes the package to npm through trusted publishing, and creates the matching tag and GitHub release. The [`pi-package` keyword](https://github.com/earendil-works/pi/blob/main/packages/coding-agent/docs/packages.md) makes the npm package visible in Pi's package catalog.
|
|
143
177
|
|
|
144
|
-
Do not
|
|
178
|
+
Do not push version tags manually. Tag pushes cannot publish; every published commit must pass the full `main` CI workflow.
|
|
145
179
|
|
|
146
180
|
## Security
|
|
147
181
|
|
|
148
|
-
Pi extensions run with your user permissions. Review the source before installing KillerOS globally. KillerOS
|
|
182
|
+
Pi extensions run with your user permissions. Review the source before installing KillerOS globally. KillerOS runs lifecycle hook commands only for projects Pi marks as trusted. Review `.pi/killeros-hooks.json` before enabling project trust.
|
|
149
183
|
|
|
150
184
|
## License
|
|
151
185
|
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
type CodexFastStateListener = () => void;
|
|
2
|
+
|
|
3
|
+
interface CodexFastState {
|
|
4
|
+
enabled: boolean;
|
|
5
|
+
listeners: Set<CodexFastStateListener>;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
const GLOBAL_STATE_KEY = "__killerosCodexFastState";
|
|
9
|
+
type GlobalWithCodexFastState = typeof globalThis & {
|
|
10
|
+
[GLOBAL_STATE_KEY]?: CodexFastState;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
const globalState = globalThis as GlobalWithCodexFastState;
|
|
14
|
+
const state = globalState[GLOBAL_STATE_KEY] ??= {
|
|
15
|
+
enabled: false,
|
|
16
|
+
listeners: new Set<CodexFastStateListener>(),
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export function isCodexFastEnabled(): boolean {
|
|
20
|
+
return state.enabled;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function toggleCodexFast(): boolean {
|
|
24
|
+
state.enabled = !state.enabled;
|
|
25
|
+
for (const listener of [...state.listeners]) listener();
|
|
26
|
+
return state.enabled;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function subscribeCodexFast(listener: CodexFastStateListener): () => void {
|
|
30
|
+
state.listeners.add(listener);
|
|
31
|
+
return () => state.listeners.delete(listener);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Test-only reset for the process-global state between isolated harnesses. */
|
|
35
|
+
export function resetCodexFastState(): void {
|
|
36
|
+
state.enabled = false;
|
|
37
|
+
state.listeners.clear();
|
|
38
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { isCodexFastEnabled, toggleCodexFast } from "./codex-fast-state.ts";
|
|
3
|
+
|
|
4
|
+
const CODEX_PROVIDER = "openai-codex";
|
|
5
|
+
type RequestPayload = Record<string, unknown>;
|
|
6
|
+
|
|
7
|
+
function isRequestPayload(value: unknown): value is RequestPayload {
|
|
8
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function registerCodexFastMode(pi: ExtensionAPI): void {
|
|
12
|
+
pi.registerCommand("codex-fast", {
|
|
13
|
+
description: "Toggle Codex fast mode",
|
|
14
|
+
handler: async (args, ctx) => {
|
|
15
|
+
if (args.trim()) {
|
|
16
|
+
ctx.ui.notify("Usage: /codex-fast", "error");
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const enabled = toggleCodexFast();
|
|
21
|
+
ctx.ui.notify(`Fast ${enabled ? "enabled" : "disabled"}`, "info");
|
|
22
|
+
},
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
pi.on("before_provider_request", (event, ctx) => {
|
|
26
|
+
if (!isCodexFastEnabled() || ctx.model?.provider !== CODEX_PROVIDER || !isRequestPayload(event.payload)) {
|
|
27
|
+
return event.payload;
|
|
28
|
+
}
|
|
29
|
+
return { ...event.payload, service_tier: "priority" };
|
|
30
|
+
});
|
|
31
|
+
}
|
package/killeros/footer.ts
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import { DynamicBorder, type ExtensionAPI, type ExtensionContext, type Theme, type ThemeColor } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import { Container, Text, truncateToWidth, visibleWidth, type TUI } from "@earendil-works/pi-tui";
|
|
3
|
+
import { isCodexFastEnabled, subscribeCodexFast } from "./codex-fast-state.ts";
|
|
3
4
|
import { formatCwd, formatTime, formatTokens, padRight } from "./display.ts";
|
|
4
5
|
import { goalElapsedMilliseconds } from "./goals.ts";
|
|
5
6
|
import type { GoalRuntime, GoalState } from "./runtime.ts";
|
|
6
7
|
import { LEVEL_COLORS, type ThinkingLevel } from "./variants.ts";
|
|
7
8
|
|
|
8
9
|
const FOOTER_REFRESH_INTERVAL_MS = 1_000;
|
|
10
|
+
const CODEX_PROVIDER = "openai-codex";
|
|
9
11
|
|
|
10
12
|
export function formatCost(usd: number): string {
|
|
11
13
|
if (!Number.isFinite(usd)) return "$—";
|
|
@@ -88,10 +90,19 @@ function modelDisplayName(model: NonNullable<ExtensionContext["model"]>): string
|
|
|
88
90
|
return model.name?.trim() || model.id;
|
|
89
91
|
}
|
|
90
92
|
|
|
91
|
-
export function formatModel(
|
|
93
|
+
export function formatModel(
|
|
94
|
+
model: ExtensionContext["model"],
|
|
95
|
+
theme: Theme,
|
|
96
|
+
includeProvider = true,
|
|
97
|
+
showCodexFast = false,
|
|
98
|
+
): string {
|
|
92
99
|
if (!model) return theme.fg("dim", "No model");
|
|
93
100
|
const name = theme.fg("text", theme.bold(modelDisplayName(model)));
|
|
94
|
-
|
|
101
|
+
const fast = showCodexFast && model.provider === CODEX_PROVIDER
|
|
102
|
+
? theme.fg("accent", theme.bold("Fast"))
|
|
103
|
+
: "";
|
|
104
|
+
const provider = includeProvider ? theme.fg("dim", formatProviderName(model.provider)) : "";
|
|
105
|
+
return [name, fast, provider].filter(Boolean).join(" ");
|
|
95
106
|
}
|
|
96
107
|
|
|
97
108
|
function compactDirectory(cwd: string): string {
|
|
@@ -158,6 +169,7 @@ export function registerFooter(pi: ExtensionAPI, goalRuntime: GoalRuntime): void
|
|
|
158
169
|
let activeTui: TUI | undefined;
|
|
159
170
|
let cachedSessionCost = 0;
|
|
160
171
|
let sessionCostDirty = true;
|
|
172
|
+
let unsubscribeCodexFast: (() => void) | undefined;
|
|
161
173
|
const resetSessionCost = (): void => {
|
|
162
174
|
cachedSessionCost = 0;
|
|
163
175
|
sessionCostDirty = true;
|
|
@@ -178,6 +190,8 @@ export function registerFooter(pi: ExtensionAPI, goalRuntime: GoalRuntime): void
|
|
|
178
190
|
pi.on("session_start", (_event, ctx) => {
|
|
179
191
|
resetSessionCost();
|
|
180
192
|
if (ctx.mode !== "tui") return;
|
|
193
|
+
unsubscribeCodexFast?.();
|
|
194
|
+
unsubscribeCodexFast = subscribeCodexFast(() => activeTui?.requestRender());
|
|
181
195
|
const sessionStart = Date.now();
|
|
182
196
|
currentModel = ctx.model;
|
|
183
197
|
thinkingLevel = pi.getThinkingLevel() as ThinkingLevel;
|
|
@@ -210,7 +224,7 @@ export function registerFooter(pi: ExtensionAPI, goalRuntime: GoalRuntime): void
|
|
|
210
224
|
const contextWindow = usage?.contextWindow ?? model?.contextWindow ?? 128_000;
|
|
211
225
|
const context = formatContextProgress(usage?.tokens ?? null, contextWindow, theme);
|
|
212
226
|
const branch = footerData.getGitBranch();
|
|
213
|
-
const signature = formatModel(model, theme);
|
|
227
|
+
const signature = formatModel(model, theme, true, isCodexFastEnabled());
|
|
214
228
|
const fullDirectory = theme.fg("dim", cwd);
|
|
215
229
|
const focusedDirectory = theme.fg("dim", compactDirectory(cwd));
|
|
216
230
|
const goal = formatGoalFooter(goalRuntime.state, theme);
|
|
@@ -224,7 +238,7 @@ export function registerFooter(pi: ExtensionAPI, goalRuntime: GoalRuntime): void
|
|
|
224
238
|
theme.fg("dim", formatTime(Date.now() - sessionStart)),
|
|
225
239
|
theme.fg("dim", formatCost(getSessionCost(ctx))),
|
|
226
240
|
], theme);
|
|
227
|
-
const essentialModel = formatModel(model, theme, false);
|
|
241
|
+
const essentialModel = formatModel(model, theme, false, isCodexFastEnabled());
|
|
228
242
|
const primaryRow = footerRowFits(primary, session, width)
|
|
229
243
|
? renderFooterRow(primary, session, width)
|
|
230
244
|
: footerRowFits(primary, "", width)
|
|
@@ -241,7 +255,6 @@ export function registerFooter(pi: ExtensionAPI, goalRuntime: GoalRuntime): void
|
|
|
241
255
|
: footerRowFits(branchLabel, focusedDirectory, width)
|
|
242
256
|
? renderFooterRow(branchLabel, focusedDirectory, width)
|
|
243
257
|
: renderFooterRow(branchLabel, "", width);
|
|
244
|
-
|
|
245
258
|
return renderFooter([primaryRow, secondaryRow], width, theme);
|
|
246
259
|
},
|
|
247
260
|
};
|
|
@@ -263,6 +276,8 @@ export function registerFooter(pi: ExtensionAPI, goalRuntime: GoalRuntime): void
|
|
|
263
276
|
activeTui?.requestRender();
|
|
264
277
|
});
|
|
265
278
|
pi.on("session_shutdown", () => {
|
|
279
|
+
unsubscribeCodexFast?.();
|
|
280
|
+
unsubscribeCodexFast = undefined;
|
|
266
281
|
resetSessionCost();
|
|
267
282
|
activeTui = undefined;
|
|
268
283
|
goalRuntime.requestRender = undefined;
|
package/killeros/goals.ts
CHANGED
|
@@ -9,7 +9,8 @@ import { BoundedText } from "./bounded-text.ts";
|
|
|
9
9
|
import { formatTime, formatTokens } from "./display.ts";
|
|
10
10
|
import { reportError } from "./errors.ts";
|
|
11
11
|
import { resolvePersonalInstructions } from "./personal-instructions.ts";
|
|
12
|
-
import type { GoalBlockerAudit, GoalFileVerification, GoalRuntime, GoalState, GoalStatus, InitRuntime } from "./runtime.ts";
|
|
12
|
+
import type { GoalBlockerAudit, GoalFileBaseline, GoalFileVerification, GoalRuntime, GoalState, GoalStatus, InitRuntime } from "./runtime.ts";
|
|
13
|
+
import { safeTerminalText } from "./safe-terminal-text.ts";
|
|
13
14
|
|
|
14
15
|
const GOAL_ENTRY_TYPE = "killeros-goal";
|
|
15
16
|
const GOAL_CONTINUATION_TYPE = "killeros-goal-continuation";
|
|
@@ -67,13 +68,21 @@ function finiteNonNegative(value: unknown): value is number {
|
|
|
67
68
|
return typeof value === "number" && Number.isFinite(value) && value >= 0;
|
|
68
69
|
}
|
|
69
70
|
|
|
71
|
+
function isGoalFileBaseline(value: unknown): value is GoalFileBaseline {
|
|
72
|
+
if (!value || typeof value !== "object") return false;
|
|
73
|
+
const candidate = value as { exists?: unknown; size?: unknown; mtimeMs?: unknown };
|
|
74
|
+
if (candidate.exists === false) return candidate.size === undefined && candidate.mtimeMs === undefined;
|
|
75
|
+
return candidate.exists === true && finiteNonNegative(candidate.size) && finiteNonNegative(candidate.mtimeMs);
|
|
76
|
+
}
|
|
77
|
+
|
|
70
78
|
function isGoalFileVerification(value: unknown): value is GoalFileVerification {
|
|
71
79
|
if (!value || typeof value !== "object") return false;
|
|
72
80
|
const candidate = value as Partial<GoalFileVerification>;
|
|
73
81
|
return candidate.kind === "file"
|
|
74
82
|
&& typeof candidate.path === "string"
|
|
75
83
|
&& candidate.path === candidate.path.trim()
|
|
76
|
-
&& isAbsoluteFilePath(candidate.path)
|
|
84
|
+
&& isAbsoluteFilePath(candidate.path)
|
|
85
|
+
&& isGoalFileBaseline(candidate.baseline);
|
|
77
86
|
}
|
|
78
87
|
|
|
79
88
|
function isAbsoluteFilePath(value: string): boolean {
|
|
@@ -81,13 +90,23 @@ function isAbsoluteFilePath(value: string): boolean {
|
|
|
81
90
|
return path.isAbsolute(value) || path.win32.isAbsolute(value);
|
|
82
91
|
}
|
|
83
92
|
|
|
93
|
+
function captureGoalFileBaseline(filePath: string): GoalFileBaseline {
|
|
94
|
+
try {
|
|
95
|
+
const artifact = lstatSync(filePath);
|
|
96
|
+
return { exists: true, size: artifact.size, mtimeMs: artifact.mtimeMs };
|
|
97
|
+
} catch {
|
|
98
|
+
return { exists: false };
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
84
102
|
function inferGoalVerification(objective: string): GoalFileVerification | undefined {
|
|
85
103
|
const destination = /\b(?:create|write|save|generate)\b[^\r\n]{0,160}?\b(?:file|document|markdown|report|spreadsheet|presentation|image)\b\s+(?:to|at|as|destination(?:\s+is)?|output(?:\s+(?:to|at))?)\b\s*(?:`([^`\r\n]+)`|"([^"\r\n]+)"|'([^'\r\n]+)'|([A-Za-z]:\\[^\s,;]+|\/[^\s,;]+))/giu;
|
|
86
104
|
const paths = [...objective.matchAll(destination)]
|
|
87
105
|
.map((match) => (match[1] ?? match[2] ?? match[3] ?? match[4] ?? "").trim())
|
|
88
106
|
.filter(isAbsoluteFilePath);
|
|
89
107
|
const unique = [...new Set(paths)];
|
|
90
|
-
|
|
108
|
+
const filePath = unique.length === 1 ? unique[0] : undefined;
|
|
109
|
+
return filePath ? { kind: "file", path: filePath, baseline: captureGoalFileBaseline(filePath) } : undefined;
|
|
91
110
|
}
|
|
92
111
|
|
|
93
112
|
function verifyGoalDeliverable(verification: GoalFileVerification): void {
|
|
@@ -100,6 +119,11 @@ function verifyGoalDeliverable(verification: GoalFileVerification): void {
|
|
|
100
119
|
if (!artifact.isFile()) {
|
|
101
120
|
throw new Error(`Goal deliverable is not a regular file at the required path: ${verification.path}`);
|
|
102
121
|
}
|
|
122
|
+
if (verification.baseline.exists
|
|
123
|
+
&& artifact.size === verification.baseline.size
|
|
124
|
+
&& artifact.mtimeMs === verification.baseline.mtimeMs) {
|
|
125
|
+
throw new Error(`Goal deliverable has not changed since the goal started: ${verification.path}`);
|
|
126
|
+
}
|
|
103
127
|
}
|
|
104
128
|
|
|
105
129
|
function isGoalBlockerAudit(value: unknown, turns: number, status: GoalStatus): value is GoalBlockerAudit {
|
|
@@ -555,9 +579,10 @@ export function registerGoal(
|
|
|
555
579
|
const icon = state.status === "active" ? "✻" : state.status === "paused" ? "Ⅱ" : state.status === "blocked" ? "!" : "✓";
|
|
556
580
|
const color: ThemeColor = state.status === "active" ? "accent" : state.status === "paused" ? "warning" : state.status === "blocked" ? "error" : "success";
|
|
557
581
|
const status = theme.fg(color, `${icon} Goal ${state.status}`);
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
582
|
+
const objective = safeTerminalText(state.objective);
|
|
583
|
+
if (!options.expanded) return new BoundedText(`${status}${theme.fg("dim", ` · ${objective}`)}`, 3);
|
|
584
|
+
const lines = [status, theme.fg("dim", objective)];
|
|
585
|
+
if (state.result) lines.push(theme.fg("muted", safeTerminalText(state.result)));
|
|
561
586
|
return new BoundedText(lines.join("\n"));
|
|
562
587
|
});
|
|
563
588
|
|
|
@@ -615,18 +640,18 @@ export function registerGoal(
|
|
|
615
640
|
};
|
|
616
641
|
},
|
|
617
642
|
renderCall(args, theme) {
|
|
618
|
-
return new Text(`${theme.fg("toolTitle", theme.bold("goal "))}${theme.fg("muted", args.status)}`, 0, 0);
|
|
643
|
+
return new Text(`${theme.fg("toolTitle", theme.bold("goal "))}${theme.fg("muted", safeTerminalText(args.status))}`, 0, 0);
|
|
619
644
|
},
|
|
620
645
|
renderResult(result, options, theme, context) {
|
|
621
646
|
if (context?.isError) {
|
|
622
647
|
const first = result.content[0];
|
|
623
|
-
const message = first?.type === "text" ? first.text : "Goal update failed";
|
|
648
|
+
const message = first?.type === "text" ? safeTerminalText(first.text) : "Goal update failed";
|
|
624
649
|
return new BoundedText(theme.fg("error", message), options.expanded ? undefined : 3);
|
|
625
650
|
}
|
|
626
651
|
const details = result.details;
|
|
627
652
|
if (!details) return new BoundedText(theme.fg("dim", "Goal updated"));
|
|
628
653
|
const label = details.status === "complete" ? "✓ Complete" : details.status === "blocked" ? "! Blocked" : `! Blocker audit ${details.streak}/3`;
|
|
629
|
-
const text = `${theme.fg(details.status === "complete" ? "success" : "warning", label)}${theme.fg("dim", ` · ${details.evidence}`)}`;
|
|
654
|
+
const text = `${theme.fg(details.status === "complete" ? "success" : "warning", label)}${theme.fg("dim", ` · ${safeTerminalText(details.evidence)}`)}`;
|
|
630
655
|
return new BoundedText(text, options.expanded ? undefined : 3);
|
|
631
656
|
},
|
|
632
657
|
});
|
package/killeros/hooks.ts
CHANGED
|
@@ -4,7 +4,6 @@ import path from "node:path";
|
|
|
4
4
|
import { StringDecoder } from "node:string_decoder";
|
|
5
5
|
import { CONFIG_DIR_NAME, type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
6
6
|
import { reportError } from "./errors.ts";
|
|
7
|
-
import { MAX_NODE_TIMER_MS } from "./limits.ts";
|
|
8
7
|
|
|
9
8
|
type KillerosHookEvent = "tool_call" | "tool_result" | "agent_settled";
|
|
10
9
|
|
|
@@ -29,6 +28,8 @@ interface HookExecutionResult {
|
|
|
29
28
|
|
|
30
29
|
const HOOK_EVENTS: readonly KillerosHookEvent[] = ["tool_call", "tool_result", "agent_settled"];
|
|
31
30
|
const HOOK_OUTPUT_LIMIT = 16 * 1024;
|
|
31
|
+
const HOOK_PAYLOAD_LIMIT = 8_000;
|
|
32
|
+
const HOOK_TIMEOUT_MAX_MS = 300_000;
|
|
32
33
|
|
|
33
34
|
function loadKillerosHooks(ctx: ExtensionContext): KillerosHookConfig {
|
|
34
35
|
const configPath = path.join(ctx.cwd, CONFIG_DIR_NAME, "killeros-hooks.json");
|
|
@@ -49,11 +50,14 @@ function loadKillerosHooks(ctx: ExtensionContext): KillerosHookConfig {
|
|
|
49
50
|
ctx.ui.notify(`Ignored ${event} hook ${index + 1}: matchers are only valid for tool events`, "warning");
|
|
50
51
|
return false;
|
|
51
52
|
}
|
|
53
|
+
if (hook?.timeoutMs !== undefined && (!Number.isSafeInteger(hook.timeoutMs) || hook.timeoutMs <= 0 || hook.timeoutMs > HOOK_TIMEOUT_MAX_MS)) {
|
|
54
|
+
ctx.ui.notify(`Ignored ${event} hook ${index + 1}: timeoutMs must be an integer from 1 to ${HOOK_TIMEOUT_MAX_MS}`, "warning");
|
|
55
|
+
return false;
|
|
56
|
+
}
|
|
52
57
|
const valid = hook
|
|
53
58
|
&& typeof hook.command === "string"
|
|
54
59
|
&& hook.command.trim().length > 0
|
|
55
|
-
&& (hook.matcher === undefined || typeof hook.matcher === "string")
|
|
56
|
-
&& (hook.timeoutMs === undefined || Number.isSafeInteger(hook.timeoutMs) && hook.timeoutMs > 0 && hook.timeoutMs <= MAX_NODE_TIMER_MS);
|
|
60
|
+
&& (hook.matcher === undefined || typeof hook.matcher === "string");
|
|
57
61
|
if (!valid) {
|
|
58
62
|
ctx.ui.notify(`Ignored invalid ${event} hook ${index + 1} in ${configPath}`, "warning");
|
|
59
63
|
return false;
|
|
@@ -189,15 +193,22 @@ export function executeHook(
|
|
|
189
193
|
finish(termination ? terminationCode() : 1);
|
|
190
194
|
});
|
|
191
195
|
child.once("close", (code) => finish(termination ? terminationCode() : code ?? 1));
|
|
192
|
-
timer = setTimeout(() => beginTermination("timeout"), Math.max(
|
|
196
|
+
timer = setTimeout(() => beginTermination("timeout"), Math.max(1, Math.min(timeoutMs, HOOK_TIMEOUT_MAX_MS)));
|
|
193
197
|
});
|
|
194
198
|
}
|
|
195
199
|
|
|
200
|
+
function serializeHookPayload(payload: unknown): string {
|
|
201
|
+
const serialized = JSON.stringify(payload) ?? "null";
|
|
202
|
+
if (serialized.length <= HOOK_PAYLOAD_LIMIT) return serialized;
|
|
203
|
+
const previewLength = Math.floor((HOOK_PAYLOAD_LIMIT - 64) / 2);
|
|
204
|
+
return JSON.stringify({ truncated: true, preview: serialized.slice(0, previewLength) });
|
|
205
|
+
}
|
|
206
|
+
|
|
196
207
|
function hookEnvironment(event: KillerosHookEvent, toolName = "", payload: unknown = {}): Record<string, string> {
|
|
197
208
|
return {
|
|
198
209
|
KILLEROS_EVENT: event,
|
|
199
210
|
KILLEROS_TOOL: toolName,
|
|
200
|
-
KILLEROS_PAYLOAD:
|
|
211
|
+
KILLEROS_PAYLOAD: serializeHookPayload(payload),
|
|
201
212
|
};
|
|
202
213
|
}
|
|
203
214
|
|
package/killeros/question.ts
CHANGED
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
} from "@earendil-works/pi-tui";
|
|
17
17
|
import { Type, type Static } from "typebox";
|
|
18
18
|
import { BoundedText } from "./bounded-text.ts";
|
|
19
|
+
import { safeTerminalText } from "./safe-terminal-text.ts";
|
|
19
20
|
|
|
20
21
|
const OptionSchema = Type.Object({
|
|
21
22
|
label: Type.String({ minLength: 1, maxLength: 200, description: "Display label for the option" }),
|
|
@@ -264,11 +265,12 @@ export function registerQuestionTool(pi: ExtensionAPI): void {
|
|
|
264
265
|
if (ctx.mode !== "tui") throw new Error("The question tool requires interactive TUI mode");
|
|
265
266
|
if (signal?.aborted) throw new Error("Question cancelled before it opened");
|
|
266
267
|
|
|
268
|
+
const question = safeTerminalText(params.question);
|
|
267
269
|
const options: DisplayOption[] = [
|
|
268
270
|
...params.options.map((option, index) => ({
|
|
269
|
-
label: option.label,
|
|
270
|
-
description: option.description,
|
|
271
|
-
preview: option.preview,
|
|
271
|
+
label: safeTerminalText(option.label),
|
|
272
|
+
description: option.description === undefined ? undefined : safeTerminalText(option.description),
|
|
273
|
+
preview: option.preview === undefined ? undefined : safeTerminalText(option.preview),
|
|
272
274
|
originalIndex: index + 1,
|
|
273
275
|
isOther: false,
|
|
274
276
|
})),
|
|
@@ -599,7 +601,7 @@ export function registerQuestionTool(pi: ExtensionAPI): void {
|
|
|
599
601
|
} else lines = [`${selected ? `> ${selected.label}` : "No matching options"} · ${position}`];
|
|
600
602
|
} else if (rowBudget <= 5) {
|
|
601
603
|
lines = [
|
|
602
|
-
...boundedQuestionLines(
|
|
604
|
+
...boundedQuestionLines(question, width, Math.max(1, rowBudget - 3)),
|
|
603
605
|
editMode !== "none"
|
|
604
606
|
? `${editMode === "filter" ? "Filter" : "Answer"} ${editMode === "filter" ? filterCount : editorCount}/${CUSTOM_INPUT_MAX_CHARACTERS.toLocaleString()}`
|
|
605
607
|
: selected ? optionLabel(selected, optionIndex) : "No matching options",
|
|
@@ -607,7 +609,7 @@ export function registerQuestionTool(pi: ExtensionAPI): void {
|
|
|
607
609
|
editMode !== "none" ? editHint : browseHint,
|
|
608
610
|
];
|
|
609
611
|
} else {
|
|
610
|
-
const questionLines = boundedQuestionLines(
|
|
612
|
+
const questionLines = boundedQuestionLines(question, width, Math.max(1, rowBudget - 5));
|
|
611
613
|
const contentRows = rowBudget - questionLines.length - 4;
|
|
612
614
|
const optionCapacity = Math.max(1, Math.min(5, Math.ceil(contentRows / 2)));
|
|
613
615
|
const detailCapacity = Math.max(0, contentRows - optionCapacity);
|
|
@@ -723,20 +725,26 @@ export function registerQuestionTool(pi: ExtensionAPI): void {
|
|
|
723
725
|
renderCall(args, theme, context) {
|
|
724
726
|
const { mode, minSelections: minimum, maxSelections: maximum } = normalizeQuestionSelection(args);
|
|
725
727
|
const multiple = mode === "multiple";
|
|
728
|
+
const question = safeTerminalText(args.question);
|
|
729
|
+
const options = args.options.map((option) => ({
|
|
730
|
+
label: safeTerminalText(option.label),
|
|
731
|
+
description: option.description === undefined ? undefined : safeTerminalText(option.description),
|
|
732
|
+
preview: option.preview === undefined ? undefined : safeTerminalText(option.preview),
|
|
733
|
+
}));
|
|
726
734
|
if (!context.expanded) {
|
|
727
735
|
const title = multiple ? "question (multi-select) " : "question ";
|
|
728
|
-
const detail = multiple ? `${
|
|
729
|
-
return new BoundedText(`${theme.fg("toolTitle", theme.bold(title))}${theme.fg("muted", oneLine(
|
|
736
|
+
const detail = multiple ? `${options.length} options · choose ${minimum}–${maximum}` : `${options.length} option${options.length === 1 ? "" : "s"}`;
|
|
737
|
+
return new BoundedText(`${theme.fg("toolTitle", theme.bold(title))}${theme.fg("muted", oneLine(question))}\n${theme.fg("dim", ` ${detail}`)}`, 3);
|
|
730
738
|
}
|
|
731
739
|
const title = multiple ? "question (multi-select) " : "question ";
|
|
732
|
-
const lines = [`${theme.fg("toolTitle", theme.bold(title))}${theme.fg("muted",
|
|
733
|
-
if (multiple) lines.push(theme.fg("dim", `${
|
|
734
|
-
|
|
740
|
+
const lines = [`${theme.fg("toolTitle", theme.bold(title))}${theme.fg("muted", question)}`];
|
|
741
|
+
if (multiple) lines.push(theme.fg("dim", `${options.length} options · choose ${minimum}–${maximum}`));
|
|
742
|
+
options.forEach((option, index) => {
|
|
735
743
|
lines.push(theme.fg("text", `${multiple ? "[ ] " : ""}${index + 1}. ${option.label}`));
|
|
736
744
|
if (option.description) lines.push(theme.fg("muted", ` ${option.description}`));
|
|
737
745
|
if (option.preview) lines.push(theme.fg("dim", option.preview));
|
|
738
746
|
});
|
|
739
|
-
lines.push(theme.fg("text", `${multiple ? "[ ] " : ""}${
|
|
747
|
+
lines.push(theme.fg("text", `${multiple ? "[ ] " : ""}${options.length + 1}. Type a custom answer`));
|
|
740
748
|
return new BoundedText(lines.join("\n"));
|
|
741
749
|
},
|
|
742
750
|
|
|
@@ -744,14 +752,16 @@ export function registerQuestionTool(pi: ExtensionAPI): void {
|
|
|
744
752
|
const details = result.details;
|
|
745
753
|
if (!details) {
|
|
746
754
|
const first = result.content[0];
|
|
747
|
-
return new BoundedText(first?.type === "text" ? first.text : "", options.expanded ? undefined : 3);
|
|
755
|
+
return new BoundedText(first?.type === "text" ? safeTerminalText(first.text) : "", options.expanded ? undefined : 3);
|
|
748
756
|
}
|
|
749
757
|
if (details.cancelled || ("answer" in details && details.answer === null)) return new BoundedText(theme.fg("warning", "Cancelled"));
|
|
750
758
|
if ("mode" in details && details.mode === "multiple") {
|
|
751
|
-
|
|
759
|
+
const answers = details.answers.map(safeTerminalText);
|
|
760
|
+
const customAnswer = details.customAnswer === undefined ? undefined : safeTerminalText(details.customAnswer);
|
|
761
|
+
return new MultipleResultText(answers, options.expanded, customAnswer, theme.fg.bind(theme));
|
|
752
762
|
}
|
|
753
763
|
if (!("answer" in details) || details.answer === null) return new BoundedText("");
|
|
754
|
-
const answer = details.answer;
|
|
764
|
+
const answer = safeTerminalText(details.answer);
|
|
755
765
|
if (details.wasCustom) {
|
|
756
766
|
return new BoundedText(`${theme.fg("success", "✓ ")}${theme.fg("muted", "(wrote) ")}${theme.fg("accent", answer)}`, options.expanded ? undefined : 3);
|
|
757
767
|
}
|
package/killeros/runtime.ts
CHANGED
|
@@ -28,9 +28,14 @@ export interface GoalBlockerAudit {
|
|
|
28
28
|
lastTurn: number;
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
+
export type GoalFileBaseline =
|
|
32
|
+
| { exists: false }
|
|
33
|
+
| { exists: true; size: number; mtimeMs: number };
|
|
34
|
+
|
|
31
35
|
export interface GoalFileVerification {
|
|
32
36
|
kind: "file";
|
|
33
37
|
path: string;
|
|
38
|
+
baseline: GoalFileBaseline;
|
|
34
39
|
}
|
|
35
40
|
|
|
36
41
|
export interface GoalState {
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { stripTerminalSequences } from "@earendil-works/pi-tui";
|
|
2
|
+
|
|
3
|
+
/** Remove terminal commands and unsafe controls while preserving line feeds. */
|
|
4
|
+
export function safeTerminalText(value: string): string {
|
|
5
|
+
return stripTerminalSequences(value).replace(/[\u0000-\u0009\u000B-\u001F\u007F-\u009F]/gu, "");
|
|
6
|
+
}
|