@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
@@ -3,24 +3,15 @@ import { defineComponent, useApp, useCapabilities, useEffect, useFocusScope, use
3
3
  import { Badge, Column, Divider, EmptyState, Field, Form, FormActions, Marquee, Panel, RadioGroup, Row, SearchBox, Select, TextInput, argumentOf, useForm } from '@textui/widgets';
4
4
  import { PRESETS, presetFor, scheduleProblem, zoneIsKnownHere } from './schedule.js';
5
5
  import { AUTOMATIONS_SCOPE, CHANGES_SCOPE, CHAT_SCOPE, CONTROLLER, MCP_SCOPE, SESSIONS_SCOPE, SKILLS_SCOPE, modelCommand, settingCommand, } from './control.js';
6
- import { branchName, branchDrift, ARCHIVED, AUTOMATIONS, AUTOMATION_ROW, CHANGES, CUSTOMIZATIONS, DRAFT, EXPANDED, FILTER, FOCUS, HISTORY, HOST, INPUT, CHANGE_AT, CHANGE_ROW, CHANGE_SCOPES, FILES_AT, FILES_ENTRIES, FILES_OPEN, MODEL, MODEL_CONFIG, OPEN, OPEN_FILE, CHAT_URI, PROVIDER, QUEUE, SELECTED, SESSIONS, SETTINGS, SIDEBAR, CHATS, CURSOR, FIND, FINDING, FIND_AT, OPEN_TERMINAL, PRESENT, SPLIT_AT, SPLIT_DEFAULT, TERMINAL, TERMINALS, TURNS, WORKSPACE, hiddenSessions, openSession, visibleSessions, workspaceName, } from './state.js';
7
- import { findBlocks, toBlocks } from './blocks.js';
6
+ import { branchName, branchDrift, pullRequestLabel, ARCHIVED, AUTOMATIONS, AUTOMATION_ROW, CHANGES, CUSTOMIZATIONS, DRAFT, EXPANDED, FILTER, FOCUS, HISTORY, HOST, INPUT, CHANGE_AT, CHANGE_ROW, CHANGE_SCOPES, FILES_AT, FILES_ENTRIES, FILES_OPEN, MODEL, MODEL_CONFIG, OPEN, OPEN_FILE, CHAT_URI, PROVIDER, QUEUE, SELECTED, SESSIONS, SETTINGS, SIDEBAR, CHATS, CURSOR, FIND, FINDING, FIND_AT, OPEN_TERMINAL, PRESENT, SPLIT_AT, SPLIT_DEFAULT, TERMINAL, TERMINALS, TURNS, WORKSPACE, ANSWERS, INPUT_STATUS, MARKDOWN, boodFloorFor, hiddenSessions, openSession, sessionView, visibleSessions, workspaceName, } from './state.js';
7
+ import { toBlocks } from './blocks.js';
8
8
  import { decodeStatus } from './ahp/status.js';
9
- import { ChatTranscript } from './view/transcript.js';
10
- import { ChatComposer } from './view/composer.js';
11
- import { ChatSessionHead } from './view/sessionhead.js';
12
- import { settingIcon, valueIcon } from './view/icons.js';
13
- import { ChatHitl, ChatInputStatus } from './view/hitl.js';
9
+ import { ChatComposer, ChatHitl, ChatInputStatus, ChatSessionHead, ChatTranscript, ConnectionBadge, FileDiff, SessionDetails, SessionList, diffLines, findBlocks, openPicker, settingIcon, valueIcon, } from '@textui/chat';
14
10
  import { ChangesList } from './view/changes.js';
15
11
  import { FileList } from './view/files.js';
16
12
  import { AutomationList } from './view/automations.js';
17
13
  import { CustomizationList } from './view/customizations.js';
18
14
  import { TerminalView } from './view/terminal.js';
19
- import { FileDiff } from './view/filediff.js';
20
- import { diffLines } from './diff.js';
21
- import { ConnectionBadge, SessionList } from './view/sessions.js';
22
- import { SessionDetails } from './view/details.js';
23
- import { openPicker } from './view/picker.js';
24
15
  /**
25
16
  * The screens.
26
17
  *
@@ -31,9 +22,19 @@ import { openPicker } from './view/picker.js';
31
22
  * command palette, a confirm - is a layer or an expansion inside one of these,
32
23
  * because none of them is a place you navigate *to*.
33
24
  *
34
- * Every screen is composition. The parts are in `view/`, the actions are in
35
- * `control.ts`, and what is left here is which part goes where.
25
+ * Every screen is composition. The chat parts are `@textui/chat`'s, the
26
+ * AHP-only ones are in `view/`, the actions are in `control.ts`, and what is
27
+ * left here is which part goes where.
36
28
  */
29
+ /**
30
+ * Keys the reference host seeds from the client's own settings and never asks
31
+ * about (`WELL_KNOWN_PICKER_PROPERTIES` in `agentHostChatInputPicker.ts`):
32
+ * how a worktree's branch is named and tracked, which ignored files come
33
+ * along, the shell's init script. Declared so the value rides in the config
34
+ * bag; drawn, they are five chips nobody can change, which is what "too many
35
+ * options" looked like.
36
+ */
37
+ const SEEDED = new Set(['worktreeBranchPrefix', 'worktreeBranchTrack', 'worktreeCreateNewBranch', 'worktreeIncludeFiles', 'shellInitScripts']);
37
38
  /**
38
39
  * Everything the catalogue knows about one session, as rows.
39
40
  *
@@ -80,7 +81,7 @@ function describe(session, detail) {
80
81
  // how the pane came to show a blank "Permissions" against a host whose key
81
82
  // for it is `autoApprove`.
82
83
  ...(detail?.config.properties ?? [])
83
- .filter((property) => property.values.length > 0)
84
+ .filter((property) => property.values.length > 0 && !SEEDED.has(property.key))
84
85
  .map((property) => ({
85
86
  id: `config.${property.key}`,
86
87
  label: property.title,
@@ -90,7 +91,7 @@ function describe(session, detail) {
90
91
  {
91
92
  id: 'branch',
92
93
  label: 'Branch',
93
- value: [branchName(session), branchDrift(session)].filter(Boolean).join(' '),
94
+ value: [branchName(session), branchDrift(session), pullRequestLabel(session)].filter(Boolean).join(' '),
94
95
  absent: 'not a repository, or the host does not say',
95
96
  },
96
97
  // The identifiers, in full and copyable. A URI you can read half of is
@@ -202,7 +203,7 @@ export const SessionsScreen = defineComponent('SessionsScreen', () => {
202
203
  return (_jsxs(Row, { flex: 1, gap: 1, children: [_jsxs(Panel, { title: "Sessions", ...(reading ? { width: aside } : { flex: 1 }), meta: waiting > 0 ? `${theme.glyphs.warning} ${waiting} waiting on you` : `${sessions.length} shown`, children: [_jsx(SearchBox, { value: filter, placeholder: "title, provider or workspace",
203
204
  // Named, so `ctrl+f` has something to focus. A control whose id
204
205
  // comes from its instance cannot be the target of a command.
205
- focusId: "chat.filter", onChange: (value) => app.store.set(FILTER, value) }), _jsx(SessionList, { sessions: sessions, selectedId: selected, focusId: "chat.sessions",
206
+ focusId: "chat.filter", onChange: (value) => app.store.set(FILTER, value) }), _jsx(SessionList, { sessions: sessions.map(sessionView), selectedId: selected, focusId: "chat.sessions",
206
207
  // The list, not the filter. Whatever registers first would
207
208
  // otherwise hold the keyboard on arrival, and the filter is drawn
208
209
  // above the list - which made every single-letter command a letter
@@ -341,6 +342,12 @@ function useHarnessCommands() {
341
342
  }, []);
342
343
  return items;
343
344
  }
345
+ /**
346
+ * The keys that say where a session runs, which get a row of their own, in
347
+ * the order they read: after the directory, whether the session sits in it
348
+ * or in a worktree of it, and then the branch a worktree starts from.
349
+ */
350
+ const WHERE = ['isolation', 'branch'];
344
351
  function useComposerOptions() {
345
352
  const unicode = useCapabilities().unicode;
346
353
  const controller = useRequiredService(CONTROLLER);
@@ -357,15 +364,27 @@ function useComposerOptions() {
357
364
  }, []);
358
365
  // Asking is also what registers a command per property, so the chips below
359
366
  // have something to open and the palette has the same questions in it.
367
+ // Asked again after every answer while there is no session yet, because
368
+ // the questions depend on the answers: `branch` is read-only until
369
+ // isolation is `worktree`, and the reference host rewrites the schema on
370
+ // `resolveSessionConfig` to say so. On what the values say rather than the
371
+ // object, since the host's echo of the same answers is a new object every
372
+ // time and would ask forever. And again when the directory changes, since
373
+ // isolation and the branches are questions about one repository. An open
374
+ // session's config arrives on its own channel, and asking for it after
375
+ // every change would answer with the value the host held before the
376
+ // change reached it.
377
+ const answered = open ? '' : JSON.stringify(settings);
360
378
  useEffect(() => {
361
379
  void controller.settings().then(setConfig)
362
380
  .catch((error) => controller.report(error));
363
- }, [provider, open]);
381
+ }, [provider, open, answered, open ? '' : workspace]);
364
382
  const agent = agents.find((found) => found.provider === provider);
