@vzlentin/harness 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +62 -2
- package/dist/answer.d.ts +29 -0
- package/dist/answer.js +171 -0
- package/dist/chat.d.ts +48 -0
- package/dist/chat.js +208 -0
- package/dist/children.d.ts +211 -0
- package/dist/children.js +390 -0
- package/dist/chrome.d.ts +18 -0
- package/dist/chrome.js +73 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +245 -0
- package/dist/client.d.ts +49 -0
- package/dist/client.js +162 -0
- package/dist/context.d.ts +13 -0
- package/dist/context.js +115 -0
- package/dist/durable-entry.d.ts +13 -0
- package/dist/durable-entry.js +33 -0
- package/dist/entries.d.ts +9 -0
- package/dist/entries.js +50 -0
- package/dist/herdr.d.ts +36 -0
- package/dist/herdr.js +106 -0
- package/dist/host.d.ts +18 -0
- package/dist/host.js +731 -0
- package/dist/picker.d.ts +22 -0
- package/dist/picker.js +70 -0
- package/dist/projects.d.ts +23 -0
- package/dist/projects.js +93 -0
- package/dist/protocol.d.ts +232 -0
- package/dist/protocol.js +54 -0
- package/dist/tasks.d.ts +31 -0
- package/dist/tasks.js +78 -0
- package/dist/theme.d.ts +3 -0
- package/dist/theme.js +18 -0
- package/dist/tool.d.ts +137 -0
- package/dist/tool.js +209 -0
- package/dist/trust.d.ts +5 -0
- package/dist/trust.js +15 -0
- package/dist/view-state.d.ts +16 -0
- package/dist/view-state.js +39 -0
- package/dist/viewer.d.ts +20 -0
- package/dist/viewer.js +307 -0
- package/package.json +48 -5
package/README.md
CHANGED
|
@@ -1,3 +1,63 @@
|
|
|
1
|
-
#
|
|
1
|
+
# harness
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Observable, persistent subagents on [Pi](https://github.com/earendil-works/pi)'s libraries and
|
|
4
|
+
[pi-durable](https://www.npmjs.com/package/@earendil-works/pi-durable). Each subagent is a conversation in a durable
|
|
5
|
+
host process. You can spawn it, check its status, wait for its answer, steer it, and watch it live in a viewer.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
You need Node.js 22.19 or later, and a login for a model provider in Pi's agent directory (`~/.pi/agent`): log in once
|
|
10
|
+
with `/login` in `pi`, or set the API key of the provider, such as `ANTHROPIC_API_KEY`.
|
|
11
|
+
|
|
12
|
+
```sh
|
|
13
|
+
npm install -g @vzlentin/harness
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
This gives you the `harness` command.
|
|
17
|
+
|
|
18
|
+
### Pi
|
|
19
|
+
|
|
20
|
+
To also get the `subagent` tool, the `/subagent` command and the subagents widget in Pi:
|
|
21
|
+
|
|
22
|
+
```sh
|
|
23
|
+
pi install npm:@vzlentin/pi-subagent
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
You can add it now or later. Pi and the `harness` command use the same host.
|
|
27
|
+
|
|
28
|
+
### Update and remove
|
|
29
|
+
|
|
30
|
+
```sh
|
|
31
|
+
npm update -g @vzlentin/harness
|
|
32
|
+
pi update # when you installed @vzlentin/pi-subagent
|
|
33
|
+
npm uninstall -g @vzlentin/harness
|
|
34
|
+
pi remove npm:@vzlentin/pi-subagent
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## Use
|
|
38
|
+
|
|
39
|
+
```sh
|
|
40
|
+
harness spawn --name review --prompt "Review the current diff"
|
|
41
|
+
harness list
|
|
42
|
+
harness wait <handle>
|
|
43
|
+
harness send <handle> "Focus on error handling"
|
|
44
|
+
harness attach <handle> # live viewer
|
|
45
|
+
harness stop <handle>
|
|
46
|
+
harness main # talk to the agent of the main project
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
[docs/spec.md](docs/spec.md) describes all the commands, the tool, the viewer, projects, the host and its state.
|
|
50
|
+
|
|
51
|
+
## Development
|
|
52
|
+
|
|
53
|
+
```sh
|
|
54
|
+
npm ci
|
|
55
|
+
npm run check # Biome, TypeScript, Vitest
|
|
56
|
+
XDG_STATE_HOME=/tmp/harness-dev node src/cli.ts list
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
`node src/cli.ts` runs the CLI of this checkout. Give it its own `XDG_STATE_HOME`, so its hosts do not share sockets and
|
|
60
|
+
stores with the hosts of the installed `harness`.
|
|
61
|
+
|
|
62
|
+
The checkout runs from its TypeScript source. `npm publish` builds `dist/` first, and the package contains only
|
|
63
|
+
`dist/`. The Pi plugin is the package in `pi-subagent/`, published from that directory with `npm publish`.
|
package/dist/answer.d.ts
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Schema answers: the `respond` tool, its `answer_schema` section and its reminders, in the `harness-answer`
|
|
3
|
+
* extension. Its caller passes its pi-durable module, so a process never holds two copies.
|
|
4
|
+
*/
|
|
5
|
+
import { type JsonValue } from "@earendil-works/chord";
|
|
6
|
+
import type * as Durable from "@earendil-works/pi-durable";
|
|
7
|
+
import type { ConversationId, Extension, JsonObject, Tx } from "@earendil-works/pi-durable";
|
|
8
|
+
/**
|
|
9
|
+
* The user message that asks again for a `respond` call after an answer of a `schema` ask without one. A reminder is
|
|
10
|
+
* known by its exact text, so a user prompt with the same text counts as one.
|
|
11
|
+
*/
|
|
12
|
+
export declare const REMINDER = "Answer with the respond tool, as the schema in answer_schema says.";
|
|
13
|
+
export type Answers = {
|
|
14
|
+
/**
|
|
15
|
+
* The extension with `respond`; the caller installs it and selects it for each conversation that can ask with a
|
|
16
|
+
* schema.
|
|
17
|
+
*/
|
|
18
|
+
readonly extension: Extension;
|
|
19
|
+
/**
|
|
20
|
+
* Makes `requestId` the current ask of `id`, with `schema` or without one, and offers `respond` to the model only
|
|
21
|
+
* with a schema. Keeps the saved answers and the other entries of the stored `modelTools`.
|
|
22
|
+
*/
|
|
23
|
+
prepare(tx: Tx, id: ConversationId, requestId: string, schema?: JsonObject): Promise<void>;
|
|
24
|
+
/** The answer that `respond` saved in the turn `requestId` of `id`, if any. */
|
|
25
|
+
valueIn(tx: Tx, id: ConversationId, requestId: string): Promise<JsonValue | undefined>;
|
|
26
|
+
};
|
|
27
|
+
export declare function createAnswers(durable: typeof Durable): Answers;
|
|
28
|
+
/** True when an answer with `stopReason` reached its end; any other answer is cut. */
|
|
29
|
+
export declare function complete(stopReason: string): boolean;
|
package/dist/answer.js
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Schema answers: the `respond` tool, its `answer_schema` section and its reminders, in the `harness-answer`
|
|
3
|
+
* extension. Its caller passes its pi-durable module, so a process never holds two copies.
|
|
4
|
+
*/
|
|
5
|
+
import { copyJson } from "@earendil-works/chord";
|
|
6
|
+
import { Type, validateToolArguments } from "@earendil-works/pi-ai";
|
|
7
|
+
/**
|
|
8
|
+
* The user message that asks again for a `respond` call after an answer of a `schema` ask without one. A reminder is
|
|
9
|
+
* known by its exact text, so a user prompt with the same text counts as one.
|
|
10
|
+
*/
|
|
11
|
+
export const REMINDER = "Answer with the respond tool, as the schema in answer_schema says.";
|
|
12
|
+
/** How many reminders one turn submission gets before its ask fails. */
|
|
13
|
+
const MAX_REMINDERS = 2;
|
|
14
|
+
export function createAnswers(durable) {
|
|
15
|
+
const AnswerDoc = durable.defineDoc({
|
|
16
|
+
kind: "harness.answer-schema",
|
|
17
|
+
version: 1,
|
|
18
|
+
scope: "conversation",
|
|
19
|
+
history: "latest",
|
|
20
|
+
fork: "current",
|
|
21
|
+
initial: () => ({ answers: {} }),
|
|
22
|
+
});
|
|
23
|
+
/** Saves a schema ask's answer, checked against the schema, and ends the turn. Only a schema ask offers it. */
|
|
24
|
+
const respond = durable.defineTool({
|
|
25
|
+
name: "respond",
|
|
26
|
+
description: "Give the answer of this turn in the structured form the prompt asks for. This ends the turn.",
|
|
27
|
+
parameters: Type.Object({
|
|
28
|
+
answer: Type.Unknown({
|
|
29
|
+
description: "The answer value itself, in the form the prompt asks for. Do not give its JSON text as a string.",
|
|
30
|
+
}),
|
|
31
|
+
}),
|
|
32
|
+
callers: ["model"],
|
|
33
|
+
deferred: true,
|
|
34
|
+
execute: async (args, api, context) => {
|
|
35
|
+
const doc = await api.snapshot(AnswerDoc, api.conversationId, context);
|
|
36
|
+
const requestId = doc?.requestId;
|
|
37
|
+
if (doc?.schema === undefined || requestId === undefined) {
|
|
38
|
+
return { isError: true, output: [{ type: "text", text: "This turn asks for no structured answer." }] };
|
|
39
|
+
}
|
|
40
|
+
let answer;
|
|
41
|
+
try {
|
|
42
|
+
answer = checkedAnswer(doc.schema, args, api.callId);
|
|
43
|
+
}
|
|
44
|
+
catch (error) {
|
|
45
|
+
return {
|
|
46
|
+
isError: true,
|
|
47
|
+
output: [{ type: "text", text: error instanceof Error ? error.message : String(error) }],
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
await api.commit(async (tx) => {
|
|
51
|
+
(await tx.doc(AnswerDoc, api.conversationId)).answers[requestId] = answer;
|
|
52
|
+
return undefined;
|
|
53
|
+
}, context);
|
|
54
|
+
return { output: [{ type: "text", text: "Answer recorded." }], control: { terminate: true } };
|
|
55
|
+
},
|
|
56
|
+
});
|
|
57
|
+
/**
|
|
58
|
+
* Saves the number of reminders at the end of the request as the memo `reminders` of the generation, for a schema
|
|
59
|
+
* ask only. A continuation starts a new generation, so each generation counts the reminders of its turn again from
|
|
60
|
+
* its messages. A compaction in the turn can summarize reminders away and reset the count. Each reset needs a
|
|
61
|
+
* compaction, so the turn still ends.
|
|
62
|
+
*/
|
|
63
|
+
const countReminders = async (request, api, context) => {
|
|
64
|
+
const doc = await api.snapshot(AnswerDoc, api.conversationId, context);
|
|
65
|
+
if (doc?.schema !== undefined)
|
|
66
|
+
await api.memo("reminders", remindersAtEnd(request.messages), context);
|
|
67
|
+
return undefined;
|
|
68
|
+
};
|
|
69
|
+
/** A reminder to call `respond`, when a schema ask got a complete answer without one and has reminders left. */
|
|
70
|
+
const remind = async (answer, api, context) => {
|
|
71
|
+
const doc = await api.snapshot(AnswerDoc, api.conversationId, context);
|
|
72
|
+
if (doc?.schema === undefined || doc.requestId === undefined)
|
|
73
|
+
return undefined;
|
|
74
|
+
if (Object.hasOwn(doc.answers, doc.requestId) || !complete(answer.stopReason))
|
|
75
|
+
return undefined;
|
|
76
|
+
const count = (await api.memo("reminders", context)) ?? 0;
|
|
77
|
+
return count < MAX_REMINDERS ? { continue: REMINDER } : undefined;
|
|
78
|
+
};
|
|
79
|
+
const extension = durable.defineExtension({
|
|
80
|
+
name: "harness-answer",
|
|
81
|
+
tools: [respond],
|
|
82
|
+
hooks: [durable.hook(durable.GenerationTask, { beforeRequest: countReminders, onYield: remind })],
|
|
83
|
+
sections: [
|
|
84
|
+
durable.section("answer_schema", async ({ read, conversationId }, context) => {
|
|
85
|
+
const schema = (await read.snapshot(AnswerDoc, conversationId, context))?.schema;
|
|
86
|
+
return schema && `The answer of respond must match this JSON Schema:\n${JSON.stringify(schema)}`;
|
|
87
|
+
}),
|
|
88
|
+
],
|
|
89
|
+
});
|
|
90
|
+
return {
|
|
91
|
+
extension,
|
|
92
|
+
async prepare(tx, id, requestId, schema) {
|
|
93
|
+
const doc = await tx.doc(AnswerDoc, id);
|
|
94
|
+
if (schema === undefined)
|
|
95
|
+
delete doc.schema;
|
|
96
|
+
else
|
|
97
|
+
doc.schema = schema;
|
|
98
|
+
doc.requestId = requestId;
|
|
99
|
+
const agent = await tx.doc(durable.AgentDoc, id);
|
|
100
|
+
const name = respond.name;
|
|
101
|
+
const tools = agent.modelTools;
|
|
102
|
+
if (schema !== undefined) {
|
|
103
|
+
if (!Array.isArray(tools)) {
|
|
104
|
+
const remove = tools?.remove?.filter((tool) => tool !== name);
|
|
105
|
+
const add = (tools?.add ?? []).filter((tool) => tool !== name);
|
|
106
|
+
agent.modelTools = { ...(remove === undefined ? {} : { remove }), add: [...add, name] };
|
|
107
|
+
}
|
|
108
|
+
else if (!tools.includes(name))
|
|
109
|
+
tools.push(name);
|
|
110
|
+
}
|
|
111
|
+
else if (Array.isArray(tools)) {
|
|
112
|
+
if (tools.includes(name))
|
|
113
|
+
agent.modelTools = tools.filter((tool) => tool !== name);
|
|
114
|
+
}
|
|
115
|
+
else if (tools?.add?.includes(name)) {
|
|
116
|
+
agent.modelTools = { ...tools, add: tools.add.filter((tool) => tool !== name) };
|
|
117
|
+
}
|
|
118
|
+
},
|
|
119
|
+
async valueIn(tx, id, requestId) {
|
|
120
|
+
const { answers } = await tx.doc(AnswerDoc, id);
|
|
121
|
+
// A copy, because the draft is not readable after the commit.
|
|
122
|
+
return Object.hasOwn(answers, requestId) ? copyJson(answers[requestId]) : undefined;
|
|
123
|
+
},
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
/** True when an answer with `stopReason` reached its end; any other answer is cut. */
|
|
127
|
+
export function complete(stopReason) {
|
|
128
|
+
return stopReason === "stop" || stopReason === "toolUse";
|
|
129
|
+
}
|
|
130
|
+
/** The number of reminders at the end of `messages`, counting back to the first other user message. */
|
|
131
|
+
function remindersAtEnd(messages) {
|
|
132
|
+
let count = 0;
|
|
133
|
+
for (const message of messages.toReversed()) {
|
|
134
|
+
if (message.role !== "user")
|
|
135
|
+
continue;
|
|
136
|
+
const { content } = message;
|
|
137
|
+
const text = typeof content === "string"
|
|
138
|
+
? content
|
|
139
|
+
: content.flatMap((part) => (part.type === "text" ? [part.text] : [])).join("");
|
|
140
|
+
if (text !== REMINDER)
|
|
141
|
+
break;
|
|
142
|
+
count++;
|
|
143
|
+
}
|
|
144
|
+
return count;
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* The answer of `args` checked against `schema`, as `validateToolArguments` returns it. A string answer that does not
|
|
148
|
+
* match is parsed as JSON, and an object or array is checked again, because a model can send it as its JSON text. Any
|
|
149
|
+
* other string throws the first error.
|
|
150
|
+
*/
|
|
151
|
+
function checkedAnswer(schema, args, id) {
|
|
152
|
+
const parameters = { type: "object", properties: { answer: schema }, required: ["answer"] };
|
|
153
|
+
const check = (answer) => validateToolArguments({ name: "respond", description: "", parameters }, { type: "toolCall", id, name: "respond", arguments: { answer } }).answer;
|
|
154
|
+
try {
|
|
155
|
+
return check(args.answer);
|
|
156
|
+
}
|
|
157
|
+
catch (error) {
|
|
158
|
+
if (typeof args.answer !== "string")
|
|
159
|
+
throw error;
|
|
160
|
+
let parsed;
|
|
161
|
+
try {
|
|
162
|
+
parsed = JSON.parse(args.answer);
|
|
163
|
+
}
|
|
164
|
+
catch {
|
|
165
|
+
throw error;
|
|
166
|
+
}
|
|
167
|
+
if (typeof parsed !== "object" || parsed === null)
|
|
168
|
+
throw error;
|
|
169
|
+
return check(parsed);
|
|
170
|
+
}
|
|
171
|
+
}
|
package/dist/chat.d.ts
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A durable conversation drawn with Pi's own message and tool components.
|
|
3
|
+
*
|
|
4
|
+
* Adapted from `ChatView` in mavam/pi-agents, src/ui/attach.ts. MIT License, Copyright (c) 2026 Matthias Vallentin.
|
|
5
|
+
*/
|
|
6
|
+
import type { ToolResultMessage } from "@earendil-works/pi-ai";
|
|
7
|
+
import type { ConversationView } from "@earendil-works/pi-durable";
|
|
8
|
+
import { Container, type TUI } from "@earendil-works/pi-tui";
|
|
9
|
+
/**
|
|
10
|
+
* A tool result as Pi shows its own. pi-durable adds the diagnostics of a call for the model as a `<harness>` block, such
|
|
11
|
+
* as `Command exited with code 3`, and the card shows them as plain lines.
|
|
12
|
+
*/
|
|
13
|
+
export declare function displayedResult(result: ToolResultMessage): ToolResultMessage;
|
|
14
|
+
/** The transcript of one conversation view, updated in place as the view changes. */
|
|
15
|
+
export declare class ChatView {
|
|
16
|
+
readonly transcript: Container;
|
|
17
|
+
/** The newest card for each call ID: providers can use a call ID again in a later turn. */
|
|
18
|
+
private readonly tools;
|
|
19
|
+
/** Every card shown, also the older ones whose call ID a later turn used again. */
|
|
20
|
+
private readonly cards;
|
|
21
|
+
/** The cards that have no final result yet. */
|
|
22
|
+
private readonly running;
|
|
23
|
+
/** The call IDs whose cards the streamed answer made; its entry takes them over. */
|
|
24
|
+
private readonly streamingCalls;
|
|
25
|
+
private readonly renderers;
|
|
26
|
+
private renderedEntryIds;
|
|
27
|
+
private streaming;
|
|
28
|
+
/** Ctrl+O shows tool output in full, as in Pi. */
|
|
29
|
+
private expanded;
|
|
30
|
+
private readonly ui;
|
|
31
|
+
private readonly cwd;
|
|
32
|
+
constructor(ui: TUI, cwd: string);
|
|
33
|
+
apply(view: ConversationView): void;
|
|
34
|
+
/** Shows tool output in full, or collapses it again. */
|
|
35
|
+
toggleExpanded(): void;
|
|
36
|
+
/** Finishes the running cards: a running bash card keeps a timer until it gets a final result. */
|
|
37
|
+
stopTimers(): void;
|
|
38
|
+
private syncTranscript;
|
|
39
|
+
private rebuild;
|
|
40
|
+
private addEntry;
|
|
41
|
+
private syncStreaming;
|
|
42
|
+
private renderer;
|
|
43
|
+
/** The newest card of a call, or a new card when the call has none. */
|
|
44
|
+
private card;
|
|
45
|
+
/** A new card of a call, also for a call ID that an earlier turn used. */
|
|
46
|
+
private newCard;
|
|
47
|
+
private addText;
|
|
48
|
+
}
|
package/dist/chat.js
ADDED
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
import { AssistantMessageComponent, createBashToolDefinition, createEditToolDefinition, createFindToolDefinition, createGrepToolDefinition, createLsToolDefinition, createReadToolDefinition, createWriteToolDefinition, getMarkdownTheme, ToolExecutionComponent, UserMessageComponent, } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { Container, Spacer, Text } from "@earendil-works/pi-tui";
|
|
3
|
+
import { color } from "./theme.js";
|
|
4
|
+
import { liveOf, userText } from "./view-state.js";
|
|
5
|
+
/** Pi's renderers of the tools that children have under the same names. */
|
|
6
|
+
const RENDERERS = {
|
|
7
|
+
read: createReadToolDefinition,
|
|
8
|
+
bash: createBashToolDefinition,
|
|
9
|
+
edit: createEditToolDefinition,
|
|
10
|
+
write: createWriteToolDefinition,
|
|
11
|
+
grep: createGrepToolDefinition,
|
|
12
|
+
find: createFindToolDefinition,
|
|
13
|
+
ls: createLsToolDefinition,
|
|
14
|
+
};
|
|
15
|
+
const DIAGNOSTICS = /^<harness>\n([\s\S]*)\n<\/harness>$/;
|
|
16
|
+
/**
|
|
17
|
+
* A tool result as Pi shows its own. pi-durable adds the diagnostics of a call for the model as a `<harness>` block, such
|
|
18
|
+
* as `Command exited with code 3`, and the card shows them as plain lines.
|
|
19
|
+
*/
|
|
20
|
+
export function displayedResult(result) {
|
|
21
|
+
let changed = false;
|
|
22
|
+
const content = result.content.map((block) => {
|
|
23
|
+
const match = block.type === "text" ? DIAGNOSTICS.exec(block.text) : null;
|
|
24
|
+
if (!match)
|
|
25
|
+
return block;
|
|
26
|
+
changed = true;
|
|
27
|
+
const lines = (match[1] ?? "").split("\n").map((line) => line.replace(/^\[(error|warning|info)\] /, ""));
|
|
28
|
+
return { type: "text", text: `\n${lines.join("\n")}` };
|
|
29
|
+
});
|
|
30
|
+
return changed ? { ...result, content } : result;
|
|
31
|
+
}
|
|
32
|
+
/** The transcript of one conversation view, updated in place as the view changes. */
|
|
33
|
+
export class ChatView {
|
|
34
|
+
transcript = new Container();
|
|
35
|
+
/** The newest card for each call ID: providers can use a call ID again in a later turn. */
|
|
36
|
+
tools = new Map();
|
|
37
|
+
/** Every card shown, also the older ones whose call ID a later turn used again. */
|
|
38
|
+
cards = [];
|
|
39
|
+
/** The cards that have no final result yet. */
|
|
40
|
+
running = new Set();
|
|
41
|
+
/** The call IDs whose cards the streamed answer made; its entry takes them over. */
|
|
42
|
+
streamingCalls = new Set();
|
|
43
|
+
renderers = new Map();
|
|
44
|
+
renderedEntryIds = [];
|
|
45
|
+
streaming;
|
|
46
|
+
/** Ctrl+O shows tool output in full, as in Pi. */
|
|
47
|
+
expanded = false;
|
|
48
|
+
ui;
|
|
49
|
+
cwd;
|
|
50
|
+
constructor(ui, cwd) {
|
|
51
|
+
this.ui = ui;
|
|
52
|
+
this.cwd = cwd;
|
|
53
|
+
}
|
|
54
|
+
apply(view) {
|
|
55
|
+
const live = liveOf(view);
|
|
56
|
+
this.syncTranscript(view.entries);
|
|
57
|
+
const message = live.generation?.message;
|
|
58
|
+
// A streamed answer without its entry was dropped, for example by a retry.
|
|
59
|
+
if (message === undefined && this.streaming !== undefined)
|
|
60
|
+
this.rebuild(view.entries);
|
|
61
|
+
if (message !== undefined)
|
|
62
|
+
this.syncStreaming(message);
|
|
63
|
+
for (const slot of live.tools ?? []) {
|
|
64
|
+
if (slot.status === "pending")
|
|
65
|
+
continue;
|
|
66
|
+
const component = this.card(slot.name, slot.callId);
|
|
67
|
+
component.setArgsComplete();
|
|
68
|
+
if (slot.status !== "running")
|
|
69
|
+
continue;
|
|
70
|
+
component.markExecutionStarted();
|
|
71
|
+
if (slot.output !== undefined || slot.details !== undefined) {
|
|
72
|
+
component.updateResult({ content: [{ type: "text", text: slot.output ?? "" }], details: slot.details, isError: false }, true);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
this.transcript.invalidate();
|
|
76
|
+
}
|
|
77
|
+
/** Shows tool output in full, or collapses it again. */
|
|
78
|
+
toggleExpanded() {
|
|
79
|
+
this.expanded = !this.expanded;
|
|
80
|
+
for (const card of this.cards)
|
|
81
|
+
card.setExpanded(this.expanded);
|
|
82
|
+
this.transcript.invalidate();
|
|
83
|
+
}
|
|
84
|
+
/** Finishes the running cards: a running bash card keeps a timer until it gets a final result. */
|
|
85
|
+
stopTimers() {
|
|
86
|
+
for (const component of this.running)
|
|
87
|
+
component.updateResult({ content: [], isError: false }, false);
|
|
88
|
+
this.running.clear();
|
|
89
|
+
}
|
|
90
|
+
syncTranscript(entries) {
|
|
91
|
+
// A compaction or a reset replaces the head of the active transcript.
|
|
92
|
+
if (this.renderedEntryIds.some((id, index) => entries[index]?.id !== id))
|
|
93
|
+
this.rebuild(entries);
|
|
94
|
+
for (const entry of entries.slice(this.renderedEntryIds.length)) {
|
|
95
|
+
this.addEntry(entry);
|
|
96
|
+
this.renderedEntryIds.push(entry.id);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
rebuild(entries) {
|
|
100
|
+
this.stopTimers();
|
|
101
|
+
this.transcript.clear();
|
|
102
|
+
this.cards.length = 0;
|
|
103
|
+
this.tools.clear();
|
|
104
|
+
this.streamingCalls.clear();
|
|
105
|
+
this.renderedEntryIds = [];
|
|
106
|
+
this.streaming = undefined;
|
|
107
|
+
for (const entry of entries) {
|
|
108
|
+
this.addEntry(entry);
|
|
109
|
+
this.renderedEntryIds.push(entry.id);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
addEntry(entry) {
|
|
113
|
+
const message = entry.model?.[0];
|
|
114
|
+
if (entry.kind === "pi.user" && message?.role === "user") {
|
|
115
|
+
this.transcript.addChild(new Spacer(1));
|
|
116
|
+
this.transcript.addChild(new UserMessageComponent(userText(message.content), getMarkdownTheme()));
|
|
117
|
+
}
|
|
118
|
+
else if (entry.kind === "pi.assistant" && message?.role === "assistant") {
|
|
119
|
+
const component = this.streaming ?? new AssistantMessageComponent(undefined, false, getMarkdownTheme());
|
|
120
|
+
if (this.streaming === undefined)
|
|
121
|
+
this.transcript.addChild(component);
|
|
122
|
+
this.streaming = undefined;
|
|
123
|
+
component.updateContent(message, false);
|
|
124
|
+
// Only an answer that asks for tools runs its calls; an aborted, failed or cut answer never does.
|
|
125
|
+
const ran = message.stopReason === "toolUse";
|
|
126
|
+
for (const content of message.content) {
|
|
127
|
+
if (content.type !== "toolCall")
|
|
128
|
+
continue;
|
|
129
|
+
const streamed = this.streamingCalls.has(content.id);
|
|
130
|
+
if (!ran && !streamed)
|
|
131
|
+
continue;
|
|
132
|
+
const card = streamed
|
|
133
|
+
? this.card(content.name, content.id, content.arguments)
|
|
134
|
+
: this.newCard(content.name, content.id, content.arguments);
|
|
135
|
+
card.setArgsComplete();
|
|
136
|
+
if (!ran) {
|
|
137
|
+
const text = "Not run: the answer was interrupted.";
|
|
138
|
+
card.updateResult({ content: [{ type: "text", text }], isError: true }, false);
|
|
139
|
+
this.running.delete(card);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
this.streamingCalls.clear();
|
|
143
|
+
}
|
|
144
|
+
else if (entry.kind === "pi.tool-result" && message?.role === "toolResult") {
|
|
145
|
+
const card = this.card(message.toolName, message.toolCallId);
|
|
146
|
+
card.updateResult(displayedResult(message));
|
|
147
|
+
this.running.delete(card);
|
|
148
|
+
}
|
|
149
|
+
else if (entry.kind === "pi.compaction") {
|
|
150
|
+
this.addText(color("muted", "[compaction]"));
|
|
151
|
+
if (message?.role === "user")
|
|
152
|
+
this.addText(userText(message.content));
|
|
153
|
+
}
|
|
154
|
+
else if (entry.kind === "pi.reset") {
|
|
155
|
+
this.addText(color("muted", "[new context]"));
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
syncStreaming(message) {
|
|
159
|
+
if (this.streaming === undefined) {
|
|
160
|
+
this.streaming = new AssistantMessageComponent(undefined, false, getMarkdownTheme());
|
|
161
|
+
this.transcript.addChild(this.streaming);
|
|
162
|
+
}
|
|
163
|
+
this.streaming.updateContent(message, true);
|
|
164
|
+
for (const content of message.content) {
|
|
165
|
+
if (content.type !== "toolCall")
|
|
166
|
+
continue;
|
|
167
|
+
if (this.streamingCalls.has(content.id))
|
|
168
|
+
this.card(content.name, content.id, content.arguments);
|
|
169
|
+
else
|
|
170
|
+
this.newCard(content.name, content.id, content.arguments);
|
|
171
|
+
this.streamingCalls.add(content.id);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
renderer(toolName) {
|
|
175
|
+
let definition = this.renderers.get(toolName);
|
|
176
|
+
if (definition === undefined) {
|
|
177
|
+
const create = RENDERERS[toolName];
|
|
178
|
+
if (create === undefined)
|
|
179
|
+
return undefined;
|
|
180
|
+
definition = create(this.cwd);
|
|
181
|
+
this.renderers.set(toolName, definition);
|
|
182
|
+
}
|
|
183
|
+
return definition;
|
|
184
|
+
}
|
|
185
|
+
/** The newest card of a call, or a new card when the call has none. */
|
|
186
|
+
card(toolName, toolCallId, args) {
|
|
187
|
+
const existing = this.tools.get(toolCallId);
|
|
188
|
+
if (existing === undefined)
|
|
189
|
+
return this.newCard(toolName, toolCallId, args ?? {});
|
|
190
|
+
if (args !== undefined)
|
|
191
|
+
existing.updateArgs(args);
|
|
192
|
+
return existing;
|
|
193
|
+
}
|
|
194
|
+
/** A new card of a call, also for a call ID that an earlier turn used. */
|
|
195
|
+
newCard(toolName, toolCallId, args) {
|
|
196
|
+
const component = new ToolExecutionComponent(toolName, toolCallId, args, {}, this.renderer(toolName), this.ui, this.cwd);
|
|
197
|
+
component.setExpanded(this.expanded);
|
|
198
|
+
this.transcript.addChild(component);
|
|
199
|
+
this.cards.push(component);
|
|
200
|
+
this.running.add(component);
|
|
201
|
+
this.tools.set(toolCallId, component);
|
|
202
|
+
return component;
|
|
203
|
+
}
|
|
204
|
+
addText(text) {
|
|
205
|
+
this.transcript.addChild(new Spacer(1));
|
|
206
|
+
this.transcript.addChild(new Text(text, 1, 0));
|
|
207
|
+
}
|
|
208
|
+
}
|