@softov/ahpc 0.1.0 → 0.3.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/dist/src/tui.d.ts CHANGED
@@ -79,7 +79,7 @@ interface Options {
79
79
  publishWritable?: boolean;
80
80
  help: boolean;
81
81
  }
82
- export declare const USAGE = "ahpc - a terminal client for the Agent Host Protocol\n\n ahpc [options]\n\nThe host\n --host <url> A live agent host, ws://host:port\n --token <tkn> A bearer token for it\n --config-file <f> Read this instead of ~/.config/ahpc/config.json\n (none of these) The scripted host, which needs nothing installed\n\nWhere the agent works\n --path <dir> A path on the host, not on this machine. The host\n has to serve it, and says so if it does not.\n Left out, the host decides.\n\nWhat this client serves back\n --publish <dir> Serve this directory to the host under\n virtual://<clientId>/. Nothing is served without\n it, and every such request is refused.\n --publish-writable Let the host write into it. Read-only otherwise.\n Serving lasts as long as the screen does: with no\n terminal attached this prints one frame and exits,\n so a background shell publishes nothing.\n\nAppearance\n --theme <name> workbench, paper-light, ...\n --shell <name> The shell layout\n --screen <name> Which screen to open on\n --bood Let the creature loose on the whole screen. It\n keeps off the composer and off anything asking\n a question, and alt+g turns it off again.\n --session <uri> Open this session\n\nStills, for a README or a test\n --static, -s One frame to stdout instead of running\n --width, -w <n> Columns\n --height <n> Rows\n --unicode <level> ascii, bmp, full\n --colors <n> 0, 4, 8 or 24\n --svg <file> Write the still as SVG here\n --tick <ms> Milliseconds per scripted word\n --settled Run the script out before the frame\n --pump <n> Or exactly this many scripted words\n --say <text> Say this on the open session first\n --approve Answer the confirmation the script stops at\n --answer ...and then the question\n\n --help, -h This\n\nCommands\n ahpc <command> ... Drive a host without the screen. 'ahpc help' lists\n them: sessions, prompts, approvals, terminals.\n";
82
+ export declare const USAGE = "ahpc - a terminal client for the Agent Host Protocol\n\n ahpc [options]\n\nThe host\n --host <url> A live agent host, ws://host:port\n --token <tkn> A bearer token for it\n --config-file <f> Read this instead of ~/.config/ahpc/config.json\n (none of these) The scripted host, which needs nothing installed\n\nWhere the agent works\n --path <dir> A path on the host, not on this machine. The host\n has to serve it, and says so if it does not.\n Left out, the host decides.\n\nWhat this client serves back\n --publish <dir> Serve this directory to the host under\n virtual://<clientId>/. Nothing is served without\n it, and every such request is refused.\n --publish-writable Let the host write into it. Read-only otherwise.\n Serving lasts as long as the screen does: with no\n terminal attached this prints one frame and exits,\n so a background shell publishes nothing.\n\nAppearance\n --theme <name> workbench, paper-light, ...\n --shell <name> The shell layout\n --screen <name> Which screen to open on\n --bood Let the creature loose on the whole screen. It\n keeps off the composer and off anything asking\n a question, and alt+g turns it off again.\n --session <uri> Open this session\n\nStills, for a README or a test\n --static, -s One frame to stdout instead of running\n --width, -w <n> Columns\n --height <n> Rows\n --unicode <level> ascii, bmp, full\n --colors <n> 0, 4, 8 or 24\n --svg <file> Write the still as SVG here\n --tick <ms> Milliseconds per scripted word\n --settled Run the script out before the frame\n --pump <n> Or exactly this many scripted words\n --say <text> Say this on the open session first\n --approve Answer the confirmation the script stops at\n --answer ...and then the question\n\n --version, -v What version this is\n --help, -h This\n\nCommands\n ahpc <command> ... Drive a host without the screen. 'ahpc help' lists\n them: sessions, prompts, approvals, terminals.\n";
83
83
  export declare function parse(argv: string[]): Options;