365
383
  // A harness with no models is the ordinary answer for one nobody has signed
366
384
  // into, so the chip says so rather than opening on an empty list. Until the
367
385
  // catalogue has arrived there is no harness to say it about.
368
386
  const models = agent ? agent.models : null;
387
+ const fromConfig = fromConfigOf(unicode, config, models, model, settings, open);
369
388
  return [
370
389
  {
371
390
  id: 'harness',
@@ -412,42 +431,58 @@ function useComposerOptions() {
412
431
  commandId: modelCommand(property.key),
413
432
  };
414
433
  }),
415
- ...(config?.properties ?? [])
416
- .filter((property) => property.values.length > 0)
417
- // A key the model also asks about is the model's to answer: the session
418
- // key is the whole harness's default and the model's is what this
419
- // message runs at, and drawing both put two controls on one row
420
- // disagreeing about the same setting.
421
- .filter((property) => !(models?.find((found) => found.id === model)?.options ?? [])
422
- .some((one) => one.key === property.key))
423
- .map((property) => {
424
- const value = settings[property.key];
425
- const chosen = property.values.find((found) => found.value === value);
426
- return {
427
- id: property.key,
428
- // The value's own mark where it has one - which of five approval
429
- // modes is in force is the thing worth reading from the row itself.
430
- // The question's mark otherwise, so a branch chip is still a branch.
431
- icon: (value !== undefined
432
- ? valueIcon(unicode, value, chosen?.label)
433
- : undefined)
434
- ?? settingIcon(unicode, property.key, property.title),
435
- label: chosen?.label ?? value ?? property.title,
436
- // The question, for anything showing these with room for the pair.
437
- title: property.title,
438
- // Shown but not asked where the host says it cannot be changed on a
439
- // running session: offering it produces a refusal, not an edit.
440
- ...(open && !property.sessionMutable ? {} : { commandId: settingCommand(property.key) }),
441
- };
442
- }),
434
+ ...fromConfig.filter((option) => !option.where),
443
435
  {
444
436
  id: 'workspace',
445
437
  icon: settingIcon(unicode, 'workspace'),
446
438
  label: workspaceName(workspace ? `file://${workspace}` : undefined),
439
+ where: true,
447
440
  ...(open ? {} : { commandId: 'compose.workspace' }),
448
441
  },
