@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,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.
@@ -41,6 +41,9 @@ export const COMMANDS = new Set([
41
41
  export const SWITCHES = new Set([
42
42
  // The screen's own.
43
43
  '--static', '-s', '--settled', '--approve', '--answer', '--bood', '--help', '-h',
44
+ // Answered by the entry point before either front end is loaded, and here
45
+ // so that neither swallows the word after it.
46
+ '--version', '-v',
44
47
  // Shape and scope, on both sides.
45
48
  '--json', '--full', '--all', '--archived', '--unread', '--undo',
46
49
  '--off', '--deny', '--reject', '--claude', '--chat',
@@ -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;
@@ -0,0 +1,120 @@
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
+ const LINK = /^agent-host-session:\/\/([^/?#]+)\/([^?#]+)(?:\?([^#]*))?(?:#.*)?$/i;
14
+ /** A link wherever it sits in prose: up to the whitespace or the bracket that ends it. */
15
+ const IN_TEXT = /agent-host-session:\/\/[^\s<>()\[\]"'`]+/gi;
16
+ const param = (query, name) => {
17
+ const found = new RegExp(`(?:^|&)${name}=([^&]*)`).exec(query)?.[1];
18
+ if (found === undefined)
19
+ return undefined;
20
+ try {
21
+ return decodeURIComponent(found);
22
+ }
23
+ catch {
24
+ return found;
25
+ }
26
+ };
27
+ /** The link read, or undefined for text that is not one. */
28
+ export function parseSessionLink(text) {
29
+ const found = LINK.exec(text.trim());
30
+ if (found === null)
31
+ return undefined;
32
+ let id;
33
+ try {
34
+ id = decodeURIComponent(found[2] ?? '');
35
+ }
36
+ catch {
37
+ id = found[2] ?? '';
38
+ }
39
+ if (id === '')
40
+ return undefined;
41
+ const query = found[3] ?? '';
42
+ const chatId = param(query, 'chat');
43
+ const turnId = param(query, 'turn');
44
+ return {
45
+ provider: found[1] ?? '',
46
+ id,
47
+ // The default chat is the session itself, which is how the reference
48
+ // host builds the link: it leaves the query off for it.
49
+ ...(chatId !== undefined && chatId !== '' && chatId !== 'default' ? { chatId } : {}),
50
+ ...(turnId !== undefined && turnId !== '' ? { turnId } : {}),
51
+ };
52
+ }
53
+ /** The id inside a session URI, whatever scheme it is under: `ahp-session:/x` and `claude:/x` are both `x`. */
54
+ export const idOf = (uri) => {
55
+ const colon = uri.indexOf(':');
56
+ return (colon < 0 ? uri : uri.slice(colon + 1)).replace(/^\/+/, '');
57
+ };
58
+ /**
59
+ * The row a link names.
60
+ *
61
+ * Matched on the id and the provider, whichever scheme the row is under: a
62
+ * session this client started is `ahp-session:/` and one read off the host's
63
+ * catalogue is `<provider>:/`, and the link says neither.
64
+ */
65
+ export const sessionOfLink = (link, rows) => rows.find((row) => idOf(row.resource) === link.id && row.provider === link.provider)
66
+ ?? rows.find((row) => idOf(row.resource) === link.id);
67
+ /**
68
+ * Every link in the transcript, once each, in the order they appear.
69
+ *
70
+ * Read from what a person can see: the prose, the tool calls' outcomes and
71
+ * outputs, and the host's notices. A tool answering `create_session` puts the
72
+ * link in its output, which is where somebody reading the transcript finds it.
73
+ */
74
+ export function linksIn(turns) {
75
+ const found = [];
76
+ const seen = new Set();
77
+ const take = (text, turnId) => {
78
+ if (!text)
79
+ return;
80
+ for (const match of text.matchAll(IN_TEXT)) {
81
+ const link = match[0].replace(/[.,;:!?]+$/, '');
82
+ if (seen.has(link) || parseSessionLink(link) === undefined)
83
+ continue;
84
+ seen.add(link);
85
+ const from = Math.max(0, (match.index ?? 0) - 40);
86
+ const context = text.slice(from, match.index ?? 0).replace(/\s+/g, ' ').trim();
87
+ found.push({ link, context, turnId });
88
+ }
89
+ };
90
+ for (const turn of turns) {
91
+ take(turn.message, turn.id);
92
+ for (const part of turn.parts) {
93
+ if (part.kind === 'markdown' || part.kind === 'systemNotification')
94
+ take(part.content, turn.id);
95
+ else if (part.kind === 'toolCall') {
96
+ take(part.call.outcome, turn.id);
97
+ take(part.call.output, turn.id);
98
+ }
99
+ }
100
+ }
101
+ return found;
102
+ }
103
+ /**
104
+ * Whether a chat URI is the chat a link names.
105
+ *
106
+ * The id is the URI's authority where it has one - `ahp-chat://<chatId>/<session>`,
107
+ * which is how the reference host and ahpd spell a chat - and the path
108
+ * otherwise, `ahp-chat:/<id>`, which is the protocol's own shape.
109
+ */
110
+ export const chatMatches = (chatUri, chatId) => {
111
+ const found = /^[^:]+:\/\/([^/]+)\//.exec(chatUri)?.[1] ?? idOf(chatUri);
112
+ if (found === '')
113
+ return false;
114
+ try {
115
+ return decodeURIComponent(found) === chatId;
116
+ }
117
+ catch {
118
+ return found === chatId;
119
+ }
120
+ };
package/dist/src/main.js CHANGED
@@ -12,8 +12,20 @@
12
12
  // does not load a front end, which is the reason they were copied here.
13
13
  import { commandIn } from './flags.js';
14
14
  const argv = process.argv.slice(2);
15
- const first = commandIn(argv);
16
- if (first !== undefined) {
15
+ /*
16
+ * Answered before anything is loaded.
17
+ *
18
+ * `--version` with no command would otherwise open the screen, which is a
19
+ * question answered by a whole renderer starting up and then being read off a
20
+ * status bar. Neither front end is imported to answer it.
21
+ */
22
+ const asked = argv.includes('--version') || argv.includes('-v');
23
+ const first = asked ? undefined : commandIn(argv);
24
+ if (asked) {
25
+ const { version } = await import('./version.js');
26
+ process.stdout.write(`${version()}\n`);
27
+ }
28
+ else if (first !== undefined) {
17
29
  /*
18
30
  * A closed pipe is not an error.
19
31
  *
@@ -12,6 +12,16 @@ export interface ServeOptions {
12
12
  * token is what makes binding anywhere else defensible.
13
13
  */
14
14
  token?: string;
15
+ /**
16
+ * Browser origins allowed to reach this, beyond its own.
17
+ *
18
+ * Empty is the safe default and the usual answer: a shell script, a webhook
19
+ * and an MCP client send no `Origin` at all, so nothing legitimate is turned
20
+ * away by allowing none. This is for a page somebody serves themselves.
21
+ */
22
+ origins?: readonly string[];
23
+ /** The opt-in tool groups to serve, beyond the core table. */
24
+ groups?: readonly string[];
15
25
  onProblem?(said: string): void;
16
26
  }
17
27
  /** What this is listening on, and how to stop it. */
@@ -12,7 +12,7 @@
12
12
  * client that launched it, this is shared and outlives any of them.
13
13
  */
14
14
  import { createServer } from 'node:http';
15
- import { answer, call, listing } from './serve.js';
15
+ import { SPOKEN, answer, call, listing } from './serve.js';
16
16
  /** How much of a request body is read before it is refused, in bytes. */
17
17
  const LIMIT = 1_000_000;
18
18
  const body = async (request) => {
@@ -24,6 +24,39 @@ const body = async (request) => {
24
24
  }
25
25
  return read;
26
26
  };
27
+ /**
28
+ * Answer a request with a stream rather than one object.
29
+ *
30
+ * The transport allows either, and this is the branch that lets anything be
31
+ * said before the result: notifications go out as they happen and the response
32
+ * is the last event, after which the stream closes. Only opened where the
33
+ * caller asked for progress, because a stream is worse for everybody else -
34
+ * more to parse, and a connection held open for a request that answers at once.
35
+ */
36
+ const stream = (response) => {
37
+ response.writeHead(200, {
38
+ 'content-type': 'text/event-stream',
39
+ 'cache-control': 'no-cache, no-transform',
40
+ connection: 'keep-alive',
41
+ });
42
+ const event = (value) => { response.write(`data: ${JSON.stringify(value)}\n\n`); };
43
+ return {
44
+ event,
45
+ end: (value) => { event(value); response.end(); },
46
+ };
47
+ };
48
+ /**
49
+ * Whether this request asked to be told what is happening while it waits.
50
+ *
51
+ * A `progressToken` in `_meta`, which is the only way a caller asks. Read here
52
+ * rather than inside `answer`, because the decision it drives - a stream or an
53
+ * object - has to be made before a single byte of the response is written.
54
+ */
55
+ const wantsProgress = (message) => {
56
+ const params = message.params;
57
+ const token = params?._meta?.progressToken;
58
+ return typeof token === 'string' || typeof token === 'number';
59
+ };
27
60
  const send = (response, code, value) => {
28
61
  const text = JSON.stringify(value);
29
62
  response.writeHead(code, {
@@ -44,6 +77,52 @@ const allowed = (request, token) => {
44
77
  const said = request.headers.authorization;
45
78
  return typeof said === 'string' && said.trim() === `Bearer ${token}`;
46
79
  };
80
+ /**
81
+ * Whether a browser may talk to this.
82
+ *
83
+ * The transport says a server **MUST** validate `Origin` to stop DNS
84
+ * rebinding, and the reason it says so is that binding to loopback is not the
85
+ * protection it looks like: a page on any website can POST to
86
+ * `http://127.0.0.1:7431` from inside the browser of the person running this,
87
+ * and the request arrives from their own machine looking exactly like theirs.
88
+ * Without this, opening a tab would be enough to drive every session on the
89
+ * host.
90
+ *
91
+ * No header at all is allowed. A browser always sends one on a request like
92
+ * these; a program does not, and refusing those would refuse every real
93
+ * caller to guard against a thing that cannot happen.
94
+ */
95
+ const sameOrigin = (request, url, extra) => {
96
+ const said = request.headers.origin;
97
+ if (typeof said !== 'string' || said === '')
98
+ return true;
99
+ if (extra.includes(said))
100
+ return true;
101
+ try {
102
+ const from = new URL(said);
103
+ // The host and port this request came in on, whatever name was used to
104
+ // reach it - `localhost` and `127.0.0.1` are the same server and a person
105
+ // typing either should not be told no.
106
+ return from.host === url.host;
107
+ }
108
+ catch {
109
+ return false;
110
+ }
111
+ };
112
+ /**
113
+ * Whether this can speak the version the client says it is using.
114
+ *
115
+ * The transport says an invalid or unsupported `MCP-Protocol-Version` **MUST**
116
+ * be a 400. Absent is not unsupported: the same paragraph says to assume
117
+ * `2025-03-26` where there is no header, which is a client from before it
118
+ * existed.
119
+ */
120
+ const speakable = (request) => {
121
+ const said = request.headers['mcp-protocol-version'];
122
+ if (said === undefined)
123
+ return true;
124
+ return typeof said === 'string' && SPOKEN.includes(said);
125
+ };
47
126
  /** Start listening. Answers once the socket is up. */
48
127
  export async function serve(host, options) {
49
128
  const server = createServer((request, response) => {
@@ -51,6 +130,18 @@ export async function serve(host, options) {
51
130
  try {
52
131
  const url = new URL(request.url ?? '/', `http://${request.headers.host ?? 'localhost'}`);
53
132
  const path = url.pathname.replace(/\/+$/, '') || '/';
133
+ if (!sameOrigin(request, url, options.origins ?? [])) {
134
+ // 403 rather than 401: a token would not make this request
135
+ // acceptable, so inviting one would be the wrong thing to say.
136
+ send(response, 403, { error: `This server does not serve requests from ${String(request.headers.origin)}.` });
137
+ return;
138
+ }
139
+ if (!speakable(request)) {
140
+ send(response, 400, {
141
+ error: `This server does not speak MCP ${String(request.headers['mcp-protocol-version'])}. It speaks ${SPOKEN.join(', ')}.`,
142
+ });
143
+ return;
144
+ }
54
145
  if (!allowed(request, options.token)) {
55
146
  send(response, 401, { error: 'This server needs a bearer token.' });
56
147
  return;
@@ -59,7 +150,7 @@ export async function serve(host, options) {
59
150
  // what to call. Every tool with its schema, which is also what an MCP
60
151
  // client gets from `tools/list`.
61
152
  if (request.method === 'GET' && (path === '/api' || path === '/')) {
62
- send(response, 200, listing());
153
+ send(response, 200, listing(options.groups));
63
154
  return;
64
155
  }
65
156
  if (path === '/mcp') {
@@ -77,6 +168,22 @@ export async function serve(host, options) {
77
168
  send(response, 400, { jsonrpc: '2.0', id: null, error: { code: -32700, message: 'That is not JSON.' } });
78
169
  return;
79
170
  }
171
+ if (wantsProgress(message)) {
172
+ const open = stream(response);
173
+ const reply = await answer(host, message, {
174
+ ...options,
175
+ notify: (notification) => open.event({ jsonrpc: '2.0', ...notification }),
176
+ });
177
+ // A notification that also carried a progress token has nothing to
178
+ // end the stream with, so it is closed rather than left open on a
179
+ // response that is never coming.
180
+ if (reply === undefined) {
181
+ response.end();
182
+ return;
183
+ }
184
+ open.end(reply);
185
+ return;
186
+ }
80
187
  const reply = await answer(host, message, options);
81
188
  // A notification is answered with 202 and no body, which is what the
82
189
  // transport says and what a client waiting on one would hang over.
@@ -105,7 +212,7 @@ export async function serve(host, options) {
105
212
  return;
106
213
  }
107
214
  }
108
- const result = await call(host, name, input);
215
+ const result = await call(host, name, input, undefined, options.groups);
109
216
  // Shaped for a program rather than for a model: the answer itself,
110
217
  // or the refusal as an error, without MCP's content envelope around
111
218
  // it. A caller that wants the envelope has `/mcp`.