@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.
Files changed (73) hide show
  1. package/README.md +37 -6
  2. package/dist/src/ahp/fake.js +61 -14
  3. package/dist/src/ahp/live.d.ts +7 -0
  4. package/dist/src/ahp/live.js +156 -43
  5. package/dist/src/ahp/publish.js +13 -0
  6. package/dist/src/ahp/types.d.ts +20 -0
  7. package/dist/src/app.js +34 -7
  8. package/dist/src/blocks.d.ts +4 -74
  9. package/dist/src/blocks.js +10 -48
  10. package/dist/src/cli/main.d.ts +1 -1
  11. package/dist/src/cli/main.js +109 -6
  12. package/dist/src/connect.d.ts +2 -0
  13. package/dist/src/connect.js +1 -0
  14. package/dist/src/control.d.ts +6 -0
  15. package/dist/src/control.js +140 -10
  16. package/dist/src/flags.js +4 -1
  17. package/dist/src/links.d.ts +54 -0
  18. package/dist/src/links.js +120 -0
  19. package/dist/src/main.js +14 -2
  20. package/dist/src/mcp/http.d.ts +10 -0
  21. package/dist/src/mcp/http.js +110 -3
  22. package/dist/src/mcp/serve.d.ts +47 -9
  23. package/dist/src/mcp/serve.js +65 -14
  24. package/dist/src/mcp/stdio.d.ts +1 -0
  25. package/dist/src/mcp/stdio.js +7 -1
  26. package/dist/src/mcp/tools.d.ts +37 -2
  27. package/dist/src/mcp/tools.js +439 -2
  28. package/dist/src/resources.d.ts +13 -0
  29. package/dist/src/resources.js +46 -0
  30. package/dist/src/screens.js +95 -50
  31. package/dist/src/state.d.ts +42 -0
  32. package/dist/src/state.js +80 -1
  33. package/dist/src/tui.d.ts +3 -1
  34. package/dist/src/tui.js +29 -4
  35. package/dist/src/version.d.ts +2 -0
  36. package/dist/src/version.js +38 -0
  37. package/dist/src/view/creature.d.ts +0 -12
  38. package/dist/src/view/creature.js +0 -20
  39. package/dist/src/view/wire.d.ts +36 -0
  40. package/dist/src/view/wire.js +196 -0
  41. package/dist/src/wait.d.ts +11 -0
  42. package/dist/src/wait.js +15 -2
  43. package/dist/src/wire.d.ts +70 -0
  44. package/dist/src/wire.js +194 -0
  45. package/dist/src/wiretui.d.ts +22 -0
  46. package/dist/src/wiretui.js +69 -0
  47. package/package.json +6 -5
  48. package/dist/src/diff.d.ts +0 -44
  49. package/dist/src/diff.js +0 -111
  50. package/dist/src/view/bubble.d.ts +0 -75
  51. package/dist/src/view/bubble.js +0 -86
  52. package/dist/src/view/composer.d.ts +0 -64
  53. package/dist/src/view/composer.js +0 -192
  54. package/dist/src/view/controls.d.ts +0 -44
  55. package/dist/src/view/controls.js +0 -49
  56. package/dist/src/view/details.d.ts +0 -65
  57. package/dist/src/view/details.js +0 -65
  58. package/dist/src/view/filediff.d.ts +0 -29
  59. package/dist/src/view/filediff.js +0 -24
  60. package/dist/src/view/hitl.d.ts +0 -43
  61. package/dist/src/view/hitl.js +0 -171
  62. package/dist/src/view/icons.d.ts +0 -13
  63. package/dist/src/view/icons.js +0 -71
  64. package/dist/src/view/picker.d.ts +0 -42
  65. package/dist/src/view/picker.js +0 -71
  66. package/dist/src/view/sessionhead.d.ts +0 -41
  67. package/dist/src/view/sessionhead.js +0 -60
  68. package/dist/src/view/sessions.d.ts +0 -34
  69. package/dist/src/view/sessions.js +0 -61
  70. package/dist/src/view/toolcall.d.ts +0 -27
  71. package/dist/src/view/toolcall.js +0 -48
  72. package/dist/src/view/transcript.d.ts +0 -50
  73. package/dist/src/view/transcript.js +0 -60