442
+ ...fromConfig.filter((option) => option.where)
443
+ .sort((a, b) => WHERE.indexOf(a.id) - WHERE.indexOf(b.id)),
449
444
  ];
450
445
  }
446
+ /** The session's own questions, as chips. */
447
+ function fromConfigOf(unicode, config, models, model, settings, open) {
448
+ return (config?.properties ?? [])
449
+ // Something to choose from, or a host that said to ask it: `branch` is
450
+ // `enumDynamic` with its values behind `sessionConfigCompletions`, and
451
+ // a chip that needed the list up front never showed the one question
452
+ // that has more answers than a schema holds.
453
+ .filter((property) => (property.values.length > 0 || property.enumDynamic === true) && !SEEDED.has(property.key))
454
+ // A key the model also asks about is the model's to answer: the session
455
+ // key is the whole harness's default and the model's is what this
456
+ // message runs at, and drawing both put two controls on one row
457
+ // disagreeing about the same setting.
458
+ .filter((property) => !(models?.find((found) => found.id === model)?.options ?? [])
459
+ .some((one) => one.key === property.key))
460
+ // Shown but not asked where the host says it cannot be changed on a
461
+ // running session, or at all: offering it produces a refusal, not an
462
+ // edit. And a value that is neither askable nor known is nothing to
463
+ // draw: a read-only `branch` with no default is a chip saying "Branch".
464
+ .map((property) => ({ property, asked: !((open && !property.sessionMutable) || property.readOnly === true) }))
465
+ .filter(({ property, asked }) => asked || settings[property.key] !== undefined)
466
+ .map(({ property, asked }) => {
467
+ const value = settings[property.key];
468
+ const chosen = property.values.find((found) => found.value === value);
469
+ return {
470
+ id: property.key,
471
+ // The value's own mark where it has one - which of five approval
472
+ // modes is in force is the thing worth reading from the row itself.
473
+ // The question's mark otherwise, so a branch chip is still a branch.
474
+ icon: (value !== undefined
475
+ ? valueIcon(unicode, value, chosen?.label)
476
+ : undefined)
477
+ ?? settingIcon(unicode, property.key, property.title),
478
+ label: chosen?.label ?? value ?? property.title,
479
+ // The question, for anything showing these with room for the pair.
480
+ title: property.title,
481
+ ...(WHERE.includes(property.key) ? { where: true } : {}),
482
+ ...(asked ? { commandId: settingCommand(property.key) } : {}),
483
+ };
484
+ });
485
+ }
451
486
  // -------------------------------------------------------------------- 2. chat
