@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.
- package/README.md +37 -6
- package/dist/src/ahp/fake.js +61 -14
- package/dist/src/ahp/live.d.ts +7 -0
- package/dist/src/ahp/live.js +156 -43
- package/dist/src/ahp/publish.js +13 -0
- package/dist/src/ahp/types.d.ts +20 -0
- package/dist/src/app.js +34 -7
- package/dist/src/blocks.d.ts +4 -74
- package/dist/src/blocks.js +10 -48
- package/dist/src/cli/main.d.ts +1 -1
- package/dist/src/cli/main.js +109 -6
- package/dist/src/connect.d.ts +2 -0
- package/dist/src/connect.js +1 -0
- package/dist/src/control.d.ts +6 -0
- package/dist/src/control.js +140 -10
- package/dist/src/flags.js +4 -1
- package/dist/src/links.d.ts +54 -0
- package/dist/src/links.js +120 -0
- package/dist/src/main.js +14 -2
- package/dist/src/mcp/http.d.ts +10 -0
- package/dist/src/mcp/http.js +110 -3
- package/dist/src/mcp/serve.d.ts +47 -9
- package/dist/src/mcp/serve.js +65 -14
- package/dist/src/mcp/stdio.d.ts +1 -0
- package/dist/src/mcp/stdio.js +7 -1
- package/dist/src/mcp/tools.d.ts +37 -2
- package/dist/src/mcp/tools.js +439 -2
- package/dist/src/resources.d.ts +13 -0
- package/dist/src/resources.js +46 -0
- package/dist/src/screens.js +95 -50
- package/dist/src/state.d.ts +42 -0
- package/dist/src/state.js +80 -1
- package/dist/src/tui.d.ts +3 -1
- package/dist/src/tui.js +29 -4
- package/dist/src/version.d.ts +2 -0
- package/dist/src/version.js +38 -0
- package/dist/src/view/creature.d.ts +0 -12
- package/dist/src/view/creature.js +0 -20
- package/dist/src/view/wire.d.ts +36 -0
- package/dist/src/view/wire.js +196 -0
- package/dist/src/wait.d.ts +11 -0
- package/dist/src/wait.js +15 -2
- package/dist/src/wire.d.ts +70 -0
- package/dist/src/wire.js +194 -0
- package/dist/src/wiretui.d.ts +22 -0
- package/dist/src/wiretui.js +69 -0
- package/package.json +6 -5
- package/dist/src/diff.d.ts +0 -44
- package/dist/src/diff.js +0 -111
- package/dist/src/view/bubble.d.ts +0 -75
- package/dist/src/view/bubble.js +0 -86
- package/dist/src/view/composer.d.ts +0 -64
- package/dist/src/view/composer.js +0 -192
- package/dist/src/view/controls.d.ts +0 -44
- package/dist/src/view/controls.js +0 -49
- package/dist/src/view/details.d.ts +0 -65
- package/dist/src/view/details.js +0 -65
- package/dist/src/view/filediff.d.ts +0 -29
- package/dist/src/view/filediff.js +0 -24
- package/dist/src/view/hitl.d.ts +0 -43
- package/dist/src/view/hitl.js +0 -171
- package/dist/src/view/icons.d.ts +0 -13
- package/dist/src/view/icons.js +0 -71
- package/dist/src/view/picker.d.ts +0 -42
- package/dist/src/view/picker.js +0 -71
- package/dist/src/view/sessionhead.d.ts +0 -41
- package/dist/src/view/sessionhead.js +0 -60
- package/dist/src/view/sessions.d.ts +0 -34
- package/dist/src/view/sessions.js +0 -61
- package/dist/src/view/toolcall.d.ts +0 -27
- package/dist/src/view/toolcall.js +0 -48
- package/dist/src/view/transcript.d.ts +0 -50
- package/dist/src/view/transcript.js +0 -60
package/dist/src/control.js
CHANGED
|
@@ -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,
|
|
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
|
|
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: '
|
|
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
|
-
//
|
|
1691
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
16
|
-
|
|
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
|
*
|
package/dist/src/mcp/http.d.ts
CHANGED
|
@@ -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. */
|
package/dist/src/mcp/http.js
CHANGED
|
@@ -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`.
|