@softov/ahpc 0.3.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 (60) hide show
  1. package/README.md +6 -4
  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 +93 -37
  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 +75 -2
  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 +1 -1
  17. package/dist/src/links.d.ts +54 -0
  18. package/dist/src/links.js +120 -0
  19. package/dist/src/resources.d.ts +13 -0
  20. package/dist/src/resources.js +46 -0
  21. package/dist/src/screens.js +95 -50
  22. package/dist/src/state.d.ts +42 -0
  23. package/dist/src/state.js +80 -1
  24. package/dist/src/tui.d.ts +3 -1
  25. package/dist/src/tui.js +28 -4
  26. package/dist/src/view/creature.d.ts +0 -12
  27. package/dist/src/view/creature.js +0 -20
  28. package/dist/src/view/wire.d.ts +36 -0
  29. package/dist/src/view/wire.js +196 -0
  30. package/dist/src/wire.d.ts +70 -0
  31. package/dist/src/wire.js +194 -0
  32. package/dist/src/wiretui.d.ts +22 -0
  33. package/dist/src/wiretui.js +69 -0
  34. package/package.json +6 -5
  35. package/dist/src/diff.d.ts +0 -44
  36. package/dist/src/diff.js +0 -111
  37. package/dist/src/view/bubble.d.ts +0 -75
  38. package/dist/src/view/bubble.js +0 -86
  39. package/dist/src/view/composer.d.ts +0 -64
  40. package/dist/src/view/composer.js +0 -192
  41. package/dist/src/view/controls.d.ts +0 -44
  42. package/dist/src/view/controls.js +0 -49
  43. package/dist/src/view/details.d.ts +0 -65
  44. package/dist/src/view/details.js +0 -65
  45. package/dist/src/view/filediff.d.ts +0 -29
  46. package/dist/src/view/filediff.js +0 -24
  47. package/dist/src/view/hitl.d.ts +0 -43
  48. package/dist/src/view/hitl.js +0 -171
  49. package/dist/src/view/icons.d.ts +0 -13
  50. package/dist/src/view/icons.js +0 -71
  51. package/dist/src/view/picker.d.ts +0 -42
  52. package/dist/src/view/picker.js +0 -71
  53. package/dist/src/view/sessionhead.d.ts +0 -41
  54. package/dist/src/view/sessionhead.js +0 -60
  55. package/dist/src/view/sessions.d.ts +0 -34
  56. package/dist/src/view/sessions.js +0 -61
  57. package/dist/src/view/toolcall.d.ts +0 -27
  58. package/dist/src/view/toolcall.js +0 -48
  59. package/dist/src/view/transcript.d.ts +0 -50
  60. package/dist/src/view/transcript.js +0 -60