452
487
  /**
453
488
  * What goes after a slash, from both places it can come from.
@@ -533,6 +568,12 @@ export const ChatScreen = defineComponent('ChatScreen', () => {
533
568
  const present = useStoreValue(PRESENT, []) ?? [];
534
569
  const chat = useStoreValue(CHAT_URI, null) ?? null;
535
570
  const running = turns.some((turn) => turn.state === 'running');
571
+ // What the components used to read for themselves. The switch, the row
572
+ // under the composer and the draft answers are this client's state; the
573
+ // components take them as props and know nothing of the paths.
574
+ const markdown = useStoreValue(MARKDOWN, true) ?? true;
575
+ const inputStatus = useStoreValue(INPUT_STATUS, null) ?? null;
576
+ const [answers, setAnswers] = useStore(`${ANSWERS}/${input?.id ?? 'none'}`, {});
536
577
  /*
537
578
  * Memoised, and the three below it with it, because of what the
538
579
  * transcript does with them.
@@ -593,7 +634,7 @@ export const ChatScreen = defineComponent('ChatScreen', () => {
593
634
  // What the caption is made of, as one value that can be compared. The
594
635
  // parts are rebuilt every render and the caption they describe is not.
595
636
  const headSignature = JSON.stringify([session, model, chat, settingRows, chats.length, reading]);
596
- const head = useMemo(() => (_jsxs(Column, { padding: [0, 0, 1, 0], children: [session ? (_jsx(ChatSessionHead, { session: session, present: present, ...(model ? { model } : {}), ...(chat ? { chat } : {}), settings: chats.length > 1
637
+ const head = useMemo(() => (_jsxs(Column, { padding: [0, 0, 1, 0], children: [session ? (_jsx(ChatSessionHead, { session: sessionView(session), present: present, ...(model ? { model } : {}), ...(chat ? { chat } : {}), settings: chats.length > 1
597
638
  // Where a session holds several, which one is being read is the
598
639
  // first thing somebody needs from the header - a transcript
599
640
  // that changed under the same title is otherwise unexplained.
@@ -669,7 +710,11 @@ export const ChatScreen = defineComponent('ChatScreen', () => {
669
710
  // The top of the conversation, inside it. See `head`.
670
711
  , {
671
712
  // The top of the conversation, inside it. See `head`.
672
- head: head, flex: 1, blocks: blocks, expanded: expanded, ...(finding && query.trim() !== '' ? { match: query.trim(), pinCursor: true } : {}), cursor: cursor ?? 0, onCursor: onCursor, onToggle: onToggle }), input ? (_jsx(ChatHitl, { input: input, onApprove: (option) => controller.approve(option), onDeny: () => controller.deny(), onAnswer: (answers, accepted) => controller.answer(answers, accepted),
713
+ head: head, flex: 1, blocks: blocks, expanded: expanded, ...(finding && query.trim() !== '' ? { match: query.trim(), pinCursor: true } : {}), cursor: cursor ?? 0, onCursor: onCursor, onToggle: onToggle, markdown: markdown }), input ? (_jsx(ChatHitl, { input: input, draft: answers ?? {}, onDraft: setAnswers,
714
+ // Where it starts, so the creature has somewhere to stand that is
715
+ // not on it. The block sits above the composer, so this is the
716
+ // floor while a question is up.
717
+ onMeasure: (rect) => app.store.set(boodFloorFor('ask'), rect?.y ?? 0), onApprove: (option) => controller.approve(option), onDeny: () => controller.deny(), onAnswer: (answers, accepted) => controller.answer(answers, accepted),
673
718
  // The block takes keys globally while it is up, escape included -
674
719
  // which is right for `a` and `d` and wrong for the one key that is
675
720
  // how you leave. Sent back to the transcript unconditionally it
@@ -688,7 +733,7 @@ export const ChatScreen = defineComponent('ChatScreen', () => {
688
733
  app.screens.pop();
689
734
  else
690
735
  app.focus.focus('chat.transcript');
691
- } })) : null, _jsx(ChatInputStatus, {}), _jsx(ChatComposer, { value: draft, running: running, queued: queued.length, options: options, onOption: (option, anchorId) => {
736
+ } })) : null, _jsx(ChatInputStatus, { status: inputStatus }), _jsx(ChatComposer, { value: draft, onMeasure: (rect) => app.store.set(boodFloorFor('composer'), rect?.y ?? 0), running: running, queued: queued.length, options: options, onOption: (option, anchorId) => {
692
737
  if (option.commandId)
693
738
  openPicker(app, { commandId: option.commandId, anchorId });
694
739
  }, commands: slashCommands(app, skills), paths: paths, onPath: (path) => {
@@ -741,7 +786,7 @@ export const NewSessionScreen = defineComponent('NewSessionScreen', () => {
741
786
  const options = useComposerOptions();
742
787
  const harnessSkills = useHarnessCommands();
743
788
  const newPaths = usePathCompletions(draft, 'ahp-root://');
744
- return (_jsxs(Column, { flex: 1, gap: 1, children: [_jsxs(Column, { flex: 1, justify: "center", align: "center", gap: 0, children: [_jsx("text", { content: "A new session", fg: "muted" }), _jsx("text", { content: "The first message is what starts it.", fg: "subtle" }), _jsx("text", { content: `${theme.glyphs.chevronLeft} esc for the sessions you already have`, fg: "subtle" })] }), _jsx(ChatComposer, { value: draft, options: options, onOption: (option, anchorId) => {
789
+ return (_jsxs(Column, { flex: 1, gap: 1, children: [_jsxs(Column, { flex: 1, justify: "center", align: "center", gap: 0, children: [_jsx("text", { content: "A new session", fg: "muted" }), _jsx("text", { content: "The first message is what starts it.", fg: "subtle" }), _jsx("text", { content: `${theme.glyphs.chevronLeft} esc for the sessions you already have`, fg: "subtle" })] }), _jsx(ChatComposer, { value: draft, onMeasure: (rect) => app.store.set(boodFloorFor('composer'), rect?.y ?? 0), options: options, onOption: (option, anchorId) => {
745
790
  if (option.commandId)
746
791
  openPicker(app, { commandId: option.commandId, anchorId });
747
792
  },
@@ -1,4 +1,5 @@
1
1
  import type { BindingPath, ReactiveStore } from '@textui/core';
2
+ import type { ChatSession } from '@textui/chat';
2
3
  import type { HostEvent } from './ahp/connection.js';
3
4
  import type { Changeset, PendingInput, QueuedMessage, SessionSummary, Turn } from './ahp/types.js';
4
5
  /**
@@ -237,6 +238,14 @@ export declare const CURSOR: BindingPath;
237
238
  * link's target rather than its label.
238
239
  */
