@softov/ahpc 0.2.0 → 0.4.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 +37 -6
- package/dist/src/ahp/fake.js +61 -14
- package/dist/src/ahp/live.d.ts +7 -0
- package/dist/src/ahp/live.js +156 -43
- package/dist/src/ahp/publish.js +13 -0
- package/dist/src/ahp/types.d.ts +20 -0
- package/dist/src/app.js +34 -7
- package/dist/src/blocks.d.ts +4 -74
- package/dist/src/blocks.js +10 -48
- package/dist/src/cli/main.d.ts +1 -1
- package/dist/src/cli/main.js +109 -6
- package/dist/src/connect.d.ts +2 -0
- package/dist/src/connect.js +1 -0
- package/dist/src/control.d.ts +6 -0
- package/dist/src/control.js +140 -10
- package/dist/src/flags.js +4 -1
- package/dist/src/links.d.ts +54 -0
- package/dist/src/links.js +120 -0
- package/dist/src/main.js +14 -2
- package/dist/src/mcp/http.d.ts +10 -0
- package/dist/src/mcp/http.js +110 -3
- package/dist/src/mcp/serve.d.ts +47 -9
- package/dist/src/mcp/serve.js +65 -14
- package/dist/src/mcp/stdio.d.ts +1 -0
- package/dist/src/mcp/stdio.js +7 -1
- package/dist/src/mcp/tools.d.ts +37 -2
- package/dist/src/mcp/tools.js +439 -2
- package/dist/src/resources.d.ts +13 -0
- package/dist/src/resources.js +46 -0
- package/dist/src/screens.js +95 -50
- package/dist/src/state.d.ts +42 -0
- package/dist/src/state.js +80 -1
- package/dist/src/tui.d.ts +3 -1
- package/dist/src/tui.js +29 -4
- package/dist/src/version.d.ts +2 -0
- package/dist/src/version.js +38 -0
- package/dist/src/view/creature.d.ts +0 -12
- package/dist/src/view/creature.js +0 -20
- package/dist/src/view/wire.d.ts +36 -0
- package/dist/src/view/wire.js +196 -0
- package/dist/src/wait.d.ts +11 -0
- package/dist/src/wait.js +15 -2
- package/dist/src/wire.d.ts +70 -0
- package/dist/src/wire.js +194 -0
- package/dist/src/wiretui.d.ts +22 -0
- package/dist/src/wiretui.js +69 -0
- package/package.json +6 -5
- package/dist/src/diff.d.ts +0 -44
- package/dist/src/diff.js +0 -111
- package/dist/src/view/bubble.d.ts +0 -75
- package/dist/src/view/bubble.js +0 -86
- package/dist/src/view/composer.d.ts +0 -64
- package/dist/src/view/composer.js +0 -192
- package/dist/src/view/controls.d.ts +0 -44
- package/dist/src/view/controls.js +0 -49
- package/dist/src/view/details.d.ts +0 -65
- package/dist/src/view/details.js +0 -65
- package/dist/src/view/filediff.d.ts +0 -29
- package/dist/src/view/filediff.js +0 -24
- package/dist/src/view/hitl.d.ts +0 -43
- package/dist/src/view/hitl.js +0 -171
- package/dist/src/view/icons.d.ts +0 -13
- package/dist/src/view/icons.js +0 -71
- package/dist/src/view/picker.d.ts +0 -42
- package/dist/src/view/picker.js +0 -71
- package/dist/src/view/sessionhead.d.ts +0 -41
- package/dist/src/view/sessionhead.js +0 -60
- package/dist/src/view/sessions.d.ts +0 -34
- package/dist/src/view/sessions.js +0 -61
- package/dist/src/view/toolcall.d.ts +0 -27
- package/dist/src/view/toolcall.js +0 -48
- package/dist/src/view/transcript.d.ts +0 -50
- package/dist/src/view/transcript.js +0 -60
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { BindingPath, BoxProps, Disposable, RenderOutput, TextUIApp } from '@textui/core';
|
|
2
|
+
import type { WireRow } from '../wire.js';
|
|
3
|
+
export declare const WIRE_ROWS: BindingPath;
|
|
4
|
+
export declare const WIRE_FILTER: BindingPath;
|
|
5
|
+
export declare const WIRE_SELECTED: BindingPath;
|
|
6
|
+
export declare const WIRE_FOLLOW: BindingPath;
|
|
7
|
+
export declare const WIRE_FILE: BindingPath;
|
|
8
|
+
export declare const WIRE_FRAME: BindingPath;
|
|
9
|
+
export declare const WIRE_SCOPE = "wire.rows";
|
|
10
|
+
/** The rows the filter keeps, from what the store holds. */
|
|
11
|
+
export declare const visibleRows: (rows: WireRow[], filter: string) => WireRow[];
|
|
12
|
+
export interface WireListProps extends BoxProps {
|
|
13
|
+
rows: WireRow[];
|
|
14
|
+
selectedId?: string | null;
|
|
15
|
+
onSelect?(id: string): void;
|
|
16
|
+
onOpen?(id: string): void;
|
|
17
|
+
emptyMessage?: string;
|
|
18
|
+
focusId?: string;
|
|
19
|
+
autoFocus?: boolean;
|
|
20
|
+
}
|
|
21
|
+
export declare const WireList: (props: WireListProps) => RenderOutput;
|
|
22
|
+
export declare const WireScreen: (props: Record<string, never>) => RenderOutput;
|
|
23
|
+
export interface WireOptions {
|
|
24
|
+
/** The capture to read. */
|
|
25
|
+
file: string;
|
|
26
|
+
/** How often to look for more of it, in milliseconds. */
|
|
27
|
+
interval?: number;
|
|
28
|
+
builtins?: boolean;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* The wire screen on an application, reading one file.
|
|
32
|
+
*
|
|
33
|
+
* The same shape as `registerChat`, and nothing shared with it: no host, no
|
|
34
|
+
* controller, no sessions. What comes back takes the reader down with it.
|
|
35
|
+
*/
|
|
36
|
+
export declare function registerWire(app: TextUIApp, options: WireOptions): Disposable;
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "@textui/core/jsx-runtime";
|
|
2
|
+
import { createBag, defineComponent, useApp, useEffect, useFocusScope, useSize, useStoreValue, useTheme } from '@textui/core';
|
|
3
|
+
import { Column, KeyHints, List, Panel, Row, ScrollView, SearchBox, registerBuiltins } from '@textui/widgets';
|
|
4
|
+
import { follow, matches } from '../wire.js';
|
|
5
|
+
/*
|
|
6
|
+
* The wire, as a screen.
|
|
7
|
+
*
|
|
8
|
+
* One row per frame and nothing else on it: the time, which way it went, the
|
|
9
|
+
* method or the action type, the channel. A row opens to the frame itself.
|
|
10
|
+
* The file is read as it grows, so a capture `ahpd --wire` or this client is
|
|
11
|
+
* still writing is watched live - which is the point: every question about
|
|
12
|
+
* the wire had been answered by reading source, and this answers it by
|
|
13
|
+
* looking.
|
|
14
|
+
*
|
|
15
|
+
* Its own screen rather than one of the conversation's, because it needs no
|
|
16
|
+
* host. A capture is a file, and the file may be the other end's.
|
|
17
|
+
*/
|
|
18
|
+
export const WIRE_ROWS = '$/wire/rows';
|
|
19
|
+
export const WIRE_FILTER = '$/wire/filter';
|
|
20
|
+
export const WIRE_SELECTED = '$/wire/selected';
|
|
21
|
+
export const WIRE_FOLLOW = '$/wire/follow';
|
|
22
|
+
export const WIRE_FILE = '$/wire/file';
|
|
23
|
+
export const WIRE_FRAME = '$/wire/frame';
|
|
24
|
+
export const WIRE_SCOPE = 'wire.rows';
|
|
25
|
+
/** The most frames held. A capture of a long session is more than a screen can list, and the newest are what is being watched. */
|
|
26
|
+
const KEPT = 20_000;
|
|
27
|
+
/** Below this width the frame is a drawer over the list rather than a pane beside it. */
|
|
28
|
+
const SPLIT = 100;
|
|
29
|
+
const TONE = {
|
|
30
|
+
request: 'default',
|
|
31
|
+
notification: 'default',
|
|
32
|
+
response: 'muted',
|
|
33
|
+
error: 'danger',
|
|
34
|
+
text: 'warning',
|
|
35
|
+
};
|
|
36
|
+
/** The rows the filter keeps, from what the store holds. */
|
|
37
|
+
export const visibleRows = (rows, filter) => (filter.trim() === '' ? rows : rows.filter((row) => matches(row, filter)));
|
|
38
|
+
/** The frame, as lines. Capped: a snapshot can run to thousands, and the top of it is what says what it is. */
|
|
39
|
+
const linesOf = (frame) => {
|
|
40
|
+
const text = typeof frame === 'string' ? frame : JSON.stringify(frame, null, 2) ?? '';
|
|
41
|
+
const lines = text.split('\n');
|
|
42
|
+
return lines.length > 3000 ? [...lines.slice(0, 3000), `… ${lines.length - 3000} more lines`] : lines;
|
|
43
|
+
};
|
|
44
|
+
export const WireList = defineComponent('WireList', (props) => {
|
|
45
|
+
const { rows, selectedId, onSelect, onOpen, emptyMessage, focusId, autoFocus, ...rest } = props;
|
|
46
|
+
const theme = useTheme();
|
|
47
|
+
const byId = new Map(rows.map((row) => [String(row.seq), row]));
|
|
48
|
+
const items = rows.map((row) => ({ id: String(row.seq), label: row.method || row.kind, tone: TONE[row.kind] }));
|
|
49
|
+
return (_jsx(List, { items: items, ...(selectedId ? { selectedId } : {}), ...(focusId ? { focusId } : {}), ...(autoFocus ? { autoFocus } : {}), ...(onSelect ? { onSelect: (id) => onSelect(id) } : {}), ...(onOpen ? { onActivate: (id) => onOpen(id) } : {}), emptyMessage: emptyMessage ?? 'Nothing on the wire yet', renderItem: (item, state) => {
|
|
50
|
+
const row = byId.get(item.id);
|
|
51
|
+
if (row === undefined)
|
|
52
|
+
return _jsx("text", { content: item.label });
|
|
53
|
+
const time = row.at.length >= 23 ? row.at.slice(11, 23) : row.at;
|
|
54
|
+
// Which way it went, drawn as an arrow between the two ends: the
|
|
55
|
+
// client is on the left, the host on the right, on every row.
|
|
56
|
+
const arrow = row.from === 'client' ? theme.glyphs.arrowRight : theme.glyphs.arrowLeft;
|
|
57
|
+
const what = row.kind === 'response' ? `${row.method} ${theme.glyphs.check}` : row.kind === 'error' ? `${row.method} ${theme.glyphs.cross}` : row.method || row.kind;
|
|
58
|
+
const dim = state.selected ? {} : { fg: 'subtle' };
|
|
59
|
+
return (_jsxs(Row, { gap: 1, children: [_jsx("text", { content: time, shrink: 0, ...dim }), _jsx("text", { content: arrow, shrink: 0, ...(state.selected ? {} : { fg: (row.from === 'client' ? 'accent' : 'info') }) }), _jsx("text", { content: what, shrink: 0, bold: row.kind === 'request' || row.kind === 'notification', ...(state.selected ? {} : { fg: TONE[row.kind] }) }), row.type ? _jsx("text", { content: row.type, shrink: 0, ...(state.selected ? {} : { fg: 'accent' }) }) : null, row.channel ? _jsx("text", { content: row.channel, shrink: 2, truncate: "start", ...dim }) : null, _jsx("text", { content: row.detail, flex: 1, truncate: "end", ...dim })] }));
|
|
60
|
+
}, ...rest }));
|
|
61
|
+
});
|
|
62
|
+
export const WireScreen = defineComponent('WireScreen', () => {
|
|
63
|
+
const app = useApp();
|
|
64
|
+
const theme = useTheme();
|
|
65
|
+
useFocusScope({ id: WIRE_SCOPE });
|
|
66
|
+
const rows = useStoreValue(WIRE_ROWS, []) ?? [];
|
|
67
|
+
const filter = useStoreValue(WIRE_FILTER, '') ?? '';
|
|
68
|
+
const selected = useStoreValue(WIRE_SELECTED, null) ?? null;
|
|
69
|
+
const following = useStoreValue(WIRE_FOLLOW, true) ?? true;
|
|
70
|
+
const file = useStoreValue(WIRE_FILE, '') ?? '';
|
|
71
|
+
const drawer = useStoreValue(WIRE_FRAME, false) ?? false;
|
|
72
|
+
const shown = visibleRows(rows, filter);
|
|
73
|
+
const width = useSize().width;
|
|
74
|
+
const wide = width >= SPLIT;
|
|
75
|
+
const open = wide || drawer;
|
|
76
|
+
// Following: the highlight stays on the newest row the filter keeps, so
|
|
77
|
+
// a live capture reads like a tail. Stopped by moving the highlight.
|
|
78
|
+
const last = shown.at(-1);
|
|
79
|
+
useEffect(() => {
|
|
80
|
+
if (shown.length === 0)
|
|
81
|
+
return;
|
|
82
|
+
if (following) {
|
|
83
|
+
app.store.set(WIRE_SELECTED, String(last?.seq ?? ''));
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
if (selected !== null && shown.some((row) => String(row.seq) === selected))
|
|
87
|
+
return;
|
|
88
|
+
app.store.set(WIRE_SELECTED, String(last?.seq ?? ''));
|
|
89
|
+
}, [last?.seq ?? 0, following, filter]);
|
|
90
|
+
const current = shown.find((row) => String(row.seq) === selected) ?? null;
|
|
91
|
+
const aside = Math.max(40, Math.min(80, Math.round(width * 0.45)));
|
|
92
|
+
const list = (_jsxs(Panel, { title: "Wire", ...(open && wide ? { width: width - aside - 1 } : { flex: 1 }), meta: `${shown.length}${shown.length === rows.length ? '' : ` of ${rows.length}`} frames${following ? ` ${theme.glyphs.bulletFilled} following` : ''}`, children: [_jsx(SearchBox, { value: filter, placeholder: "method, action type, channel, id", focusId: "wire.filter", onChange: (value) => app.store.set(WIRE_FILTER, value) }), _jsx(WireList, { rows: shown, selectedId: selected, focusId: "wire.list", autoFocus: true, flex: 1, onSelect: (id) => {
|
|
93
|
+
if (id !== selected && id !== String(last?.seq ?? ''))
|
|
94
|
+
app.store.set(WIRE_FOLLOW, false);
|
|
95
|
+
app.store.set(WIRE_SELECTED, id);
|
|
96
|
+
}, onOpen: () => { app.store.set(WIRE_FRAME, true); app.focus.focus('wire.frame'); }, emptyMessage: rows.length === 0 ? `Nothing in ${file || 'the capture'} yet` : 'Nothing matches' }), _jsx("text", { content: file, fg: "subtle", truncate: "start" })] }));
|
|
97
|
+
const frame = current ? (_jsxs(Panel, { title: `${current.from === 'client' ? 'client' : 'host'} ${current.from === 'client' ? theme.glyphs.arrowRight : theme.glyphs.arrowLeft} ${current.method || current.kind}`, ...(wide ? { width: aside } : { flex: 1 }), meta: current.id !== undefined ? `id ${current.id}` : current.kind, children: [_jsxs(Row, { gap: 1, children: [_jsx("text", { content: current.at, fg: "subtle", shrink: 0 }), current.peer ? _jsx("text", { content: current.peer, fg: "muted", flex: 1, truncate: "start" }) : null] }), _jsx(ScrollView, { flex: 1, focusId: "wire.frame", children: _jsx(Column, { children: linesOf(current.frame).map((line, index) => (_jsx("text", { content: line, wrap: "none", truncate: "end", ...(current.kind === 'error' && index === 0 ? { fg: 'danger' } : {}) }, index))) }) })] })) : (_jsx(Panel, { title: "Frame", ...(wide ? { width: aside } : { flex: 1 }), children: _jsx("text", { content: "No frame under the highlight.", fg: "subtle" }) }));
|
|
98
|
+
if (!wide)
|
|
99
|
+
return open ? frame : list;
|
|
100
|
+
return (_jsxs(Row, { flex: 1, gap: 1, children: [list, open ? frame : null] }));
|
|
101
|
+
});
|
|
102
|
+
const WireHeader = defineComponent('WireHeader', () => {
|
|
103
|
+
const theme = useTheme();
|
|
104
|
+
const file = useStoreValue(WIRE_FILE, '') ?? '';
|
|
105
|
+
return (_jsxs(Row, { gap: 1, children: [_jsx("text", { content: "Wire", bold: true, fg: "accent", shrink: 0 }), _jsx("text", { content: theme.glyphs.separator, fg: "subtle", shrink: 0 }), _jsx("text", { content: file, fg: "muted", flex: 1, truncate: "start" })] }));
|
|
106
|
+
});
|
|
107
|
+
const WireHints = defineComponent('WireHints', (props) => {
|
|
108
|
+
const theme = useTheme();
|
|
109
|
+
const following = useStoreValue(WIRE_FOLLOW, true) ?? true;
|
|
110
|
+
return (_jsx(KeyHints, { ...props, hints: [
|
|
111
|
+
{ keys: `${theme.glyphs.arrowUp}${theme.glyphs.arrowDown}`, label: 'frame' },
|
|
112
|
+
{ keys: 'enter', label: 'open' },
|
|
113
|
+
{ keys: 'esc', label: 'back' },
|
|
114
|
+
{ keys: 'f', label: following ? 'stop following' : 'follow' },
|
|
115
|
+
{ keys: 'ctrl+f', label: 'filter' },
|
|
116
|
+
{ keys: 'ctrl+c', label: 'quit' },
|
|
117
|
+
] }));
|
|
118
|
+
});
|
|
119
|
+
/**
|
|
120
|
+
* The wire screen on an application, reading one file.
|
|
121
|
+
*
|
|
122
|
+
* The same shape as `registerChat`, and nothing shared with it: no host, no
|
|
123
|
+
* controller, no sessions. What comes back takes the reader down with it.
|
|
124
|
+
*/
|
|
125
|
+
export function registerWire(app, options) {
|
|
126
|
+
const bag = createBag();
|
|
127
|
+
if (options.builtins !== false)
|
|
128
|
+
bag.add(registerBuiltins(app));
|
|
129
|
+
app.store.set(WIRE_FILE, options.file);
|
|
130
|
+
app.store.set(WIRE_ROWS, []);
|
|
131
|
+
app.store.set(WIRE_FOLLOW, true);
|
|
132
|
+
app.store.set(WIRE_FRAME, false);
|
|
133
|
+
for (const [component, render] of [
|
|
134
|
+
['WireList', WireList],
|
|
135
|
+
['WireScreen', WireScreen],
|
|
136
|
+
['WireHeader', WireHeader],
|
|
137
|
+
['WireHints', WireHints],
|
|
138
|
+
]) {
|
|
139
|
+
bag.add(app.components.register({ component, category: 'template', renderer: { kind: 'function', render: render } }));
|
|
140
|
+
}
|
|
141
|
+
bag.add(app.surfaces.open({ surface: 'header', key: 'title', target: { component: 'WireHeader' } }));
|
|
142
|
+
bag.add(app.surfaces.open({ surface: 'status', key: 'status', target: { component: 'WireHints' } }));
|
|
143
|
+
bag.add(app.screens.register({ id: 'wire', component: 'WireScreen' }));
|
|
144
|
+
bag.add(app.commands.register({
|
|
145
|
+
id: 'wire.follow',
|
|
146
|
+
title: 'Follow the wire',
|
|
147
|
+
category: 'Wire',
|
|
148
|
+
description: 'Keep the highlight on the newest frame',
|
|
149
|
+
slots: ['palette'],
|
|
150
|
+
keepOpen: true,
|
|
151
|
+
checked: WIRE_FOLLOW,
|
|
152
|
+
run: () => app.store.set(WIRE_FOLLOW, !(app.store.get(WIRE_FOLLOW) ?? true)),
|
|
153
|
+
}));
|
|
154
|
+
bag.add(app.commands.register({
|
|
155
|
+
id: 'wire.filter',
|
|
156
|
+
title: 'Filter the wire',
|
|
157
|
+
category: 'Wire',
|
|
158
|
+
description: 'Keep the frames whose method, action type or channel match',
|
|
159
|
+
slots: ['palette'],
|
|
160
|
+
run: () => app.focus.focus('wire.filter'),
|
|
161
|
+
}));
|
|
162
|
+
bag.add(app.commands.register({
|
|
163
|
+
id: 'wire.openFrame',
|
|
164
|
+
title: 'Open the frame',
|
|
165
|
+
category: 'Wire',
|
|
166
|
+
description: 'Read the frame under the highlight',
|
|
167
|
+
slots: ['palette'],
|
|
168
|
+
run: () => { app.store.set(WIRE_FRAME, true); app.focus.focus('wire.frame'); },
|
|
169
|
+
}));
|
|
170
|
+
bag.add(app.commands.register({
|
|
171
|
+
id: 'wire.closeFrame',
|
|
172
|
+
title: 'Back to the list',
|
|
173
|
+
category: 'Wire',
|
|
174
|
+
description: 'Put the frame away',
|
|
175
|
+
slots: ['palette'],
|
|
176
|
+
run: () => { app.store.set(WIRE_FRAME, false); app.focus.focus('wire.list'); },
|
|
177
|
+
}));
|
|
178
|
+
for (const binding of [
|
|
179
|
+
{ keys: 'f', commandId: 'wire.follow', scopeId: WIRE_SCOPE },
|
|
180
|
+
{ keys: 'ctrl+f', commandId: 'wire.filter' },
|
|
181
|
+
{ keys: 'right', commandId: 'wire.openFrame', scopeId: WIRE_SCOPE },
|
|
182
|
+
{ keys: 'left', commandId: 'wire.closeFrame', scopeId: WIRE_SCOPE },
|
|
183
|
+
{ keys: 'escape', commandId: 'wire.closeFrame', scopeId: WIRE_SCOPE },
|
|
184
|
+
])
|
|
185
|
+
bag.add(app.keybindings.register(binding));
|
|
186
|
+
// The file, folded in as it grows. Kept to the newest `KEPT`, and the
|
|
187
|
+
// sequence numbers keep counting so a row's name never changes under it.
|
|
188
|
+
const reader = follow(options.file, (rows) => {
|
|
189
|
+
const held = app.store.get(WIRE_ROWS) ?? [];
|
|
190
|
+
const next = [...held, ...rows];
|
|
191
|
+
app.store.set(WIRE_ROWS, next.length > KEPT ? next.slice(next.length - KEPT) : next);
|
|
192
|
+
}, options.interval === undefined ? {} : { interval: options.interval });
|
|
193
|
+
bag.add({ dispose: () => reader.close() });
|
|
194
|
+
app.screens.reset('wire');
|
|
195
|
+
return bag;
|
|
196
|
+
}
|
package/dist/src/wait.d.ts
CHANGED
|
@@ -24,6 +24,17 @@ export interface TurnOptions {
|
|
|
24
24
|
id: string;
|
|
25
25
|
name: string;
|
|
26
26
|
}): void;
|
|
27
|
+
/**
|
|
28
|
+
* A tool the agent has started using, said once per call.
|
|
29
|
+
*
|
|
30
|
+
* Every tool call rather than only the ones that stop for a person, because
|
|
31
|
+
* this is what a caller watching a long turn has to go on: `onWaiting` fires
|
|
32
|
+
* on an approval and most turns never ask for one.
|
|
33
|
+
*/
|
|
34
|
+
onStep?(call: {
|
|
35
|
+
id: string;
|
|
36
|
+
name: string;
|
|
37
|
+
}): void;
|
|
27
38
|
}
|
|
28
39
|
/**
|
|
29
40
|
* Say something, and block until the turn it starts has finished.
|
package/dist/src/wait.js
CHANGED
|
@@ -79,6 +79,14 @@ export async function turn(host, uri, text, options = {}) {
|
|
|
79
79
|
let first = true;
|
|
80
80
|
let noted;
|
|
81
81
|
let answer;
|
|
82
|
+
/** Tool calls already reported, so a snapshot rebuilt per token says each once. */
|
|
83
|
+
const stepped = new Set();
|
|
84
|
+
const step = (call) => {
|
|
85
|
+
if (stepped.has(call.id))
|
|
86
|
+
return;
|
|
87
|
+
stepped.add(call.id);
|
|
88
|
+
options.onStep?.(call);
|
|
89
|
+
};
|
|
82
90
|
const finished = until(host, uri, (event) => {
|
|
83
91
|
/*
|
|
84
92
|
* Two vocabularies, because a host may speak either.
|
|
@@ -98,8 +106,9 @@ export async function turn(host, uri, text, options = {}) {
|
|
|
98
106
|
options.onDelta?.(event.text);
|
|
99
107
|
return false;
|
|
100
108
|
}
|
|
101
|
-
if (event.type === 'toolCall'
|
|
102
|
-
|
|
109
|
+
if (event.type === 'toolCall') {
|
|
110
|
+
step({ id: event.call.id, name: event.call.name });
|
|
111
|
+
if (event.call.status === 'pending-confirmation' && noted !== event.call.id) {
|
|
103
112
|
noted = event.call.id;
|
|
104
113
|
options.onWaiting?.({ id: event.call.id, name: event.call.name });
|
|
105
114
|
}
|
|
@@ -124,6 +133,10 @@ export async function turn(host, uri, text, options = {}) {
|
|
|
124
133
|
options.onDelta?.(now.slice(given));
|
|
125
134
|
given = now.length;
|
|
126
135
|
}
|
|
136
|
+
for (const part of event.active.parts) {
|
|
137
|
+
if (part.kind === 'toolCall')
|
|
138
|
+
step({ id: part.call.id, name: part.call.name });
|
|
139
|
+
}
|
|
127
140
|
const call = event.active.parts.find((part) => part.kind === 'toolCall'
|
|
128
141
|
&& part.call.status === 'pending-confirmation');
|
|
129
142
|
if (call?.kind === 'toolCall' && noted !== call.call.id) {
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A wire capture, read back.
|
|
3
|
+
*
|
|
4
|
+
* `--wire <file>` on either end writes one line per frame, `{ at, from, peer,
|
|
5
|
+
* frame }`, and this is the other half: the lines as rows a screen can list,
|
|
6
|
+
* and a reader that keeps up with a file still being written. Nothing here
|
|
7
|
+
* renders, and nothing here needs a host - a capture is a file, and the two
|
|
8
|
+
* ends of a conversation can be read from the same shell.
|
|
9
|
+
*/
|
|
10
|
+
/** One line of a capture, as written by `ahpd --wire` and `ahpc --wire`. */
|
|
11
|
+
export interface WireLine {
|
|
12
|
+
at: string;
|
|
13
|
+
from: 'client' | 'host';
|
|
14
|
+
peer?: string;
|
|
15
|
+
frame: unknown;
|
|
16
|
+
}
|
|
17
|
+
/** One frame, described. Everything a row shows is decided here, once. */
|
|
18
|
+
export interface WireRow {
|
|
19
|
+
/** Its line number in the file, which is the only name a frame has. */
|
|
20
|
+
seq: number;
|
|
21
|
+
at: string;
|
|
22
|
+
from: 'client' | 'host';
|
|
23
|
+
peer: string;
|
|
24
|
+
/** What kind of JSON-RPC message the frame is. */
|
|
25
|
+
kind: 'request' | 'response' | 'error' | 'notification' | 'text';
|
|
26
|
+
/** The method, or for a response the method of the request it answers. */
|
|
27
|
+
method: string;
|
|
28
|
+
/** The request id, where there is one. */
|
|
29
|
+
id?: string;
|
|
30
|
+
/** The channel the frame names, where it names one. */
|
|
31
|
+
channel?: string;
|
|
32
|
+
/** The action type, for a dispatch or an action. */
|
|
33
|
+
type?: string;
|
|
34
|
+
/** One line about the payload: the error message, the action, the text. */
|
|
35
|
+
detail: string;
|
|
36
|
+
frame: unknown;
|
|
37
|
+
}
|
|
38
|
+
/** One line parsed, or undefined for one that is not a capture line. */
|
|
39
|
+
export declare function parseWireLine(text: string): WireLine | undefined;
|
|
40
|
+
/**
|
|
41
|
+
* The frames described, in order.
|
|
42
|
+
*
|
|
43
|
+
* A response says nothing about what it answers, so the requests are held by
|
|
44
|
+
* id and direction until their answer goes by - which is what puts a method
|
|
45
|
+
* on a response row, and is why this is a reader with state rather than a
|
|
46
|
+
* function of one line.
|
|
47
|
+
*/
|
|
48
|
+
export declare function describer(): (line: WireLine, seq: number) => WireRow;
|
|
49
|
+
/** Whether a row is one the filter keeps. A substring of anything a row shows, the way the catalogue filters. */
|
|
50
|
+
export declare const matches: (row: WireRow, query: string) => boolean;
|
|
51
|
+
export interface Follower {
|
|
52
|
+
/** Stop reading. */
|
|
53
|
+
close(): void;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* The file, as it grows.
|
|
57
|
+
*
|
|
58
|
+
* Read from the top, then from wherever the last read stopped whenever the
|
|
59
|
+
* file changes - `fs.watch` where the platform has it, and a poll beside it
|
|
60
|
+
* because a watcher on a file being appended to by another process is not
|
|
61
|
+
* something every platform delivers. A line still being written is kept
|
|
62
|
+
* until its newline arrives, so a frame is never handed over in halves. A
|
|
63
|
+
* file cut back to nothing, which is what a new `--wire` run does, starts
|
|
64
|
+
* over rather than reading past its end.
|
|
65
|
+
*/
|
|
66
|
+
export declare function follow(path: string, onRows: (rows: WireRow[]) => void, options?: {
|
|
67
|
+
interval?: number;
|
|
68
|
+
}): Follower;
|
|
69
|
+
/** A row as one line of text, for a shell that is not a terminal. */
|
|
70
|
+
export declare const rowText: (row: WireRow) => string;
|
package/dist/src/wire.js
ADDED
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A wire capture, read back.
|
|
3
|
+
*
|
|
4
|
+
* `--wire <file>` on either end writes one line per frame, `{ at, from, peer,
|
|
5
|
+
* frame }`, and this is the other half: the lines as rows a screen can list,
|
|
6
|
+
* and a reader that keeps up with a file still being written. Nothing here
|
|
7
|
+
* renders, and nothing here needs a host - a capture is a file, and the two
|
|
8
|
+
* ends of a conversation can be read from the same shell.
|
|
9
|
+
*/
|
|
10
|
+
import { closeSync, openSync, readSync, statSync, watch } from 'node:fs';
|
|
11
|
+
const bag = (value) => (typeof value === 'object' && value !== null ? value : {});
|
|
12
|
+
const str = (value) => (typeof value === 'string' ? value : undefined);
|
|
13
|
+
/** One line parsed, or undefined for one that is not a capture line. */
|
|
14
|
+
export function parseWireLine(text) {
|
|
15
|
+
let held;
|
|
16
|
+
try {
|
|
17
|
+
held = JSON.parse(text);
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
return undefined;
|
|
21
|
+
}
|
|
22
|
+
const line = bag(held);
|
|
23
|
+
if (typeof line.at !== 'string' || (line.from !== 'client' && line.from !== 'host') || !('frame' in line))
|
|
24
|
+
return undefined;
|
|
25
|
+
return { at: line.at, from: line.from, ...(typeof line.peer === 'string' ? { peer: line.peer } : {}), frame: line.frame };
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* The frames described, in order.
|
|
29
|
+
*
|
|
30
|
+
* A response says nothing about what it answers, so the requests are held by
|
|
31
|
+
* id and direction until their answer goes by - which is what puts a method
|
|
32
|
+
* on a response row, and is why this is a reader with state rather than a
|
|
33
|
+
* function of one line.
|
|
34
|
+
*/
|
|
35
|
+
export function describer() {
|
|
36
|
+
/** Requests waiting for an answer, by the direction they went and their id. */
|
|
37
|
+
const pending = new Map();
|
|
38
|
+
return (line, seq) => {
|
|
39
|
+
const frame = bag(line.frame);
|
|
40
|
+
const peer = line.peer ?? '';
|
|
41
|
+
if (typeof line.frame !== 'object' || line.frame === null) {
|
|
42
|
+
return { seq, at: line.at, from: line.from, peer, kind: 'text', method: '', detail: String(line.frame), frame: line.frame };
|
|
43
|
+
}
|
|
44
|
+
const id = frame.id === undefined || frame.id === null ? undefined : String(frame.id);
|
|
45
|
+
const params = bag(frame.params);
|
|
46
|
+
const channel = str(params.channel);
|
|
47
|
+
const action = bag(params.action);
|
|
48
|
+
const type = str(action.type);
|
|
49
|
+
if (typeof frame.method === 'string') {
|
|
50
|
+
const method = frame.method;
|
|
51
|
+
if (id !== undefined)
|
|
52
|
+
pending.set(`${line.from}:${id}`, method);
|
|
53
|
+
return {
|
|
54
|
+
seq, at: line.at, from: line.from, peer,
|
|
55
|
+
kind: id === undefined ? 'notification' : 'request',
|
|
56
|
+
method, ...(id === undefined ? {} : { id }), ...(channel === undefined ? {} : { channel }), ...(type === undefined ? {} : { type }),
|
|
57
|
+
detail: detailOf(method, params, action),
|
|
58
|
+
frame: line.frame,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
// An answer travels the other way from the question it answers.
|
|
62
|
+
const asked = line.from === 'client' ? 'host' : 'client';
|
|
63
|
+
const method = id === undefined ? '' : pending.get(`${asked}:${id}`) ?? '';
|
|
64
|
+
if (id !== undefined)
|
|
65
|
+
pending.delete(`${asked}:${id}`);
|
|
66
|
+
if ('error' in frame) {
|
|
67
|
+
const error = bag(frame.error);
|
|
68
|
+
return { seq, at: line.at, from: line.from, peer, kind: 'error', method, ...(id === undefined ? {} : { id }), detail: `${str(error.code) ?? String(error.code ?? '')} ${str(error.message) ?? ''}`.trim(), frame: line.frame };
|
|
69
|
+
}
|
|
70
|
+
return { seq, at: line.at, from: line.from, peer, kind: 'response', method, ...(id === undefined ? {} : { id }), detail: summaryOf(frame.result), frame: line.frame };
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
/** What a request or a notification is about, in a few words. */
|
|
74
|
+
const detailOf = (method, params, action) => {
|
|
75
|
+
// The action type is the row's own field; what is left is what the action
|
|
76
|
+
// carried, where it carried words.
|
|
77
|
+
if (method === 'dispatchAction' || method === 'action') {
|
|
78
|
+
const text = str(bag(action.message).text) ?? str(action.text) ?? str(action.title);
|
|
79
|
+
return text === undefined ? '' : text.replace(/\s+/g, ' ').slice(0, 80);
|
|
80
|
+
}
|
|
81
|
+
if (method === 'initialize')
|
|
82
|
+
return str(params.clientId) ?? '';
|
|
83
|
+
if (method === 'createSession')
|
|
84
|
+
return `${str(params.provider) ?? ''} ${str(params.channel) ?? ''}`.trim();
|
|
85
|
+
// The channel is the row's own field too; what else the request names.
|
|
86
|
+
return str(params.uri) ?? str(params.session) ?? str(params.resource) ?? str(params.provider) ?? '';
|
|
87
|
+
};
|
|
88
|
+
/** What a result is, in a few words: its keys, or the value when it is one. */
|
|
89
|
+
const summaryOf = (result) => {
|
|
90
|
+
if (result === undefined || result === null)
|
|
91
|
+
return '';
|
|
92
|
+
if (typeof result !== 'object')
|
|
93
|
+
return String(result);
|
|
94
|
+
if (Array.isArray(result))
|
|
95
|
+
return `${result.length} item${result.length === 1 ? '' : 's'}`;
|
|
96
|
+
const keys = Object.keys(result);
|
|
97
|
+
return keys.length === 0 ? '{}' : keys.slice(0, 6).join(', ') + (keys.length > 6 ? ', …' : '');
|
|
98
|
+
};
|
|
99
|
+
/** Whether a row is one the filter keeps. A substring of anything a row shows, the way the catalogue filters. */
|
|
100
|
+
export const matches = (row, query) => {
|
|
101
|
+
const wanted = query.trim().toLowerCase();
|
|
102
|
+
if (wanted === '')
|
|
103
|
+
return true;
|
|
104
|
+
return [row.method, row.channel ?? '', row.type ?? '', row.kind, row.from, row.id ?? '', row.detail, row.peer]
|
|
105
|
+
.some((field) => field.toLowerCase().includes(wanted));
|
|
106
|
+
};
|
|
107
|
+
/**
|
|
108
|
+
* The file, as it grows.
|
|
109
|
+
*
|
|
110
|
+
* Read from the top, then from wherever the last read stopped whenever the
|
|
111
|
+
* file changes - `fs.watch` where the platform has it, and a poll beside it
|
|
112
|
+
* because a watcher on a file being appended to by another process is not
|
|
113
|
+
* something every platform delivers. A line still being written is kept
|
|
114
|
+
* until its newline arrives, so a frame is never handed over in halves. A
|
|
115
|
+
* file cut back to nothing, which is what a new `--wire` run does, starts
|
|
116
|
+
* over rather than reading past its end.
|
|
117
|
+
*/
|
|
118
|
+
export function follow(path, onRows, options = {}) {
|
|
119
|
+
const describe = describer();
|
|
120
|
+
let offset = 0;
|
|
121
|
+
let seq = 0;
|
|
122
|
+
let partial = '';
|
|
123
|
+
let closed = false;
|
|
124
|
+
const read = () => {
|
|
125
|
+
if (closed)
|
|
126
|
+
return;
|
|
127
|
+
let size;
|
|
128
|
+
try {
|
|
129
|
+
size = statSync(path).size;
|
|
130
|
+
}
|
|
131
|
+
catch {
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
if (size < offset) {
|
|
135
|
+
offset = 0;
|
|
136
|
+
partial = '';
|
|
137
|
+
}
|
|
138
|
+
if (size === offset)
|
|
139
|
+
return;
|
|
140
|
+
const fd = openSync(path, 'r');
|
|
141
|
+
let chunk;
|
|
142
|
+
try {
|
|
143
|
+
chunk = Buffer.alloc(size - offset);
|
|
144
|
+
readSync(fd, chunk, 0, chunk.length, offset);
|
|
145
|
+
}
|
|
146
|
+
finally {
|
|
147
|
+
closeSync(fd);
|
|
148
|
+
}
|
|
149
|
+
offset = size;
|
|
150
|
+
const text = partial + chunk.toString('utf8');
|
|
151
|
+
const lines = text.split('\n');
|
|
152
|
+
partial = lines.pop() ?? '';
|
|
153
|
+
const rows = [];
|
|
154
|
+
for (const line of lines) {
|
|
155
|
+
if (line.trim() === '')
|
|
156
|
+
continue;
|
|
157
|
+
const parsed = parseWireLine(line);
|
|
158
|
+
seq += 1;
|
|
159
|
+
if (parsed === undefined) {
|
|
160
|
+
rows.push({ seq, at: '', from: 'client', peer: '', kind: 'text', method: '', detail: line.slice(0, 120), frame: line });
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
163
|
+
rows.push(describe(parsed, seq));
|
|
164
|
+
}
|
|
165
|
+
if (rows.length > 0)
|
|
166
|
+
onRows(rows);
|
|
167
|
+
};
|
|
168
|
+
read();
|
|
169
|
+
const timer = setInterval(read, options.interval ?? 250);
|
|
170
|
+
timer.unref?.();
|
|
171
|
+
let watcher;
|
|
172
|
+
try {
|
|
173
|
+
watcher = watch(path, () => { read(); });
|
|
174
|
+
watcher.unref?.();
|
|
175
|
+
watcher.on('error', () => { watcher = undefined; });
|
|
176
|
+
}
|
|
177
|
+
catch {
|
|
178
|
+
watcher = undefined;
|
|
179
|
+
}
|
|
180
|
+
return {
|
|
181
|
+
close: () => {
|
|
182
|
+
closed = true;
|
|
183
|
+
clearInterval(timer);
|
|
184
|
+
watcher?.close();
|
|
185
|
+
},
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
/** A row as one line of text, for a shell that is not a terminal. */
|
|
189
|
+
export const rowText = (row) => {
|
|
190
|
+
const time = row.at.length >= 23 ? row.at.slice(11, 23) : row.at;
|
|
191
|
+
const arrow = row.from === 'client' ? '->' : '<-';
|
|
192
|
+
const what = row.kind === 'response' ? `${row.method} ok` : row.kind === 'error' ? `${row.method} error` : row.method;
|
|
193
|
+
return [time, arrow, what || row.kind, row.type ?? '', row.channel ?? '', row.detail].filter((one) => one !== '').join(' ');
|
|
194
|
+
};
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `ahpc wire <file>`: a capture, watched.
|
|
3
|
+
*
|
|
4
|
+
* A screen with no host behind it. On a terminal it is the wire screen,
|
|
5
|
+
* reading the file as it grows; anywhere else it is one line per frame to
|
|
6
|
+
* stdout, which is what a shell wants from it. Loaded only when asked for,
|
|
7
|
+
* for the same reason the conversation screen is: it pulls in a renderer,
|
|
8
|
+
* and `ahpc session list` should not pay for one.
|
|
9
|
+
*/
|
|
10
|
+
export interface WireTuiOptions {
|
|
11
|
+
file: string;
|
|
12
|
+
theme?: string;
|
|
13
|
+
shell?: string;
|
|
14
|
+
/** Keep printing as the file grows, off a terminal. */
|
|
15
|
+
follow?: boolean;
|
|
16
|
+
/** One JSON object per row rather than a line for reading. */
|
|
17
|
+
json?: boolean;
|
|
18
|
+
/** Only the rows this matches, off a terminal; on one the filter box is the same thing. */
|
|
19
|
+
filter?: string;
|
|
20
|
+
}
|
|
21
|
+
/** The screen, or the lines. */
|
|
22
|
+
export declare function wireTui(options: WireTuiOptions): Promise<void>;
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `ahpc wire <file>`: a capture, watched.
|
|
3
|
+
*
|
|
4
|
+
* A screen with no host behind it. On a terminal it is the wire screen,
|
|
5
|
+
* reading the file as it grows; anywhere else it is one line per frame to
|
|
6
|
+
* stdout, which is what a shell wants from it. Loaded only when asked for,
|
|
7
|
+
* for the same reason the conversation screen is: it pulls in a renderer,
|
|
8
|
+
* and `ahpc session list` should not pay for one.
|
|
9
|
+
*/
|
|
10
|
+
import { WRITER_KEY, createApp } from '@textui/core';
|
|
11
|
+
import { createNodeTerminal, createWriter } from '@textui/terminal';
|
|
12
|
+
import { registerWire } from './view/wire.js';
|
|
13
|
+
import { follow, matches, rowText } from './wire.js';
|
|
14
|
+
/** The screen, or the lines. */
|
|
15
|
+
export async function wireTui(options) {
|
|
16
|
+
if (!process.stdout.isTTY || options.json) {
|
|
17
|
+
await new Promise((resolve) => {
|
|
18
|
+
let quiet;
|
|
19
|
+
const done = () => { reader.close(); resolve(); };
|
|
20
|
+
const reader = follow(options.file, (rows) => {
|
|
21
|
+
for (const row of rows) {
|
|
22
|
+
if (options.filter !== undefined && !matches(row, options.filter))
|
|
23
|
+
continue;
|
|
24
|
+
process.stdout.write(options.json ? `${JSON.stringify(row)}\n` : `${rowText(row)}\n`);
|
|
25
|
+
}
|
|
26
|
+
if (!options.follow) {
|
|
27
|
+
if (quiet)
|
|
28
|
+
clearTimeout(quiet);
|
|
29
|
+
quiet = setTimeout(done, 50);
|
|
30
|
+
}
|
|
31
|
+
});
|
|
32
|
+
// Without `--follow`, what is there now and then out: the first read
|
|
33
|
+
// is synchronous, so nothing arriving in the next moment means the
|
|
34
|
+
// file has been read to its end.
|
|
35
|
+
if (!options.follow && quiet === undefined)
|
|
36
|
+
quiet = setTimeout(done, 50);
|
|
37
|
+
});
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
const terminal = createNodeTerminal();
|
|
41
|
+
const app = createApp({
|
|
42
|
+
terminal,
|
|
43
|
+
...(options.theme ? { theme: options.theme } : {}),
|
|
44
|
+
...(options.shell ? { shell: options.shell } : {}),
|
|
45
|
+
session: { managed: true, altScreen: true, mouse: true, title: 'wire' },
|
|
46
|
+
onBoot: (booted) => {
|
|
47
|
+
registerWire(booted, { file: options.file });
|
|
48
|
+
booted.commands.register({
|
|
49
|
+
id: 'app.quit',
|
|
50
|
+
title: 'Quit',
|
|
51
|
+
slots: ['palette'],
|
|
52
|
+
run: () => void app.stop().then(() => process.exit(0)),
|
|
53
|
+
});
|
|
54
|
+
booted.keybindings.register({ keys: 'ctrl+c', commandId: 'app.quit' });
|
|
55
|
+
booted.keybindings.register({ keys: 'ctrl+q', commandId: 'app.quit' });
|
|
56
|
+
booted.keybindings.register({ keys: 'q', commandId: 'app.quit', scopeId: 'wire.rows' });
|
|
57
|
+
},
|
|
58
|
+
});
|
|
59
|
+
app.services.provide(WRITER_KEY, createWriter(terminal.capabilities()));
|
|
60
|
+
await app.start();
|
|
61
|
+
const bail = (label) => (error) => {
|
|
62
|
+
void app.stop().finally(() => {
|
|
63
|
+
process.stderr.write(`${label}: ${error instanceof Error ? error.stack ?? error.message : String(error)}\n`);
|
|
64
|
+
process.exit(1);
|
|
65
|
+
});
|
|
66
|
+
};
|
|
67
|
+
process.on('unhandledRejection', bail('Unhandled rejection'));
|
|
68
|
+
process.on('uncaughtException', bail('Uncaught exception'));
|
|
69
|
+
}
|