package/dist/src/app.js CHANGED
@@ -3,19 +3,15 @@ import { createBag, defineComponent, useApp, useTheme, useStoreSubtree, useStore
3
3
  import { KeyHints, Row, registerBuiltins } from '@textui/widgets';
4
4
  import { CONTROLLER, createController } from './control.js';
5
5
  import { fakeHost } from './ahp/fake.js';
6
+ import { hostResources } from './resources.js';
6
7
  import { BOOD, BOOD_FLOAT, BOOD_FLOOR, BOOD_INLINE, FOCUS, HOST, HOST_ERROR, INPUT, INPUT_STATUS, OPEN, RUNNING, SCREEN, SESSIONS, SPLIT_AT, SPLIT_DEFAULT, STATUS, WORKSPACE, boodFloor, openSession, workspaceName, } from './state.js';
7
8
  import { decodeStatus } from './ahp/status.js';
8
9
  import { AutomationsScreen, ChangesScreen, ChatScreen, FilesScreen, HostsScreen, McpScreen, NewAutomationScreen, NewSessionScreen, SessionsScreen, TerminalScreen, SettingsScreen, SkillsScreen, } from './screens.js';
9
- import { ChatBubble, ReasoningBlock, StreamingText } from './view/bubble.js';
10
- import { ChatComposer } from './view/composer.js';
11
- import { ChatHitl } from './view/hitl.js';
12
- import { ChatTranscript } from './view/transcript.js';
10
+ import { ChatBubble, ChatComposer, ChatHitl, ChatTranscript, ConnectionBadge, ReasoningBlock, SEND_ID, SessionList, StreamingText, ToolCallRow, } from '@textui/chat';
13
11
  import { BoodSprite, Creature, moodOf, pickBood } from './view/creature.js';
14
12
  import { ChangesList } from './view/changes.js';
15
13
  import { FileList } from './view/files.js';
16
14
  import { AutomationList } from './view/automations.js';
17
- import { ConnectionBadge, SessionList } from './view/sessions.js';
18
- import { ToolCallRow } from './view/toolcall.js';
19
15
  /**
20
16
  * A chat client for an agent host.
21
17
  *
@@ -124,6 +120,7 @@ const Header = defineComponent('ChatHeader', () => {
124
120
  */
125
121
  const Hints = defineComponent('ChatHints', (props) => {
126
122
  const theme = useTheme();
123
+ const app = useApp();
127
124
  /**
128
125
  * Which key the footer names for a newline.
129
126
  *
@@ -153,8 +150,19 @@ const Hints = defineComponent('ChatHints', (props) => {
153
150
  // it, escape leaves the field; from the transcript, escape leaves the
154
151
  // screen - and a hint row that said one of those in both places is wrong
155
152
  // half the time.
156
- const focused = useStoreValue(FOCUS, null);
153
+ const focused = useStoreValue(FOCUS, null) ?? null;
157
154
  const composing = focused === 'chat.composer';
155
+ // The control rows under the field are their own place: enter there opens
156
+ // a chip's panel or sends, and "alt+enter newline" is a key for a field
157
+ // the keyboard has left.
158
+ const onChip = focused !== null && focused.startsWith('chat.option.');
159
+ const onSend = focused === SEND_ID;
160
+ // A panel over the screen - a chip's picker, the palette - holds the
161
+ // keyboard, and the keys are the panel's until it closes. Read off the
162
+ // layers when the focus moves, which a panel that traps it does on the way
163
+ // in and on the way out; the layers themselves are not something a
164
+ // component can subscribe to.
165
+ const inPanel = app.layers.entries().some((entry) => entry.trapFocus === true);
158
166
  // A question is not a confirmation, and the keys are not the same either.
159
167
  // Offering "a approve" over an elicitation is the same mistake as rendering
160
168
  // one as the other, made in the one row that is supposed to explain it.
@@ -174,6 +182,22 @@ const Hints = defineComponent('ChatHints', (props) => {
174
182
  { keys: 'esc', label: 'read' },
175
183
  ] }));
176
184
  }
185
+ if (inPanel) {
186
+ return (_jsx(KeyHints, { ...props, hints: [
187
+ { keys: upDown, label: 'move' },
188
+ { keys: 'enter', label: 'choose' },
189
+ { keys: 'esc', label: 'back' },
190
+ { keys: 'ctrl+c', label: running ? 'stop' : 'quit' },
191
+ ] }));
192
+ }
193
+ if ((screen === 'chat' || screen === 'new') && (onChip || onSend)) {
194
+ return (_jsx(KeyHints, { ...props, hints: [
195
+ { keys: 'enter', label: onSend ? (screen === 'new' ? 'start' : running ? 'queue' : 'send') : 'open' },
196
+ { keys: 'tab', label: 'next option' },
197
+ { keys: 'esc', label: 'write' },
198
+ { keys: 'ctrl+c', label: running ? 'stop' : 'quit' },
199
+ ] }));
200
+ }
177
201
  if (screen === 'chat') {
178
202
  return (_jsx(KeyHints, { ...props, hints: composing
179
203
  ? [
@@ -293,6 +317,9 @@ export function registerChat(app, options = {}) {
293
317
  app.store.set(BOOD_FLOAT, options.boodFloat ?? false);
294
318
  bag.add(controller);
295
319
  bag.add(app.services.provide(CONTROLLER, controller));
320
+ // `file:` is the host's disk from here on, for the picker and anything
321
+ // else in textui that reads the resource registry.
322
+ bag.add(app.resources.registerProvider(hostResources(host)));
296
323
  for (const [component, render] of [
297
324
  ['ChatBubble', ChatBubble],
298
325
  ['StreamingText', StreamingText],
@@ -1,4 +1,7 @@
1
- import type { QueuedMessage, ToolCall, Turn } from './ahp/types.js';
1
+ import type { Block } from '@textui/chat';
2
+ import type { QueuedMessage, Turn } from './ahp/types.js';
3
+ export type { Block } from '@textui/chat';
4
+ export { selectable } from '@textui/chat';
2
5
  /**
3
6
  * A conversation, flattened into the rows a viewport scrolls.
4
7
  *
@@ -12,77 +15,4 @@ import type { QueuedMessage, ToolCall, Turn } from './ahp/types.js';
12
15
  * in one stream, and "let me search for those" means something before the
13
16
  * searches and nothing after them.
14
17
  */
15
- export type Block = {
16
- kind: 'said';
17
- id: string;
18
- turnId: string;
19
- text: string;
20
- } | {
21
- kind: 'header';
22
- id: string;
23
- turnId: string;
24
- model?: string;
25
- /**
26
- * What the turn was asked for besides the model, in the host's words.
27
- *
28
- * The values rather than the keys: `thinkingLevel` is one host's name for
29
- * a property whose *answers* are what a person reads, and a header that
30
- * spelled out the key would be twice as long and no clearer.
31
- */
32
- settings?: string;
33
- meta: string;
34
- state: Turn['state'];
35
- } | {
36
- kind: 'prose';
37
- id: string;
38
- turnId: string;
39
- content: string;
40
- streaming: boolean;
41
- } | {
42
- kind: 'reasoning';
43
- id: string;
44
- turnId: string;
45
- content: string;
46
- streaming: boolean;
47
- } | {
48
- kind: 'notice';
49
- id: string;
50
- turnId: string;
51
- content: string;
52
- } | {
53
- kind: 'failure';
54
- id: string;
55
- turnId: string;
56
- content: string;
57
- resumable: boolean;
58
- } | {
59
- kind: 'tool';
60
- id: string;
61
- turnId: string;
62
- call: ToolCall;
63
- } | {
64
- kind: 'queued';
65
- id: string;
66
- messageId: string;
67
- text: string;
68
- };
69
- /**
70
- * Everything in a block that a person could be looking for.
71
- *
72
- * A tool call is its name, its command and what came back, because all three
73
- * are things somebody searches a transcript for - the file a command touched
74
- * is in the output and nowhere else. A header is the model and the settings,
75
- * which is how "where did I switch to opus" is answered.
76
- */
77
- export declare function blockText(block: Block): string;
78
- /**
79
- * Where in the conversation a query appears, as block indices in order.
80
- *
81
- * Case-insensitive, and a blank query matches nothing rather than everything:
82
- * a find with no term is a find that has not been typed yet, and lighting up
83
- * every block for it is the opposite of what the box is for.
84
- */
85
- export declare function findBlocks(blocks: Block[], query: string): number[];
86
- /** Blocks the cursor stops on: the ones that do something when activated. */
87
- export declare function selectable(block: Block): boolean;
88
18
  export declare function toBlocks(turns: Turn[], queued?: QueuedMessage[]): Block[];
@@ -1,55 +1,17 @@
1
+ export { selectable } from '@textui/chat';
1
2
  /**
2
- * Everything in a block that a person could be looking for.
3
+ * A conversation, flattened into the rows a viewport scrolls.
3
4
  *
4
- * A tool call is its name, its command and what came back, because all three
5
- * are things somebody searches a transcript for - the file a command touched
6
- * is in the output and nowhere else. A header is the model and the settings,
7
- * which is how "where did I switch to opus" is answered.
8
- */
9
- export function blockText(block) {
10
- switch (block.kind) {
11
- case 'said':
12
- case 'queued':
13
- return block.text;
14
- case 'prose':
15
- case 'reasoning':
16
- case 'notice':
17
- case 'failure':
18
- return block.content;
19
- case 'header':
20
- return [block.model, block.settings, block.meta].filter(Boolean).join(' ');
21
- case 'tool':
22
- return [
23
- block.call.name, block.call.toolName, block.call.input,
24
- block.call.intention, block.call.outcome, block.call.output,
25
- ...(block.call.files ?? []),
26
- ].filter(Boolean).join(' ');
27
- }
28
- }
29
- /**
30
- * Where in the conversation a query appears, as block indices in order.
5
+ * A turn is not a box. It is a run of rows - a header, some prose, a tool
6
+ * call, more prose - and the transcript has to be able to put its cursor on
7
+ * one of them, measure it, and scroll to it. Nesting each turn inside a
8
+ * container would mean the transcript could only ever scroll to a whole turn,
9
+ * and a turn can be four hundred rows long.
31
10
  *
32
- * Case-insensitive, and a blank query matches nothing rather than everything:
33
- * a find with no term is a find that has not been typed yet, and lighting up
34
- * every block for it is the opposite of what the box is for.
11
+ * The order is the host's order. `responseParts` interleaves prose and calls
12
+ * in one stream, and "let me search for those" means something before the
13
+ * searches and nothing after them.
35
14
  */
36
- export function findBlocks(blocks, query) {
37
- const needle = query.trim().toLowerCase();
38
- if (needle === '')
39
- return [];
40
- const found = [];
41
- blocks.forEach((block, index) => {
42
- if (blockText(block).toLowerCase().includes(needle))
43
- found.push(index);
44
- });
45
- return found;
46
- }
47
- /** Blocks the cursor stops on: the ones that do something when activated. */
48
- export function selectable(block) {
49
- // A queued message among them, because taking one back is something you do
50
- // to it - and the cursor is how anything in the transcript is reached.
51
- return block.kind === 'tool' || block.kind === 'reasoning' || block.kind === 'queued';
52
- }
53
15
  export function toBlocks(turns, queued = []) {
54
16
  const blocks = [];
55
17
  for (const turn of turns) {
@@ -1,5 +1,5 @@
1
1
  /** Every command, and the argv reading that picks one. */
2
- export declare const HELP = "ahpc - drive an agent host from a shell\n\n ahpc [--host ws://\u2026] <command> [args] the screen is 'ahpc' with no command\n\nSessions\n session list the catalogue, newest first [--archived] [--json]\n session show <uri> what the host says about one [--full] [--json]\n session new start one [--agent P] [--cwd DIR] [--set k=v]\u2026 [--json]\n session rm <uri> dispose it\n session history <uri> its turns [--all] [--full] [--json]\n session config <uri> the schema and what is in force [--json]\n session set <uri> <k> <v> change one config key\n session read <uri> mark read [--unread]\n session archive <uri> put it away [--undo]\n session customizations <uri> skills, prompts, agents, servers [--json]\n session export <uri> the whole session as one document\n [--json] [--markdown]\n session toggle <uri> <id> turn one on [--off]\n\nTurns\n prompt <uri> <text> say it and stream the answer [--model M] [--json]\n exec <text> a session, one turn, and dispose it\n [--agent P] [--cwd DIR] [--model M] [--json]\n cancel <uri> stop the running turn\n queue <uri> <text> say it after the one running [--model M]\n unqueue <uri> <id> take it back\n\nAnswering\n watch <uri> BLOCK until something wants a person, print, exit\n [--until turn|input|idle] [--timeout S] [--json]\n confirm <uri> <toolCallId> approve a tool call [--deny] [--option ID]\n answer <uri> <requestId> answer a question [--field k=v]\u2026 [--reject]\n\nChats\n chat list <uri> the conversations in a session [--json]\n chat new <uri> [text] another one beside it\n chat rm <chatUri> close one\n\nThe harness\n agents what it serves, and each one's models [--json]\n models every model, by harness [--json]\n commands what a slash offers [--json]\n customizations skills, prompts, agents and MCP servers,\n before any session exists [--kind k] [--json]\n completions <uri> <text> what the host would complete [--offset N] [--json]\n\nChanges and files\n changes <uri> the files a session touched [--json]\n [--list] [--scope s] [--<variable> v]\n [--reviewed f] [--unreviewed f]\n [--operations] what may be done to it\n [--run id] [--file f] [--yes] do one of them\n [--list] every changeset it offers\n [--scope <name>] one of them, e.g. turn\n [--turnId <id>] what a chosen scope still needs\n [--reviewed <file>] tick one off, repeatable\n [--unreviewed <file>] and clear one\n content <uri> <file> one of them, in full\n resource list <uri> a directory the host serves [--json]\n resource read <uri> a file on the host\n resource stat <uri> what it is, without reading it [--json]\n resource write <uri> [file] from a file, or from stdin [--create-only]\n guarded by the file's etag unless [--force]\n resource rm <uri> delete it [--recursive]\n resource mkdir <uri> make a directory\n resource mv <uri> <to> move it [--fail-if-exists]\n resource cp <uri> <to> copy it [--fail-if-exists]\n\nAutomations\n automation list what runs on its own [--json]\n automation show <uri> one of them [--json]\n automation triggers what this host can trigger on [--json]\n automation runs <uri> its history, every page [--json]\n automation run <uri> start it now\n automation enable <uri> switch it on\n automation disable <uri> switch it off\n automation rm <uri> forget it\n\nThe host's own log\n logs what the daemon is saying [--level L] [--follow]\n\nSigning in\n auth what this host protects [--json]\n auth <resource> push a token [--token T] [--expires-in S]\n or set AHPC_TOKEN_<RESOURCE>, or pipe one in\n\nTerminals\n terminal list what is running [--json]\n terminal new open a shell [--cwd DIR] [--name N]\n terminal rm <uri> kill it\n terminal send <uri> <text> type into it\n terminal watch <uri> follow its output [--timeout S]\n\nRecording\n AHPC_RECORD=<file> append every frame, both directions, for\n 'npm run wire' to check against the protocol\n\nServing these sessions to something else\n mcp MCP on stdin and stdout, for a client that\n launches this process\n serve the same tools on a socket, shared\n [--serve-host H] [--serve-port N] [--serve-token T]\n /mcp is MCP; /api/<tool> is plain JSON\n\nAnything else\n dispatch <uri> <type> send one action verbatim [--field k=v]\u2026 [--chat]\n status what this client is connected to [--json]\n help this\n\nThe host\n --host <url> ws://host:port, or AHPC_HOST, or the config file\n --token <tkn> a bearer token for it, or AHPC_TOKEN, or the config file\n --config-file read this instead of the one below\n (none) the scripted host, which needs nothing installed\n\nConfiguration\n config where the file is, and what is in force [--json]\n\nOutput is for reading. --json is the same answer for a program.\n";
2
+ export declare const HELP = "ahpc - drive an agent host from a shell\n\n ahpc [--host ws://\u2026] <command> [args] the screen is 'ahpc' with no command\n\nSessions\n session list the catalogue, newest first [--archived] [--json]\n session show <uri> what the host says about one [--full] [--json]\n session new start one [--agent P] [--cwd DIR] [--set k=v]\u2026 [--json]\n session rm <uri> dispose it\n session history <uri> its turns [--all] [--full] [--json]\n session config <uri> the schema and what is in force [--json]\n session set <uri> <k> <v> change one config key\n session read <uri> mark read [--unread]\n session archive <uri> put it away [--undo]\n session customizations <uri> skills, prompts, agents, servers [--json]\n session export <uri> the whole session as one document\n [--json] [--markdown]\n session toggle <uri> <id> turn one on [--off]\n\nTurns\n prompt <uri> <text> say it and stream the answer [--model M] [--json]\n exec <text> a session, one turn, and dispose it\n [--agent P] [--cwd DIR] [--model M] [--json]\n cancel <uri> stop the running turn\n queue <uri> <text> say it after the one running [--model M]\n unqueue <uri> <id> take it back\n\nAnswering\n watch <uri> BLOCK until something wants a person, print, exit\n [--until turn|input|idle] [--timeout S] [--json]\n confirm <uri> <toolCallId> approve a tool call [--deny] [--option ID]\n answer <uri> <requestId> answer a question [--field k=v]\u2026 [--reject]\n\nChats\n chat list <uri> the conversations in a session [--json]\n chat new <uri> [text] another one beside it\n chat rm <chatUri> close one\n\nThe harness\n agents what it serves, and each one's models [--json]\n models every model, by harness [--json]\n commands what a slash offers [--json]\n customizations skills, prompts, agents and MCP servers,\n before any session exists [--kind k] [--json]\n completions <uri> <text> what the host would complete [--offset N] [--json]\n\nChanges and files\n changes <uri> the files a session touched [--json]\n [--list] [--scope s] [--<variable> v]\n [--reviewed f] [--unreviewed f]\n [--operations] what may be done to it\n [--run id] [--file f] [--yes] do one of them\n [--list] every changeset it offers\n [--scope <name>] one of them, e.g. turn\n [--turnId <id>] what a chosen scope still needs\n [--reviewed <file>] tick one off, repeatable\n [--unreviewed <file>] and clear one\n content <uri> <file> one of them, in full\n resource list <uri> a directory the host serves [--json]\n resource read <uri> a file on the host\n resource stat <uri> what it is, without reading it [--json]\n resource write <uri> [file] from a file, or from stdin [--create-only]\n guarded by the file's etag unless [--force]\n resource rm <uri> delete it [--recursive]\n resource mkdir <uri> make a directory\n resource mv <uri> <to> move it [--fail-if-exists]\n resource cp <uri> <to> copy it [--fail-if-exists]\n\nAutomations\n automation list what runs on its own [--json]\n automation show <uri> one of them [--json]\n automation triggers what this host can trigger on [--json]\n automation runs <uri> its history, every page [--json]\n automation run <uri> start it now\n automation enable <uri> switch it on\n automation disable <uri> switch it off\n automation rm <uri> forget it\n\nThe host's own log\n logs what the daemon is saying [--level L] [--follow]\n\nSigning in\n auth what this host protects [--json]\n auth <resource> push a token [--token T] [--expires-in S]\n or set AHPC_TOKEN_<RESOURCE>, or pipe one in\n\nTerminals\n terminal list what is running [--json]\n terminal new open a shell [--cwd DIR] [--name N]\n terminal rm <uri> kill it\n terminal send <uri> <text> type into it\n terminal watch <uri> follow its output [--timeout S]\n\nRecording\n --wire <file> append every frame, both directions, as JSON\n lines: { at, from, peer, frame }, for\n 'npm run wire' to check against the protocol.\n AHPC_RECORD=<file> is the same, from a shell\n wire <file> watch a capture as it is written, from either\n end: one row per frame, a row opens to the\n frame. Off a terminal, one line per frame\n [--follow] [--filter text] [--json]\n\nServing these sessions to something else\n mcp MCP on stdin and stdout, for a client that\n launches this process\n serve the same tools on a socket, shared\n [--serve-host H] [--serve-port N] [--serve-token T]\n [--serve-origin URL]\u2026 a browser page allowed in\n /mcp is MCP; /api/<tool> is plain JSON\n both take [--mcp-tools G,\u2026] extra tool groups to serve, on top of the\n sessions ones: resources, terminals,\n automations, changes\n\nAnything else\n dispatch <uri> <type> send one action verbatim [--field k=v]\u2026 [--chat]\n <uri>, anywhere above a session URI, or the agent-host-session://\n link the host's session tools answer with\n status what this client is connected to [--json]\n --version what version this is\n help this\n\nThe host\n --host <url> ws://host:port, or AHPC_HOST, or the config file\n --token <tkn> a bearer token for it, or AHPC_TOKEN, or the config file\n --config-file read this instead of the one below\n (none) the scripted host, which needs nothing installed\n\nConfiguration\n config where the file is, and what is in force [--json]\n\nOutput is for reading. --json is the same answer for a program.\n";
3
3
  /** A message for the person, not a stack trace. */
4
4
  export declare class Fault extends Error {
5
5
  }
@@ -5,8 +5,9 @@ import { configPath, loadConfig } from '../config.js';
5
5
  import { ago, archived, branch, json, line, mark, project, table } from './render.js';
6
6
  import { operate } from '../ahp/operate.js';
7
7
  import { SWITCHES } from '../flags.js';
8
+ import { parseSessionLink, sessionOfLink } from '../links.js';
8
9
  import { spoken, turn as runTurn, until } from '../wait.js';
9
- import { TOOLS } from '../mcp/tools.js';
10
+ import { GROUPS, served } from '../mcp/tools.js';
10
11
  import { SERVER } from '../mcp/serve.js';
11
12
  import { stdio } from '../mcp/stdio.js';
12
13
  import { serve as serveHttp } from '../mcp/http.js';
@@ -104,19 +105,32 @@ Terminals
104
105
  terminal watch <uri> follow its output [--timeout S]
105
106
 
106
107
  Recording
107
- AHPC_RECORD=<file> append every frame, both directions, for
108
- 'npm run wire' to check against the protocol
108
+ --wire <file> append every frame, both directions, as JSON
109
+ lines: { at, from, peer, frame }, for
110
+ 'npm run wire' to check against the protocol.
111
+ AHPC_RECORD=<file> is the same, from a shell
112
+ wire <file> watch a capture as it is written, from either
113
+ end: one row per frame, a row opens to the
114
+ frame. Off a terminal, one line per frame
115
+ [--follow] [--filter text] [--json]
109
116
 
110
117
  Serving these sessions to something else
111
118
  mcp MCP on stdin and stdout, for a client that
112
119
  launches this process
113
120
  serve the same tools on a socket, shared
114
121
  [--serve-host H] [--serve-port N] [--serve-token T]
122
+ [--serve-origin URL]… a browser page allowed in
115
123
  /mcp is MCP; /api/<tool> is plain JSON
124
+ both take [--mcp-tools G,…] extra tool groups to serve, on top of the
125
+ sessions ones: resources, terminals,
126
+ automations, changes
116
127
 
117
128
  Anything else
118
129
  dispatch <uri> <type> send one action verbatim [--field k=v]… [--chat]
130
+ <uri>, anywhere above a session URI, or the agent-host-session://
131
+ link the host's session tools answer with
119
132
  status what this client is connected to [--json]
133
+ --version what version this is
120
134
  help this
121
135
 
122
136
  The host
@@ -142,6 +156,24 @@ class Args {
142
156
  constructor(rest) {
143
157
  this.rest = rest;
144
158
  }
159
+ /** Every positional that is a link, replaced by what it names. */
160
+ async resolveLinks(resolve) {
161
+ const next = [...this.rest];
162
+ for (let i = 0; i < next.length; i++) {
163
+ const word = next[i];
164
+ if (word.startsWith('--')) {
165
+ if (!SWITCHES.has(word))
166
+ i++;
167
+ continue;
168
+ }
169
+ if (!/^agent-host-session:\/\//i.test(word))
170
+ continue;
171
+ const found = await resolve(word);
172
+ if (found !== undefined)
173
+ next[i] = found;
174
+ }
175
+ this.rest = next;
176
+ }
145
177
  /** The nth thing that is not a flag or a flag's value. */
146
178
  positional(index) {
147
179
  const found = [];
@@ -204,6 +236,7 @@ const where = (args) => {
204
236
  // write into it. Both off unless asked for.
205
237
  ...(args.value('--publish') ? { publish: args.value('--publish') } : {}),
206
238
  ...(args.has('--publish-writable') ? { publishWritable: true } : {}),
239
+ ...(args.value('--wire') ? { wire: args.value('--wire') } : {}),
207
240
  };
208
241
  };
209
242
  /** A URI the command needs, said plainly when it is missing. */
@@ -309,8 +342,53 @@ export async function cli(command, rest) {
309
342
  table(rows);
310
343
  return 0;
311
344
  }
345
+ /*
346
+ * A capture is a file, not a host.
347
+ *
348
+ * `ahpc wire out.jsonl` reads what `--wire` wrote - here or on the host -
349
+ * and needs no connection to do it; needing one would make it useless on
350
+ * exactly the capture of a host that would not answer.
351
+ */
352
+ if (command === 'wire') {
353
+ const file = args.positional(0);
354
+ if (file === undefined)
355
+ throw new Fault('wire wants a file: ahpc wire <file>');
356
+ const settings = loadConfig('ahpc', args.value('--config-file'));
357
+ const { wireTui } = await import('../wiretui.js');
358
+ const theme = args.value('--theme') ?? settings.theme;
359
+ const shell = args.value('--shell') ?? settings.shell;
360
+ const filter = args.value('--filter');
361
+ await wireTui({
362
+ file,
363
+ follow: args.has('--follow'),
364
+ json: wants,
365
+ ...(filter === undefined ? {} : { filter }),
366
+ ...(theme === undefined ? {} : { theme }),
367
+ ...(shell === undefined ? {} : { shell }),
368
+ });
369
+ return 0;
370
+ }
312
371
  const host = await connect(where(args));
313
372
  try {
373
+ /*
374
+ * A link where a URI is wanted.
375
+ *
376
+ * The reference host's session tools answer with `agent-host-session://`
377
+ * links, and a person pasting one into `session show` should get the
378
+ * session it names rather than a refusal about the scheme. Resolved once,
379
+ * against the catalogue, before any command reads its arguments; a link
380
+ * nobody on this host answers to is a sentence rather than a lookup that
381
+ * silently found nothing.
382
+ */
383
+ await args.resolveLinks(async (link) => {
384
+ const parsed = parseSessionLink(link);
385
+ if (parsed === undefined)
386
+ return undefined;
387
+ const row = sessionOfLink(parsed, await host.listSessions());
388
+ if (row === undefined)
389
+ throw new Fault(`No session on this host matches ${link}.`);
390
+ return row.resource;
391
+ });
314
392
  switch (command) {
315
393
  case 'status': {
316
394
  const rows = await host.listSessions().catch(() => []);
@@ -331,22 +409,27 @@ export async function cli(command, rest) {
331
409
  * `/api/<tool>` for anything that is not an MCP client.
332
410
  */
333
411
  case 'mcp': {
412
+ const groups = wanted(args);
334
413
  // Nothing but JSON-RPC on stdout, ever: a stray line here is a parse
335
414
  // error at the other end of a pipe nobody can see.
336
- process.stderr.write(`ahpc mcp on ${host.url || '(scripted host)'}, ${TOOLS.length} tools\n`);
337
- await stdio(host, { ...SERVER, onProblem: (said) => process.stderr.write(`${said}\n`) });
415
+ process.stderr.write(`ahpc mcp on ${host.url || '(scripted host)'}, ${served(groups).length} tools\n`);
416
+ await stdio(host, { ...SERVER, groups, onProblem: (said) => process.stderr.write(`${said}\n`) });
338
417
  return 0;
339
418
  }
340
419
  case 'serve': {
420
+ const groups = wanted(args);
341
421
  const at = await serveHttp(host, {
342
422
  ...SERVER,
423
+ groups,
343
424
  host: args.value('--serve-host') ?? '127.0.0.1',
344
425
  port: Number(args.value('--serve-port') ?? 7431),
345
426
  ...(args.value('--serve-token') === undefined ? {} : { token: args.value('--serve-token') }),
427
+ // Repeatable, because a page and its API are often two origins.
428
+ origins: args.every('--serve-origin'),
346
429
  onProblem: (said) => process.stderr.write(`${said}\n`),
347
430
  });
348
431
  line(`ahpc on http://${at.host}:${at.port} against ${host.url || '(scripted host)'}`);
349
- line(` /mcp MCP, ${TOOLS.length} tools`);
432
+ line(` /mcp MCP, ${served(groups).length} tools`);
350
433
  line(' /api/<tool> the same tools as plain JSON');
351
434
  if (args.value('--serve-token') === undefined && at.host !== '127.0.0.1' && at.host !== '::1') {
352
435
  // Said rather than refused: binding wide open is a decision somebody
@@ -997,6 +1080,26 @@ async function chats(host, args, wants) {
997
1080
  }
998
1081
  throw new Fault(`No 'chat ${verb}'. Try 'ahpc help'.`);
999
1082
  }
1083
+ /**
1084
+ * The tool groups `--mcp-tools` asked for.
1085
+ *
1086
+ * Comma-separated and repeatable, because both are what people type. Named
1087
+ * `--mcp-tools` and not `--tools`: every other flag on this client is an AHP
1088
+ * thing, and a bare `--tools` reads like one - it would look like it was
1089
+ * choosing which tools the *agent* may call, which is a different question
1090
+ * with a different answer.
1091
+ */
1092
+ function wanted(args) {
1093
+ const said = args.every('--mcp-tools')
1094
+ .flatMap((one) => one.split(','))
1095
+ .map((one) => one.trim())
1096
+ .filter((one) => one !== '');
1097
+ const strange = said.filter((one) => !GROUPS.includes(one));
1098
+ if (strange.length > 0) {
1099
+ throw new Fault(`No tool group called ${strange.join(', ')}. There is ${GROUPS.join(', ')}.`);
1100
+ }
1101
+ return said;
1102
+ }
1000
1103
  /** Everything under `terminal`. */
1001
1104
  async function shells(host, args, wants) {
1002
1105
  const verb = args.positional(0) ?? 'list';
@@ -29,6 +29,8 @@ export interface Where {
29
29
  publish?: string;
30
30
  /** Whether the published directory may be written to. Read-only otherwise. */
31
31
  publishWritable?: boolean;
32
+ /** A file every frame is appended to, both directions, as JSON lines. */
33
+ wire?: string;
32
34
  }
33
35
  /**
34
36
  * Where a refusal goes before there is an application to put it in.
@@ -27,6 +27,7 @@ export async function connect(options) {
27
27
  return await liveHost({
28
28
  url: options.host,
29
29
  ...(options.token ? { token: options.token } : {}),
30
+ ...(options.wire ? { wire: options.wire } : {}),
30
31
  onRefusal: (_uri, message) => sink.report(message),
31
32
  onLimit: (message) => sink.report(message),
32
33
  // Work the host is doing under a token of its own. Reported while it
@@ -17,6 +17,12 @@ import type { Agent, Answer, Automation, Changeset, ChangesetScope, ChatSource,
17
17
  export interface Controller {
18
18
  refresh(): Promise<void>;
19
19
  open(uri: SessionUri): void;
20
+ /**
21
+ * Open what an `agent-host-session://` link names: the session, and the
22
+ * chat in it when the link says one. False when this host has no such
23
+ * session, said out loud as well.
24
+ */
25
+ openLink(link: string): Promise<boolean>;
20
26
  close(): void;
21
27
  /** Send, or queue when a turn is already running. */
22
28
  send(text: string): void;