239
240
  export declare const MARKDOWN: BindingPath;
241
+ /**
242
+ * Draft answers to the question the host is waiting on, one map per request.
243
+ *
244
+ * In the store, not in the block: AHP has an action for a draft answer
245
+ * precisely because another client may be looking at the same question, and
246
+ * a value only one box knows is one nobody else can see.
247
+ */
248
+ export declare const ANSWERS: BindingPath;
240
249
  /**
241
250
  * Whether the catalogue's detail pane is out, or `null` for "whatever the
242
251
  * terminal is wide enough for".
@@ -344,6 +353,39 @@ export declare function projectName(session: SessionSummary): string;
344
353
  * "the host does not say" against a host that was saying it all along.
345
354
  */
346
355
  export declare function branchName(session: SessionSummary): string | undefined;
356
+ /**
357
+ * A session as the components take it: decoded, and with nothing in it that
358
+ * only this client knows how to read.
359
+ *
360
+ * The status bitfield, the git metadata, the project name and the pull
361
+ * request are all AHP's business, and `@textui/chat` has no opinion about
362
+ * any of them.
363
+ */
364
+ export declare function sessionView(session: SessionSummary): ChatSession;
365
+ /** A pull request the session's branch is known by, and what became of it. */
366
+ export interface PullRequest {
367
+ /** The number in its URL, which is how a person names one. */
368
+ number: string;
369
+ /** The last state the host observed, where it observed one. */
370
+ state?: 'open' | 'closed' | 'merged';
371
+ }
372
+ /**
373
+ * The pull request the reference host found for this session, if it applies.
374
+ *
375
+ * `_meta.github` is the reference host's, the way `_meta.git` is: a
376
+ * convention read by its own window, not a declaration. `pullRequestUrls` is
377
+ * the history, most recent first, and only the most recent counts. Two
378
+ * checks are the host's own, and are kept here so a row never says more than
379
+ * the host would: a request found for another branch is not this branch's
380
+ * (`pullRequestBranchName`, when the host has recorded one), and a state is
381
+ * only the state of the URL it was observed on (`pullRequestStateUrl`) - the
382
+ * host keeps reporting the last request it knew while it looks for one on the
383
+ * branch the checkout moved to, and "merged" beside the wrong number is worse
384
+ * than no state at all.
385
+ */
386
+ export declare function pullRequest(session: SessionSummary): PullRequest | undefined;
387
+ /** The pull request as a row says it: the number, and its state where known. */
388
+ export declare function pullRequestLabel(session: SessionSummary): string | undefined;
347
389
  /**
348
390
  * How far the branch has drifted, where the host counted.
349
391
  *
package/dist/src/state.js CHANGED
@@ -234,6 +234,14 @@ export const CURSOR = '$/screen.chat/cursor';
234
234
  * link's target rather than its label.
235
235
  */