@@ -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 [--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 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";
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,6 +5,7 @@ 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
10
  import { GROUPS, served } from '../mcp/tools.js';
10
11
  import { SERVER } from '../mcp/serve.js';
@@ -104,8 +105,14 @@ 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
@@ -120,6 +127,8 @@ Serving these sessions to something else
120
127
 
121
128
  Anything else
122
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
123
132
  status what this client is connected to [--json]
124
133
  --version what version this is
125
134
  help this
@@ -147,6 +156,24 @@ class Args {
147
156
  constructor(rest) {
148
157
  this.rest = rest;
149
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
+ }
150
177
  /** The nth thing that is not a flag or a flag's value. */
151
178
  positional(index) {
152
179
  const found = [];
@@ -209,6 +236,7 @@ const where = (args) => {
209
236
  // write into it. Both off unless asked for.
210
237
  ...(args.value('--publish') ? { publish: args.value('--publish') } : {}),
211
238
  ...(args.has('--publish-writable') ? { publishWritable: true } : {}),
239
+ ...(args.value('--wire') ? { wire: args.value('--wire') } : {}),
212
240
  };
213
241
  };
214
242
  /** A URI the command needs, said plainly when it is missing. */
@@ -314,8 +342,53 @@ export async function cli(command, rest) {
314
342
  table(rows);
315
343
  return 0;
316
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
+ }
317
371
  const host = await connect(where(args));
318
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
+ });
319
392
  switch (command) {
320
393
  case 'status': {
321
394
  const rows = await host.listSessions().catch(() => []);
@@ -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;
@@ -3,12 +3,13 @@ import { spawn } from 'node:child_process';
3
3
  import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
4
4
  import { tmpdir } from 'node:os';
5
5
  import { join } from 'node:path';
6
- import { confirm } from '@textui/widgets';
7
- import { findBlocks, toBlocks } from './blocks.js';
6
+ import { confirm, pick } from '@textui/widgets';
7
+ import { findBlocks, valueIcon } from '@textui/chat';
8
+ import { toBlocks } from './blocks.js';
9
+ import { chatMatches, linksIn, parseSessionLink, sessionOfLink } from './links.js';
8
10
  import { operate } from './ahp/operate.js';
9
11
  import { createTerminals } from './terminal.js';
10
12
  import { SessionFlag } from './ahp/types.js';
11
- import { valueIcon } from './view/icons.js';
12
13
  import { ARCHIVED, BOOD_FLOAT, CAN_ADD_CHAT, CAN_FORK, CAN_SIDE_CHAT, CHAT_URI, CHATS, CUSTOMIZATIONS, CURSOR, DRAFT, EXPANDED, FILTER, FIND, FINDING, FIND_AT, HAS_CHATS, HOST, HOST_ERROR, INPUT, MODEL, MODEL_CONFIG, OPEN_TERMINAL, AUTOMATIONS, AUTOMATION_ROW, CHANGES as CHANGES_AT_PATH, CHANGE_AT, CHANGE_ROW, CHANGE_SCOPES, FILES_AT, FILES_OPEN, OPEN, OPEN_FILE, PROVIDER, MARKDOWN, QUEUE, RUNNING, SCREEN, SELECTED, SETTINGS, SIDEBAR, SPLIT_AT, SPLIT_DEFAULT, TURNS, WORKSPACE, applyEvent, inputRefused, pendingInput, queue, reportHostError, sendingInput, sessions, turns, writeSessions, writeStatus, } from './state.js';
13
14
  export const CONTROLLER = serviceKey('chat.controller');
14
15
  /** The command that asks about one config key. Registered when a host offers it. */
@@ -427,6 +428,50 @@ export function createController(app, host, bindings) {
427
428
  if (next)
428
429
  controller.openChat(next);
429
430
  },
431
+ async openLink(link) {
432
+ const parsed = parseSessionLink(link);
433
+ if (parsed === undefined) {
434
+ failed(new Error(`${link} is not an agent-host-session:// link`));
435
+ return false;
436
+ }
437
+ // The catalogue as held, then as the host has it now: a session the
438
+ // agent made a moment ago may not have reached the list yet.
439
+ let row = sessionOfLink(parsed, sessions(app.store));
440
+ if (row === undefined) {
441
+ await controller.refresh();
442
+ row = sessionOfLink(parsed, sessions(app.store));
443
+ }
444
+ if (row === undefined) {
445
+ failed(new Error(`No session on this host matches ${link}`));
446
+ return false;
447
+ }
448
+ controller.open(row.resource);
449
+ if (parsed.chatId === undefined)
450
+ return true;
451
+ /*
452
+ * The chat, once the session has said which chats it has.
453
+ *
454
+ * The list arrives with the snapshot, a moment after `open`, and a chat
455
+ * URI is not something to build here - the host names them. Waited for,
456
+ * briefly; a chat the session no longer has is the session itself,
457
+ * which is the reference window's answer too.
458
+ */
459
+ const wanted = parsed.chatId;
460
+ for (let i = 0; i < 40; i++) {
461
+ if (app.store.get(OPEN) !== row.resource)
462
+ return true;
463
+ // Not before the default chat is known: `open` sets it when the
464
+ // detail lands, and a switch made earlier is a switch it undoes.
465
+ const settled = app.store.get(CHAT_URI) !== null;
466
+ const chat = (app.store.get(CHATS) ?? []).find((entry) => chatMatches(entry.resource, wanted));
467
+ if (settled && chat !== undefined) {
468
+ controller.openChat(chat.resource);
469
+ return true;
470
+ }
471
+ await new Promise((resolve) => { setTimeout(resolve, 50); });
472
+ }
473
+ return true;
474
+ },
430
475
  open(uri) {
431
476
  // Closing drops this consumer only. Unsubscribing the channel to shed a
432
477
  // duplicate is what silently kills the stream everything else reads.
@@ -805,6 +850,29 @@ connection) {
805
850
  */
806
851
  const provider = () => app.store.get(PROVIDER) ?? 'claude';
807
852
  const agent = () => known.agents.find((found) => found.provider === provider());
853
+ /**
854
+ * The keys of a session's config that are about its directory.
855
+ *
856
+ * The host's own, named in the reference client's `sessionConfigKeys.ts`:
857
+ * whether to work in a worktree, from which branch, and how that worktree
858
+ * is made. An answer to any of them is an answer about one repository.
859
+ */
860
+ const ABOUT_WHERE = ['isolation', 'branch', 'worktreeBranchPrefix', 'worktreeBranchTrack', 'worktreeCreateNewBranch', 'worktreeIncludeFiles'];
861
+ /**
862
+ * Point the next session somewhere else.
863
+ *
864
+ * What was answered about the old directory goes with it: `branch: main`
865
+ * chosen in one repository would be sent back to the host as the answer
866
+ * for the next, and the host echoes what it is told - so the composer
867
+ * would show a branch the new repository may not have, and start from it.
868
+ * The rest of the answers are about the harness, not the directory, and
869
+ * stay.
870
+ */
871
+ const workspace = (path) => {
872
+ app.store.set(WORKSPACE, path);
873
+ const held = app.store.get(SETTINGS) ?? {};
874
+ app.store.set(SETTINGS, Object.fromEntries(Object.entries(held).filter(([key]) => !ABOUT_WHERE.includes(key))));
875
+ };
808
876
  const listAgents = async () => {
809
877
  try {
810
878
  known.agents = await controller.agents();
@@ -1078,6 +1146,39 @@ connection) {
1078
1146
  slots: ['palette'],
1079
1147
  run: () => { app.screens.reset('new'); app.focus.focus('chat.composer'); },
1080
1148
  },
1149
+ {
1150
+ /*
1151
+ * A link in the transcript, followed.
1152
+ *
1153
+ * The reference host's session tools answer with an
1154
+ * `agent-host-session://` link, and its window makes one a click. A
1155
+ * terminal has no click, so the links in the open transcript are the
1156
+ * choices, and one chosen opens the session or the chat it names. One
1157
+ * typed is followed the same way, which is how a link from somewhere
1158
+ * else gets opened.
1159
+ */
1160
+ id: 'chat.openLink',
1161
+ title: 'Open a session link',
1162
+ category: 'Session',
1163
+ description: 'Follow an agent-host-session:// link in this transcript',
1164
+ slots: ['palette'],
1165
+ when: `${OPEN}`,
1166
+ args: [{
1167
+ name: 'link',
1168
+ type: 'string',
1169
+ required: true,
1170
+ description: 'The link to follow',
1171
+ choices: () => linksIn(app.store.get(TURNS) ?? []).map((found) => ({
1172
+ value: found.link,
1173
+ label: found.link.replace(/^agent-host-session:\/\//, ''),
1174
+ ...(found.context ? { description: found.context } : {}),
1175
+ })),
1176
+ }],
1177
+ run: (args) => {
1178
+ void controller.openLink(String(args.link ?? '')).then((opened) => { if (opened)
1179
+ app.screens.push('chat'); });
1180
+ },
1181
+ },
1081
1182
  {
1082
1183
  id: 'go.changes',
1083
1184
  title: 'What this session changed',
@@ -1610,7 +1711,7 @@ connection) {
1610
1711
  id: 'terminal.new',
1611
1712
  title: 'Open a terminal',
1612
1713
  category: 'Terminal',
1613
- description: 'Start a shell on the host, in a directory it serves',
1714
+ description: 'Start a shell on the host, in the workspace directory',
1614
1715
  slots: ['palette'],
1615
1716
  run: () => {
1616
1717
  const where = app.store.get(WORKSPACE);
@@ -1684,13 +1785,41 @@ connection) {
1684
1785
  id: 'compose.workspace',
1685
1786
  title: 'Workspace',
1686
1787
  category: 'Compose',
1687
- description: 'Select the workspace',
1788
+ description: 'Choose the directory the session works in, from the host\'s own',
1789
+ slots: ['palette'],
1790
+ when: `!${OPEN}`,
1791
+ /*
1792
+ * Picked by looking, the way the reference client picks a folder: it
1793
+ * lists the host's directories over `resourceList`, and so does this,
1794
+ * through the `file:` provider `registerChat` mounts. The dialog is
1795
+ * textui's, which walks the resource registry and never the disk this
1796
+ * client is on - a path typed blind was the only way before, and it
1797
+ * had to be a path on a machine you could not see.
1798
+ *
1799
+ * Started where the chip already points, when the host will list it;
1800
+ * a directory off the catalogue otherwise, which is somewhere on the
1801
+ * host's machine that is known to exist.
1802
+ */
1803
+ run: async () => {
1804
+ const held = app.store.get(WORKSPACE) ?? '';
1805
+ const at = `file://${held}`;
1806
+ const served = sessions(app.store).flatMap((one) => one.workingDirectories)[0];
1807
+ const listable = held !== '' && await controller.files(at).then(() => true, () => false);
1808
+ const start = listable ? at : served ?? at;
1809
+ const picked = await pick(app, { start, wants: 'directory', title: 'Workspace', placeholder: 'Filter this directory…' });
1810
+ if (picked !== null)
1811
+ workspace(decodeURIComponent(picked.replace(/^file:\/\//, '')));
1812
+ },
1813
+ },
1814
+ {
1815
+ id: 'compose.workspace.path',
1816
+ title: 'Workspace path',
1817
+ category: 'Compose',
1818
+ description: 'Type the directory the session works in',
1688
1819
  slots: ['palette'],
1689
1820
  when: `!${OPEN}`,
1690
- // No `choices`, so the palette asks for it as text - the same overlay,
1691
- // with its field as the answer rather than as a filter. Give it a
1692
- // `choices` function later and the same chip becomes a list of
1693
- // workspaces without anything else changing.
1821
+ // For a host that lists nothing - one without the `resource*` family -
1822
+ // and for a path already known, which is quicker typed than walked to.
1694
1823
  args: [{
1695
1824
  name: 'path',
1696
1825
  type: 'string',
@@ -1700,7 +1829,7 @@ connection) {
1700
1829
  run: (args) => {
1701
1830
  const path = String(args.path ?? '').trim();
1702
1831
  if (path)
1703
- app.store.set(WORKSPACE, path);
1832
+ workspace(path);
1704
1833
  },
1705
1834
  },
1706
1835
  {
@@ -2195,6 +2324,7 @@ function shipped() {
2195
2324
  // out of it is escape - the pair that makes every other letter reachable.
2196
2325
  { keys: 'c', commandId: 'go.changes', scopeId: CHAT_SCOPE },
2197
2326
  { keys: 'f', commandId: 'go.files', scopeId: CHAT_SCOPE },
2327
+ { keys: 'l', commandId: 'chat.openLink', scopeId: CHAT_SCOPE },
2198
2328
  /*
2199
2329
  * On the changes screen, and nowhere else.
2200
2330
  *
package/dist/src/flags.js CHANGED
@@ -27,7 +27,7 @@ export const COMMANDS = new Set([
27
27
  'agents', 'models', 'commands', 'customizations', 'completions', 'changes', 'content',
28
28
  'prompt', 'exec', 'cancel', 'queue', 'unqueue',
29
29
  'watch', 'confirm', 'answer', 'dispatch',
30
- 'mcp', 'serve',
30
+ 'mcp', 'serve', 'wire',
31
31
  ]);
32
32
  /**
33
33
  * Every flag that takes no value, in either front end.
@@ -0,0 +1,54 @@
1
+ /**
2
+ * `agent-host-session://` links, which are how one session names another.
3
+ *
4
+ * The reference host's session tools answer with one - `create_session`,
5
+ * `list_sessions` and `send_message` each put an `openLink` in their result -
6
+ * and its window turns the link into a click that opens the session or the
7
+ * chat. A terminal has no click, so here the same link is something to pick:
8
+ * the links in the open transcript are offered as a list, and one chosen
9
+ * opens what it names. The shape is the reference host's
10
+ * (`common/openSessionLink.ts`): `agent-host-session://<provider>/<id>`, with
11
+ * `?chat=<chatId>` for one chat of it and `&turn=<turnId>` for one turn.
12
+ */
13
+ import type { SessionSummary, Turn } from './ahp/types.js';
14
+ export interface SessionLink {
15
+ provider: string;
16
+ id: string;
17
+ chatId?: string;
18
+ turnId?: string;
19
+ }
20
+ /** The link read, or undefined for text that is not one. */
21
+ export declare function parseSessionLink(text: string): SessionLink | undefined;
22
+ /** The id inside a session URI, whatever scheme it is under: `ahp-session:/x` and `claude:/x` are both `x`. */
23
+ export declare const idOf: (uri: string) => string;
24
+ /**
25
+ * The row a link names.
26
+ *
27
+ * Matched on the id and the provider, whichever scheme the row is under: a
28
+ * session this client started is `ahp-session:/` and one read off the host's
29
+ * catalogue is `<provider>:/`, and the link says neither.
30
+ */
31
+ export declare const sessionOfLink: (link: SessionLink, rows: SessionSummary[]) => SessionSummary | undefined;
32
+ /** One link found in a transcript, and where. */
33
+ export interface FoundLink {
34
+ link: string;
35
+ /** What was around it, for the row that offers it. */
36
+ context: string;
37
+ turnId: string;
38
+ }
39
+ /**
40
+ * Every link in the transcript, once each, in the order they appear.
41
+ *
42
+ * Read from what a person can see: the prose, the tool calls' outcomes and
43
+ * outputs, and the host's notices. A tool answering `create_session` puts the
44
+ * link in its output, which is where somebody reading the transcript finds it.
45
+ */
46
+ export declare function linksIn(turns: Turn[]): FoundLink[];
47
+ /**
48
+ * Whether a chat URI is the chat a link names.
49
+ *
50
+ * The id is the URI's authority where it has one - `ahp-chat://<chatId>/<session>`,
51
+ * which is how the reference host and ahpd spell a chat - and the path
52
+ * otherwise, `ahp-chat:/<id>`, which is the protocol's own shape.
53
+ */
54
+ export declare const chatMatches: (chatUri: string, chatId: string) => boolean;