84
84
  /**
85
85
  * The screen.
package/dist/src/tui.js CHANGED
@@ -53,6 +53,7 @@ Stills, for a README or a test
53
53
  --approve Answer the confirmation the script stops at
54
54
  --answer ...and then the question
55
55
 
56
+ --version, -v What version this is
56
57
  --help, -h This
57
58
 
58
59
  Commands
@@ -0,0 +1,2 @@
1
+ /** The version in the nearest `package.json`, or `unknown` where there is none. */
2
+ export declare const version: () => string;
@@ -0,0 +1,38 @@
1
+ /*
2
+ * What version this is, read from the manifest rather than written twice.
3
+ *
4
+ * A literal in the source is a literal that drifts: the one in `mcp/serve.ts`
5
+ * said 0.1 while the package said 0.2 within a day of being written, and a
6
+ * `--version` that lies is worse than no `--version` at all.
7
+ *
8
+ * Found by walking up from this module rather than by a fixed relative path,
9
+ * because the depth differs: `src/version.ts` in a checkout and
10
+ * `dist/src/version.js` in an install, and neither should have to know which
11
+ * it is. `package.json` is always at the package root and npm always ships
12
+ * it, so the first one above this file is the right one.
13
+ *
14
+ * This file imports nothing but Node, on purpose. The entry point answers
15
+ * `--version` before it decides which front end to load, and loading one to
16
+ * answer it would make the fastest question the slowest.
17
+ */
18
+ import { readFileSync } from 'node:fs';
19
+ import { dirname, join } from 'node:path';
20
+ import { fileURLToPath } from 'node:url';
21
+ /** The version in the nearest `package.json`, or `unknown` where there is none. */
22
+ export const version = () => {
23
+ let at = dirname(fileURLToPath(import.meta.url));
24
+ for (;;) {
25
+ try {
26
+ const found = JSON.parse(readFileSync(join(at, 'package.json'), 'utf8'));
27
+ if (typeof found.version === 'string')
28
+ return found.version;
29
+ }
30
+ catch { /* not this directory */ }
31
+ const up = dirname(at);
32
+ // The root of the filesystem, which means there is no manifest anywhere
33
+ // above this file - a bundler inlined it, or something unpacked it wrong.
34
+ if (up === at)
35
+ return 'unknown';
36
+ at = up;
37
+ }
38
+ };
@@ -0,0 +1,56 @@
1
+ import type { HostConnection, HostEvent } from './ahp/connection.js';
2
+ import type { ModelSelection, SessionUri, Turn } from './ahp/types.js';
3
+ /**
4
+ * Watch one session until it does something, then stop watching.
5
+ *
6
+ * Every streaming command is this with a different stopping condition. The
7
+ * subscription is always closed - a caller that left one open would be a
8
+ * process that never exits, which is the one thing a shell cannot work around.
9
+ */
10
+ export declare function until(host: HostConnection, uri: SessionUri, done: (event: HostEvent) => boolean, options?: {
11
+ onEvent?(event: HostEvent): void;
12
+ timeoutSeconds?: number;
13
+ }): Promise<HostEvent | undefined>;
14
+ /** A turn, as a line of prose rather than a tree of parts. */
15
+ export declare const spoken: (turn: Turn) => string;
16
+ /** What a caller wants told while a turn is running, and how long to wait. */
17
+ export interface TurnOptions {
18
+ model?: ModelSelection;
19
+ timeoutSeconds?: number;
20
+ /** Text the agent has said that the caller has not been given yet. */
21
+ onDelta?(text: string): void;
22
+ /** A tool call the agent is blocked on, said once per call. */
23
+ onWaiting?(call: {
24
+ id: string;
25
+ name: string;
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;
38
+ }
39
+ /**
40
+ * Say something, and block until the turn it starts has finished.
41
+ *
42
+ * Subscribed before saying anything: the first snapshot is the baseline that
43
+ * says which turns were already there, and one taken afterwards would count
44
+ * the new turn among them.
45
+ *
46
+ * "Finished" is a turn of *ours* having ended - one that was not in the
47
+ * baseline - rather than the last in the list, which is a different session's
48
+ * answer when two people are talking in the same chat. A turn that stops to
49
+ * ask a person something is not finished and is not this caller's to answer,
50
+ * so the wait runs on until whoever is answering has.
51
+ *
52
+ * Answers `undefined` where the timeout ran out, which is a caller's to
53
+ * report rather than to throw: the turn is still going and the session is
54
+ * still there.
55
+ */
56
+ export declare function turn(host: HostConnection, uri: SessionUri, text: string, options?: TurnOptions): Promise<Turn | undefined>;
@@ -0,0 +1,160 @@
1
+ /*
2
+ * Watching one session until it does something, for whoever is asking.
3
+ *
4
+ * The CLI and the tool server both say a thing and then block until the turn
5
+ * it started has finished, and both need the same answer to "finished" - which
6
+ * is not "the last turn in the list" and not "nothing is running". Written
7
+ * once here, because the two disagreeing would mean a tool that returned
8
+ * before the reply was complete while the same command in a shell waited.
9
+ */
10
+ /**
11
+ * Watch one session until it does something, then stop watching.
12
+ *
13
+ * Every streaming command is this with a different stopping condition. The
14
+ * subscription is always closed - a caller that left one open would be a
15
+ * process that never exits, which is the one thing a shell cannot work around.
16
+ */
17
+ export function until(host, uri, done, options = {}) {
18
+ return new Promise((answer) => {
19
+ let closed = false;
20
+ /*
21
+ * The handle may not exist yet when this runs.
22
+ *
23
+ * A host is entitled to deliver the opening snapshot *synchronously*
24
+ * inside `subscribe` - the scripted one does, and it is the honest thing
25
+ * for a host holding the state already - so a condition satisfied by that
26
+ * first event fires before `subscribe` has returned anything to close.
27
+ * Reading the handle there threw, which made every waiting command fail
28
+ * against the scripted host and work against a socket, purely because one
29
+ * of them answers a tick later.
30
+ */
31
+ let handle;
32
+ const finish = (event) => {
33
+ if (closed)
34
+ return;
35
+ closed = true;
36
+ clearTimeout(timer);
37
+ handle?.close();
38
+ answer(event);
39
+ };
40
+ const timer = setTimeout(() => finish(undefined), Math.max(1, (options.timeoutSeconds ?? 900)) * 1000);
41
+ timer.unref?.();
42
+ handle = host.subscribe(uri, (event) => {
43
+ options.onEvent?.(event);
44
+ if (done(event))
45
+ finish(event);
46
+ });
47
+ // Already over, before there was a handle to close. Closing it now is what
48
+ // `finish` could not do.
49
+ if (closed)
50
+ handle.close();
51
+ });
52
+ }
53
+ /** A turn, as a line of prose rather than a tree of parts. */
54
+ export const spoken = (turn) => turn.parts
55
+ .map((part) => (part.kind === 'markdown' ? part.content : ''))
56
+ .join('')
57
+ .trim();
58
+ /**
59
+ * Say something, and block until the turn it starts has finished.
60
+ *
61
+ * Subscribed before saying anything: the first snapshot is the baseline that
62
+ * says which turns were already there, and one taken afterwards would count
63
+ * the new turn among them.
64
+ *
65
+ * "Finished" is a turn of *ours* having ended - one that was not in the
66
+ * baseline - rather than the last in the list, which is a different session's
67
+ * answer when two people are talking in the same chat. A turn that stops to
68
+ * ask a person something is not finished and is not this caller's to answer,
69
+ * so the wait runs on until whoever is answering has.
70
+ *
71
+ * Answers `undefined` where the timeout ran out, which is a caller's to
72
+ * report rather than to throw: the turn is still going and the session is
73
+ * still there.
74
+ */
75
+ export async function turn(host, uri, text, options = {}) {
76
+ let given = 0;
77
+ let sawActive = false;
78
+ let before = new Set();
79
+ let first = true;
80
+ let noted;
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
+ };
90
+ const finished = until(host, uri, (event) => {
91
+ /*
92
+ * Two vocabularies, because a host may speak either.
93
+ *
94
+ * `live.ts` rebuilds the whole view and sends a `snapshot` after every
95
+ * action; `fake.ts` sends what changed - `turnStarted`, `delta`,
96
+ * `turnComplete`. Both are `HostEvent` and both are legal, and a caller
97
+ * that read only snapshots waited for ever on the scripted host, which
98
+ * this client offers as the one that needs nothing installed.
99
+ */
100
+ if (event.type === 'turnStarted') {
101
+ sawActive = true;
102
+ return false;
103
+ }
104
+ if (event.type === 'delta') {
105
+ if (event.kind === 'markdown')
106
+ options.onDelta?.(event.text);
107
+ return false;
108
+ }
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) {
112
+ noted = event.call.id;
113
+ options.onWaiting?.({ id: event.call.id, name: event.call.name });
114
+ }
115
+ return false;
116
+ }
117
+ if (event.type === 'turnComplete') {
118
+ if (before.has(event.turn.id))
119
+ return false;
120
+ answer = event.turn;
121
+ return true;
122
+ }
123
+ if (event.type !== 'snapshot')
124
+ return false;
125
+ if (first) {
126
+ first = false;
127
+ before = new Set(event.turns.map((one) => one.id));
128
+ }
129
+ if (event.active) {
130
+ sawActive = true;
131
+ const now = spoken(event.active);
132
+ if (now.length > given) {
133
+ options.onDelta?.(now.slice(given));
134
+ given = now.length;
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
+ }
140
+ const call = event.active.parts.find((part) => part.kind === 'toolCall'
141
+ && part.call.status === 'pending-confirmation');
142
+ if (call?.kind === 'toolCall' && noted !== call.call.id) {
143
+ noted = call.call.id;
144
+ options.onWaiting?.({ id: call.call.id, name: call.call.name });
145
+ }
146
+ return false;
147
+ }
148
+ // Something wants a person. Not finished, and not this caller's to answer.
149
+ if (event.input)
150
+ return false;
151
+ const fresh = event.turns.filter((one) => one.role === 'agent' && !before.has(one.id));
152
+ if (!sawActive && fresh.length === 0)
153
+ return false;
154
+ answer = fresh[fresh.length - 1];
155
+ return true;
156
+ }, { ...(options.timeoutSeconds === undefined ? {} : { timeoutSeconds: options.timeoutSeconds }) });
157
+ host.say(uri, text, options.model);
158
+ await finished;
159
+ return answer;
160
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@softov/ahpc",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
4
4
  "type": "module",
5
5
  "description": "A terminal client for the Agent Host Protocol: sessions, a streaming transcript, and interactive prompts",
6
6
  "keywords": [