236
236
  export const MARKDOWN = '$/chat/ui/markdown';
237
+ /**
238
+ * Draft answers to the question the host is waiting on, one map per request.
239
+ *
240
+ * In the store, not in the block: AHP has an action for a draft answer
241
+ * precisely because another client may be looking at the same question, and
242
+ * a value only one box knows is one nobody else can see.
243
+ */
244
+ export const ANSWERS = '$/chat/ui/answers';
237
245
  /**
238
246
  * Whether the catalogue's detail pane is out, or `null` for "whatever the
239
247
  * terminal is wide enough for".
@@ -483,7 +491,9 @@ export function projectName(session) {
483
491
  * anything here, including a `git` that is not an object.
484
492
  */
485
493
  function git(session) {
486
- const found = session._meta?.git;
494
+ return objectAt(session._meta?.git);
495
+ }
496
+ function objectAt(found) {
487
497
  return typeof found === 'object' && found !== null && !Array.isArray(found)
488
498
  ? found
489
499
  : {};
@@ -511,6 +521,75 @@ export function branchName(session) {
511
521
  }
512
522
  return undefined;
513
523
  }
524
+ /**
525
+ * A session as the components take it: decoded, and with nothing in it that
526
+ * only this client knows how to read.
527
+ *
528
+ * The status bitfield, the git metadata, the project name and the pull
529
+ * request are all AHP's business, and `@textui/chat` has no opinion about
530
+ * any of them.
531
+ */
532
+ export function sessionView(session) {
533
+ const branch = branchName(session);
534
+ const pull = pullRequestLabel(session);
535
+ return {
536
+ id: session.resource,
537
+ title: session.title,
538
+ provider: session.provider,
539
+ status: decodeStatus(session.status),
540
+ createdAt: session.createdAt,
541
+ modifiedAt: session.modifiedAt,
542
+ workingDirectories: session.workingDirectories,
543
+ project: projectName(session),
544
+ ...(branch ? { branch } : {}),
545
+ ...(pull ? { pullRequest: pull } : {}),
546
+ ...(session.activity ? { activity: session.activity } : {}),
547
+ ...(session.origin?.kind === 'automation' ? { origin: 'by an automation' } : {}),
548
+ ...(session.changes ? { changes: session.changes } : {}),
549
+ };
550
+ }
551
+ /** The key two spellings of one pull request URL share: case and a trailing slash. */
552
+ const urlKey = (url) => url.trim().replace(/\/+$/, '').toLowerCase();
553
+ /**
554
+ * The pull request the reference host found for this session, if it applies.
555
+ *
556
+ * `_meta.github` is the reference host's, the way `_meta.git` is: a
557
+ * convention read by its own window, not a declaration. `pullRequestUrls` is
558
+ * the history, most recent first, and only the most recent counts. Two
559
+ * checks are the host's own, and are kept here so a row never says more than
560
+ * the host would: a request found for another branch is not this branch's
561
+ * (`pullRequestBranchName`, when the host has recorded one), and a state is
562
+ * only the state of the URL it was observed on (`pullRequestStateUrl`) - the
563
+ * host keeps reporting the last request it knew while it looks for one on the
564
+ * branch the checkout moved to, and "merged" beside the wrong number is worse
565
+ * than no state at all.
566
+ */
567
+ export function pullRequest(session) {
568
+ const found = objectAt(session._meta?.github);
569
+ const urls = Array.isArray(found.pullRequestUrls)
570
+ ? found.pullRequestUrls.filter((url) => typeof url === 'string')
571
+ : typeof found.pullRequestUrl === 'string' ? [found.pullRequestUrl] : [];
572
+ const url = urls[0];
573
+ if (url === undefined)
574
+ return undefined;
575
+ const branch = found.pullRequestBranchName;
576
+ if (typeof branch === 'string' && branch !== branchName(session))
577
+ return undefined;
578
+ const number = /\/pull\/(\d+)\/?$/.exec(url)?.[1];
579
+ if (number === undefined)
580
+ return undefined;
581
+ const state = found.pullRequestState;
582
+ const applies = typeof found.pullRequestStateUrl === 'string' && urlKey(found.pullRequestStateUrl) === urlKey(url);
583
+ return {
584
+ number,
585
+ ...(applies && (state === 'open' || state === 'closed' || state === 'merged') ? { state } : {}),
586
+ };
587
+ }
588
+ /** The pull request as a row says it: the number, and its state where known. */
589
+ export function pullRequestLabel(session) {
590
+ const found = pullRequest(session);
591
+ return found === undefined ? undefined : [`#${found.number}`, found.state].filter(Boolean).join(' ');
592
+ }
514
593
  /**
515
594
  * How far the branch has drifted, where the host counted.
516
595
  *
package/dist/src/tui.d.ts CHANGED
@@ -77,9 +77,11 @@ interface Options {
77
77
  publish?: string;
78
78
  /** Whether the host may write into it. Read-only otherwise. */
79
79
  publishWritable?: boolean;
80
+ /** A file every frame is appended to, both directions, as JSON lines. */
81
+ wire?: string;
80
82
  help: boolean;
81
83
  }
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";
84
+ 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\nSeeing the wire\n --wire <file> Append every frame, both directions, as JSON lines:\n { at, from, peer, frame }. AHPC_RECORD=<file> is\n the same thing from a shell.\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, by its URI or by an\n agent-host-session:// link\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
85
  export declare function parse(argv: string[]): Options;
84
86
  /**
85
87
  * The screen.
package/dist/src/tui.js CHANGED
@@ -2,6 +2,7 @@ import { WRITER_KEY, createApp } from '@textui/core';
2
2
  import { writeFile } from 'node:fs/promises';
3
3
  import { bufferToSvg, createNodeTerminal, createWriter, renderStill, } from '@textui/terminal';
4
4
  import { registerChat } from './app.js';
5
+ import { parseSessionLink } from './links.js';
5
6
  import { CONTROLLER } from './control.js';
6
7
  import { connect, sink } from './connect.js';
7
8
  import { loadConfig } from './config.js';
@@ -30,6 +31,11 @@ What this client serves back
30
31
  terminal attached this prints one frame and exits,
31
32
  so a background shell publishes nothing.
32
33
 
34
+ Seeing the wire
35
+ --wire <file> Append every frame, both directions, as JSON lines:
36
+ { at, from, peer, frame }. AHPC_RECORD=<file> is
37
+ the same thing from a shell.
38
+
33
39
  Appearance
34
40
  --theme <name> workbench, paper-light, ...
35
41
  --shell <name> The shell layout
@@ -37,7 +43,8 @@ Appearance
37
43
  --bood Let the creature loose on the whole screen. It
38
44
  keeps off the composer and off anything asking
39
45
  a question, and alt+g turns it off again.
40
- --session <uri> Open this session
46
+ --session <uri> Open this session, by its URI or by an
47
+ agent-host-session:// link
41
48
 
42
49
  Stills, for a README or a test
43
50
  --static, -s One frame to stdout instead of running
@@ -53,6 +60,7 @@ Stills, for a README or a test
53
60
  --approve Answer the confirmation the script stops at
54
61
  --answer ...and then the question
55
62
 
63
+ --version, -v What version this is
56
64
  --help, -h This
57
65
 
58
66
  Commands
@@ -147,6 +155,9 @@ export function parse(argv) {
147
155
  case '--publish-writable':
148
156
  options.publishWritable = true;
149
157
  break;
158
+ case '--wire':
159
+ options.wire = String(argv[++i]);
160
+ break;
150
161
  case '--help':
151
162
  case '-h':
152
163
  options.help = true;
@@ -197,10 +208,15 @@ async function still(options) {
197
208
  // of scripted words rather than all of them, and the caret is wherever the
198
209
  // agent had got to. `--settled` runs until the script has nothing left it
199
210
  // can do without being answered, which is how the confirmation is reached.
200
- before: (app) => {
211
+ before: async (app) => {
201
212
  const controller = app.services.require(CONTROLLER);
202
- if (options.session)
203
- controller.open(options.session);
213
+ // A URI, or the link the reference host's tools answer with.
214
+ if (options.session) {
215
+ if (parseSessionLink(options.session))
216
+ await controller.openLink(options.session);
217
+ else
218
+ controller.open(options.session);
219
+ }
204
220
  /*
205
221
  * Pushed when asked for, and not otherwise.
206
222
  *
@@ -345,6 +361,15 @@ export async function tui(argv) {
345
361
  app.services.provide(WRITER_KEY, createWriter(terminal.capabilities()));
346
362
  sink.report = (message) => reportHostError(app.store, message);
347
363
  await app.start();
364
+ // `--session`, on the screen as well as in a still: the conversation named,
365
+ // by its URI or by an `agent-host-session://` link, opened on arrival.
366
+ if (options.session) {
367
+ const controller = app.services.require(CONTROLLER);
368
+ const named = options.session;
369
+ const opened = parseSessionLink(named) ? controller.openLink(named) : (controller.open(named), Promise.resolve(true));
370
+ void opened.then((ok) => { if (ok)
371
+ app.screens.push('chat'); });
372
+ }
348
373
  /**
349
374
  * The last resort, and the reason it exists at all.
350
375
  *
@@ -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
+ };
@@ -43,18 +43,6 @@ export type { CreatureProps, Form, Mood } from './bood/index.js';
43
43
  * that matches the status row above it.
44
44
  */
45
45
  export declare function moodOf(activity: Activity): Mood;
46
- /**
47
- * Say which row this component starts at, so nothing stands on top of it.
48
- *
49
- * Its top row rather than its height: the row is where the thing actually is,
50
- * and a height has to be added to a guess about everything below it to mean
51
- * anything. Published rather than assumed because it moves - the composer
52
- * grows a slash menu upward and grows again with a wrapped draft, and the
53
- * block that asks about a tool is only there while something is waiting.
54
- * Cleared on the way out, or a screen that had one would keep making room for
55
- * it after it had gone.
56
- */
57
- export declare function useFloorTop(key: string, row: number): void;
58
46
  /**
59
47
  * One animal per run, rather than one per component that draws one.
60
48
  *
@@ -25,8 +25,6 @@
25
25
  * instead of being the big one shrunk.
26
26
  */
27
27
  import { BOOD, boodHeight, livelyNames } from './bood/index.js';
28
- import { useApp, useEffect } from '@textui/core';
29
- import { boodFloorFor } from '../state.js';
30
28
  export { BoodSprite, Creature, MOODS, FORMS, creatureFrames, creatureMotion, creatureSize, drawCreature, livelyNames, } from './bood/index.js';
31
29
  /**
32
30
  * What the figure is doing, from what the session is doing.
@@ -48,24 +46,6 @@ export function moodOf(activity) {
48
46
  : activity === 'input' ? 'thinking'
49
47
  : 'happy';
50
48
  }
51
- /**
52
- * Say which row this component starts at, so nothing stands on top of it.
53
- *
54
- * Its top row rather than its height: the row is where the thing actually is,
55
- * and a height has to be added to a guess about everything below it to mean
56
- * anything. Published rather than assumed because it moves - the composer
57
- * grows a slash menu upward and grows again with a wrapped draft, and the
58
- * block that asks about a tool is only there while something is waiting.
59
- * Cleared on the way out, or a screen that had one would keep making room for
60
- * it after it had gone.
61
- */
62
- export function useFloorTop(key, row) {
63
- const app = useApp();
64
- useEffect(() => {
65
- app.store.set(boodFloorFor(key), row);
66
- return () => app.store.set(boodFloorFor(key), 0);
67
- }, [key, row]);
68
- }
69
49
  /**
70
50
  * One animal per run, rather than one per component that draws one.
71
51
  *