@zgeoff/atc 2.8.2 → 2.9.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 +5 -0
- package/package.json +6 -3
- package/src/agents/agent-adapter.ts +3 -1
- package/src/cli.ts +114 -4
- package/src/clients.ts +82 -0
- package/src/collect-redirect-uris.ts +16 -0
- package/src/daemon/build-fleet-events.ts +9 -0
- package/src/daemon/build-message-trail-entry.ts +54 -0
- package/src/daemon/build-report-trail-entry.ts +25 -0
- package/src/daemon/build-session-message-event.ts +0 -1
- package/src/daemon/daemon.ts +72 -11
- package/src/daemon/sessions.ts +13 -1
- package/src/daemon/start-headless-run.ts +3 -8
- package/src/daemon/start-headless-turn.ts +15 -3
- package/src/daemon/truncate-summary.ts +9 -0
- package/src/grants.ts +51 -0
- package/src/mcp/answer-authorize-request.ts +98 -0
- package/src/mcp/answer-consent-request.ts +166 -0
- package/src/mcp/answer-login-request.ts +108 -0
- package/src/mcp/answer-mcp-request.ts +161 -0
- package/src/mcp/answer-rpc-request.ts +137 -0
- package/src/mcp/approval-state.ts +155 -0
- package/src/mcp/build-consent-binding.ts +13 -0
- package/src/mcp/build-owner-plugin.ts +122 -0
- package/src/mcp/build-page-response.ts +29 -0
- package/src/mcp/build-tool-list.ts +21 -0
- package/src/mcp/collect-clients.ts +31 -0
- package/src/mcp/collect-grants.ts +55 -0
- package/src/mcp/collect-json-strings.ts +17 -0
- package/src/mcp/derive-token-hash.ts +9 -0
- package/src/mcp/find-client-name.ts +19 -0
- package/src/mcp/find-owner-session-id.ts +18 -0
- package/src/mcp/is-allowed-redirect-uri.ts +21 -0
- package/src/mcp/is-loopback-host.ts +13 -0
- package/src/mcp/is-supported-protocol-version.ts +13 -0
- package/src/mcp/mcp-tools.ts +278 -0
- package/src/mcp/mint-approval-code.ts +13 -0
- package/src/mcp/normalize-approval-code.ts +8 -0
- package/src/mcp/normalize-public-url.ts +33 -0
- package/src/mcp/open-mcp-auth.ts +145 -0
- package/src/mcp/pick-error-message.ts +22 -0
- package/src/mcp/pick-protocol-version.ts +9 -0
- package/src/mcp/reconnecting-caller.ts +121 -0
- package/src/mcp/remove-client.ts +36 -0
- package/src/mcp/render-consent-page.ts +65 -0
- package/src/mcp/render-login-page.ts +47 -0
- package/src/mcp/revoke-grant.ts +28 -0
- package/src/mcp/run-tool.ts +143 -0
- package/src/mcp/start-mcp-http-server.ts +330 -0
- package/src/mcp/to-html-text.ts +12 -0
- package/src/mcp/types.ts +78 -0
- package/src/mcp/verify-oauth-query.ts +42 -0
- package/src/mcp-http-server.ts +75 -0
- package/src/mcp-server.ts +22 -450
- package/src/parse-port.ts +18 -0
- package/src/shared/collect-mcp-http-config.ts +46 -0
- package/src/shared/config.ts +3 -1
- package/src/shared/grant-scope.ts +8 -0
- package/src/shared/load-mcp-http-config.ts +20 -0
- package/src/shared/normalize-client-name.ts +28 -0
- package/src/store/state-store.ts +65 -4
- package/src/store/trail-entry.ts +27 -0
package/README.md
CHANGED
|
@@ -130,6 +130,11 @@ installs in detail.
|
|
|
130
130
|
A Claude or Codex session that is already running loads the server only after it restarts. A
|
|
131
131
|
running Grok session loads it when you press `r` in its `/mcps` list.
|
|
132
132
|
|
|
133
|
+
- `atc mcp --http` serves the same tools over HTTP behind OAuth, so a hosted assistant such as
|
|
134
|
+
ChatGPT or Claude.ai can reach your sessions with the scopes you approve. Add each assistant with
|
|
135
|
+
`atc clients add`, approve each connection with the code printed in the server's terminal, and
|
|
136
|
+
list or revoke grants with `atc grants`. [Remote MCP](./docs/architecture/remote-mcp.md) covers
|
|
137
|
+
it.
|
|
133
138
|
- A Claude session takes messages from other tools. `atc_session_message` queues one, and the
|
|
134
139
|
session reads it in a new turn, or inside the turn it is running. `atc_message_get` returns the
|
|
135
140
|
reply once that turn ends. The [protocol](./docs/architecture/protocol.md#messages) covers the
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zgeoff/atc",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.9.0",
|
|
4
4
|
"description": "Terminal control tower for coding-agent sessions",
|
|
5
5
|
"homepage": "https://github.com/zgeoff/atc#readme",
|
|
6
6
|
"bugs": "https://github.com/zgeoff/atc/issues",
|
|
@@ -37,20 +37,23 @@
|
|
|
37
37
|
},
|
|
38
38
|
"dependencies": {
|
|
39
39
|
"@anthropic-ai/claude-agent-sdk": "0.3.222",
|
|
40
|
+
"@better-auth/mcp": "1.7.6",
|
|
41
|
+
"@better-auth/oauth-provider": "1.7.6",
|
|
40
42
|
"@xterm/addon-serialize": "0.14.0",
|
|
41
43
|
"@xterm/headless": "6.0.0",
|
|
44
|
+
"better-auth": "1.7.6",
|
|
42
45
|
"bun-pty": "0.4.10",
|
|
43
46
|
"citty": "0.2.2",
|
|
44
47
|
"kysely": "0.29.5",
|
|
45
48
|
"ts-pattern": "5.9.0",
|
|
46
49
|
"xstate": "5.32.5",
|
|
47
|
-
"zod": "4.
|
|
50
|
+
"zod": "4.6.5"
|
|
48
51
|
},
|
|
49
52
|
"devDependencies": {
|
|
50
53
|
"@commitlint/cli": "21.2.0",
|
|
51
54
|
"@stylistic/eslint-plugin": "5.10.0",
|
|
52
55
|
"@tsconfig/strictest": "2.0.8",
|
|
53
|
-
"@types/bun": "1.
|
|
56
|
+
"@types/bun": "1.4.2",
|
|
54
57
|
"@zgeoff/bun-test-extended": "0.0.4",
|
|
55
58
|
"@zgeoff/format-codemod": "0.0.8",
|
|
56
59
|
"@zgeoff/oxlint-config": "0.0.6",
|
|
@@ -106,7 +106,9 @@ interface HeadlessRunRequest {
|
|
|
106
106
|
|
|
107
107
|
interface HeadlessRunEvents {
|
|
108
108
|
readonly onOutput: (text: string) => void;
|
|
109
|
-
|
|
109
|
+
|
|
110
|
+
// The turn's whole final message.
|
|
111
|
+
readonly onDone: (result: string) => void;
|
|
110
112
|
readonly onNeedsYou: (msg: string) => void;
|
|
111
113
|
}
|
|
112
114
|
|
package/src/cli.ts
CHANGED
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
// `statusline` are the commands injected into wrangled sessions.
|
|
3
3
|
import { defineCommand, runMain } from 'citty';
|
|
4
4
|
import pkg from '../package.json';
|
|
5
|
+
import { collectRedirectURIs } from './collect-redirect-uris';
|
|
6
|
+
import { parsePort } from './parse-port';
|
|
5
7
|
import { getBuild } from './shared/get-build';
|
|
6
8
|
|
|
7
9
|
const main = defineCommand({
|
|
@@ -27,12 +29,120 @@ const main = defineCommand({
|
|
|
27
29
|
defineCommand({
|
|
28
30
|
meta: {
|
|
29
31
|
name: 'mcp',
|
|
30
|
-
description:
|
|
32
|
+
description:
|
|
33
|
+
'Run an MCP server exposing the fleet as tools: over stdio, or with --http behind OAuth',
|
|
31
34
|
},
|
|
32
|
-
|
|
33
|
-
|
|
35
|
+
args: {
|
|
36
|
+
http: {
|
|
37
|
+
type: 'boolean',
|
|
38
|
+
default: false,
|
|
39
|
+
description: 'Serve MCP over HTTP behind OAuth',
|
|
40
|
+
},
|
|
41
|
+
host: {
|
|
42
|
+
type: 'string',
|
|
43
|
+
description: 'Address to bind with --http (default 127.0.0.1)',
|
|
44
|
+
},
|
|
45
|
+
port: { type: 'string', description: 'Port to listen on with --http (default 8414)' },
|
|
46
|
+
'public-url': {
|
|
47
|
+
type: 'string',
|
|
48
|
+
description:
|
|
49
|
+
'Origin clients reach the --http server at, such as https://mcp.example.com',
|
|
50
|
+
},
|
|
51
|
+
},
|
|
52
|
+
async run(ctx) {
|
|
53
|
+
if (!ctx.args.http) {
|
|
54
|
+
const server = await import('./mcp-server');
|
|
55
|
+
|
|
56
|
+
await server.runMCPServer(getBuild());
|
|
57
|
+
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const port = ctx.args.port === undefined ? null : parsePort(ctx.args.port);
|
|
62
|
+
|
|
63
|
+
if (port !== null && !port.ok) {
|
|
64
|
+
console.error(`atc mcp --http: ${port.message}`);
|
|
65
|
+
process.exit(1);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const http = await import('./mcp-http-server');
|
|
69
|
+
|
|
70
|
+
await http.runMCPHTTPServer(getBuild(), {
|
|
71
|
+
host: ctx.args.host ?? null,
|
|
72
|
+
port: port === null ? null : port.port,
|
|
73
|
+
publicURL: ctx.args['public-url'] ?? null,
|
|
74
|
+
});
|
|
75
|
+
},
|
|
76
|
+
}),
|
|
77
|
+
clients: () =>
|
|
78
|
+
defineCommand({
|
|
79
|
+
meta: {
|
|
80
|
+
name: 'clients',
|
|
81
|
+
description: 'List, add, or remove the clients that may connect to atc mcp --http',
|
|
82
|
+
},
|
|
83
|
+
default: 'list',
|
|
84
|
+
subCommands: {
|
|
85
|
+
list: () =>
|
|
86
|
+
defineCommand({
|
|
87
|
+
meta: { name: 'list', description: 'List the clients', hidden: true },
|
|
88
|
+
async run() {
|
|
89
|
+
const clients = await import('./clients');
|
|
90
|
+
|
|
91
|
+
await clients.runClients({ kind: 'list' });
|
|
92
|
+
},
|
|
93
|
+
}),
|
|
94
|
+
add: () =>
|
|
95
|
+
defineCommand({
|
|
96
|
+
meta: { name: 'add', description: 'Add a client and print its client ID' },
|
|
97
|
+
args: {
|
|
98
|
+
name: { type: 'positional', required: true, description: 'The client name' },
|
|
99
|
+
'redirect-uri': {
|
|
100
|
+
type: 'string',
|
|
101
|
+
required: true,
|
|
102
|
+
description: 'A redirect URI the client returns to; repeat for more',
|
|
103
|
+
},
|
|
104
|
+
},
|
|
105
|
+
async run(ctx) {
|
|
106
|
+
const clients = await import('./clients');
|
|
107
|
+
|
|
108
|
+
await clients.runClients({
|
|
109
|
+
kind: 'add',
|
|
110
|
+
name: ctx.args.name,
|
|
111
|
+
redirectURIs: collectRedirectURIs(ctx.rawArgs),
|
|
112
|
+
});
|
|
113
|
+
},
|
|
114
|
+
}),
|
|
115
|
+
remove: () =>
|
|
116
|
+
defineCommand({
|
|
117
|
+
meta: {
|
|
118
|
+
name: 'remove',
|
|
119
|
+
description: 'Remove a client and revoke every grant it holds',
|
|
120
|
+
},
|
|
121
|
+
args: {
|
|
122
|
+
id: { type: 'positional', required: true, description: 'The client ID' },
|
|
123
|
+
},
|
|
124
|
+
async run(ctx) {
|
|
125
|
+
const clients = await import('./clients');
|
|
126
|
+
|
|
127
|
+
await clients.runClients({ kind: 'remove', clientID: ctx.args.id });
|
|
128
|
+
},
|
|
129
|
+
}),
|
|
130
|
+
},
|
|
131
|
+
}),
|
|
132
|
+
grants: () =>
|
|
133
|
+
defineCommand({
|
|
134
|
+
meta: {
|
|
135
|
+
name: 'grants',
|
|
136
|
+
description:
|
|
137
|
+
'List the grants atc mcp --http clients hold, or revoke one with --revoke <id>',
|
|
138
|
+
},
|
|
139
|
+
args: {
|
|
140
|
+
revoke: { type: 'string', description: 'The ID of a grant to revoke' },
|
|
141
|
+
},
|
|
142
|
+
async run(ctx) {
|
|
143
|
+
const grants = await import('./grants');
|
|
34
144
|
|
|
35
|
-
await
|
|
145
|
+
await grants.runGrants(ctx.args.revoke ?? null);
|
|
36
146
|
},
|
|
37
147
|
}),
|
|
38
148
|
daemon: () =>
|
package/src/clients.ts
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { mkdirSync } from 'node:fs';
|
|
2
|
+
import { collectClients } from './mcp/collect-clients';
|
|
3
|
+
import { isAllowedRedirectURI } from './mcp/is-allowed-redirect-uri';
|
|
4
|
+
import { openMCPAuth } from './mcp/open-mcp-auth';
|
|
5
|
+
import { removeClient } from './mcp/remove-client';
|
|
6
|
+
import { mcpAuthDBFile, stateDir } from './shared/config';
|
|
7
|
+
import { normalizeClientName } from './shared/normalize-client-name';
|
|
8
|
+
|
|
9
|
+
type ClientsAction =
|
|
10
|
+
| { readonly kind: 'list' }
|
|
11
|
+
| { readonly kind: 'add'; readonly name: string; readonly redirectURIs: readonly string[] }
|
|
12
|
+
| { readonly kind: 'remove'; readonly clientID: string };
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Runs `atc clients`: lists the clients that may connect to `atc mcp --http`,
|
|
16
|
+
* adds one, or removes one along with every token and consent it holds. It
|
|
17
|
+
* opens the authorization server's database directly, so it works whether or
|
|
18
|
+
* not the server is running.
|
|
19
|
+
*/
|
|
20
|
+
export async function runClients(action: ClientsAction): Promise<void> {
|
|
21
|
+
if (action.kind === 'add') {
|
|
22
|
+
const refused = action.redirectURIs.find((uri) => !isAllowedRedirectURI(uri));
|
|
23
|
+
|
|
24
|
+
if (action.redirectURIs.length === 0 || refused !== undefined) {
|
|
25
|
+
const message =
|
|
26
|
+
refused === undefined
|
|
27
|
+
? 'atc clients add: give at least one --redirect-uri'
|
|
28
|
+
: `atc clients add: '${refused}' is not a redirect URI atc accepts; use https, or http on a loopback host, with no fragment`;
|
|
29
|
+
|
|
30
|
+
console.error(message);
|
|
31
|
+
process.exit(1);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
mkdirSync(stateDir, { recursive: true });
|
|
36
|
+
|
|
37
|
+
const store = await openMCPAuth({ dbPath: mcpAuthDBFile, origin: null });
|
|
38
|
+
|
|
39
|
+
try {
|
|
40
|
+
if (action.kind === 'add') {
|
|
41
|
+
const name = normalizeClientName(action.name);
|
|
42
|
+
|
|
43
|
+
const created = await store.auth.api.createFixedClient({
|
|
44
|
+
body: { name, redirectURIs: [...action.redirectURIs] },
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
console.log(`Added ${name}. Its client ID is ${created.clientID}`);
|
|
48
|
+
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (action.kind === 'remove') {
|
|
53
|
+
const removed = await removeClient(store.db, action.clientID);
|
|
54
|
+
|
|
55
|
+
if (!removed) {
|
|
56
|
+
console.error(`atc clients remove: no client has the ID '${action.clientID}'`);
|
|
57
|
+
|
|
58
|
+
process.exitCode = 1;
|
|
59
|
+
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
console.log(`Removed client ${action.clientID} and revoked every grant it held`);
|
|
64
|
+
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const clients = await collectClients(store.db);
|
|
69
|
+
|
|
70
|
+
if (clients.length === 0) {
|
|
71
|
+
console.log('No clients. Add one with: atc clients add <name> --redirect-uri <uri>');
|
|
72
|
+
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
for (const client of clients) {
|
|
77
|
+
console.log(`${client.clientID} ${client.name} ${client.redirectURIs.join(' ')}`);
|
|
78
|
+
}
|
|
79
|
+
} finally {
|
|
80
|
+
await store.close();
|
|
81
|
+
}
|
|
82
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Every `--redirect-uri` value in a command line, in order, whether given as
|
|
3
|
+
* `--redirect-uri <uri>` or `--redirect-uri=<uri>`. The flag can repeat, and
|
|
4
|
+
* the argument parser keeps only its last value.
|
|
5
|
+
*/
|
|
6
|
+
export function collectRedirectURIs(rawArgs: readonly string[]): readonly string[] {
|
|
7
|
+
return rawArgs.flatMap((arg, index) => {
|
|
8
|
+
if (arg.startsWith('--redirect-uri=')) {
|
|
9
|
+
return [arg.slice('--redirect-uri='.length)];
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const next = rawArgs[index + 1];
|
|
13
|
+
|
|
14
|
+
return arg === '--redirect-uri' && next !== undefined ? [next] : [];
|
|
15
|
+
});
|
|
16
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { encodeCursor } from '../protocol/encode-cursor';
|
|
2
|
+
import type { MessageID } from '../shared/message-id';
|
|
2
3
|
import type { StoredEvent } from '../store/state-store';
|
|
3
4
|
import type { SessionDescriptor } from './sessions';
|
|
4
5
|
|
|
@@ -9,6 +10,12 @@ export interface FleetEvent {
|
|
|
9
10
|
readonly name: string | null;
|
|
10
11
|
readonly kind: string;
|
|
11
12
|
readonly detail: string | null;
|
|
13
|
+
|
|
14
|
+
// The message id on a message status event, for message.get.
|
|
15
|
+
readonly message?: MessageID;
|
|
16
|
+
|
|
17
|
+
// The report label on a report event.
|
|
18
|
+
readonly label?: string;
|
|
12
19
|
}
|
|
13
20
|
|
|
14
21
|
export function buildFleetEvents(
|
|
@@ -30,6 +37,8 @@ export function buildFleetEvents(
|
|
|
30
37
|
name: live?.name ?? null,
|
|
31
38
|
kind: row.kind,
|
|
32
39
|
detail: row.detail,
|
|
40
|
+
...(row.message === undefined ? {} : { message: row.message }),
|
|
41
|
+
...(row.label === undefined ? {} : { label: row.label }),
|
|
33
42
|
};
|
|
34
43
|
});
|
|
35
44
|
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { truncateDetail } from '../agents/truncate-detail';
|
|
2
|
+
import type { AgentSessionID } from '../shared/agent-session-id';
|
|
3
|
+
import type { SessionID } from '../shared/session-id';
|
|
4
|
+
import type { MessageRecord, MessageStatus } from '../store/message-record';
|
|
5
|
+
import type { TrailEntry } from '../store/trail-entry';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The trail entry for one message status change, addressed like its SessionMessage
|
|
9
|
+
* broadcast. It is stamped with the status's own time and the owning session's agent
|
|
10
|
+
* session id, falling back to the one the message carries, and the detail previews
|
|
11
|
+
* the answer once there is one, else the text.
|
|
12
|
+
*/
|
|
13
|
+
export function buildMessageTrailEntry(
|
|
14
|
+
sessionID: SessionID,
|
|
15
|
+
agentSessionID: AgentSessionID | undefined,
|
|
16
|
+
record: MessageRecord,
|
|
17
|
+
): TrailEntry {
|
|
18
|
+
const previewText = record.status === 'answered' ? (record.answer ?? record.text) : record.text;
|
|
19
|
+
|
|
20
|
+
return {
|
|
21
|
+
at: pickStatusTime(record),
|
|
22
|
+
atcID: sessionID,
|
|
23
|
+
agentSessionID: agentSessionID ?? record.agentSessionID ?? null,
|
|
24
|
+
kind: pickMessageKind(record.status),
|
|
25
|
+
message: record.id,
|
|
26
|
+
detail: truncateDetail(previewText),
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function pickStatusTime(record: MessageRecord): number {
|
|
31
|
+
if (record.status === 'answered') {
|
|
32
|
+
return record.answeredAt ?? record.sentAt;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if (record.status === 'delivered') {
|
|
36
|
+
return record.deliveredAt ?? record.sentAt;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return record.sentAt;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function pickMessageKind(
|
|
43
|
+
status: MessageStatus,
|
|
44
|
+
): 'message-accepted' | 'message-delivered' | 'message-answered' {
|
|
45
|
+
if (status === 'answered') {
|
|
46
|
+
return 'message-answered';
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (status === 'delivered') {
|
|
50
|
+
return 'message-delivered';
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return 'message-accepted';
|
|
54
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { truncateDetail } from '../agents/truncate-detail';
|
|
2
|
+
import type { AgentSessionID } from '../shared/agent-session-id';
|
|
3
|
+
import type { SessionID } from '../shared/session-id';
|
|
4
|
+
import type { TrailEntry } from '../store/trail-entry';
|
|
5
|
+
import type { NoteReport } from './parse-report';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The trail entry for one note a session reported, carrying its label and a
|
|
9
|
+
* preview of its text.
|
|
10
|
+
*/
|
|
11
|
+
export function buildReportTrailEntry(
|
|
12
|
+
sessionID: SessionID,
|
|
13
|
+
agentSessionID: AgentSessionID | undefined,
|
|
14
|
+
report: Readonly<NoteReport>,
|
|
15
|
+
reportedAt: number,
|
|
16
|
+
): TrailEntry {
|
|
17
|
+
return {
|
|
18
|
+
at: reportedAt,
|
|
19
|
+
atcID: sessionID,
|
|
20
|
+
agentSessionID: agentSessionID ?? null,
|
|
21
|
+
kind: 'report',
|
|
22
|
+
label: report.label,
|
|
23
|
+
detail: truncateDetail(report.text),
|
|
24
|
+
};
|
|
25
|
+
}
|
|
@@ -11,7 +11,6 @@ import type { MessageRecord } from '../store/message-record';
|
|
|
11
11
|
* broadcast stays small; `message.get` returns the full message. Delivery
|
|
12
12
|
* and answer fields appear only once they are set.
|
|
13
13
|
*/
|
|
14
|
-
// oxlint-disable-next-line prefer-readonly-parameter-types -- every field is readonly; the branded id has no readonly form to wrap it in
|
|
15
14
|
export function buildSessionMessageEvent(sessionID: SessionID, record: MessageRecord): EventMsg {
|
|
16
15
|
return {
|
|
17
16
|
v: PROTOCOL_V,
|
package/src/daemon/daemon.ts
CHANGED
|
@@ -8,9 +8,12 @@ import { truncateToBytes } from '../shared/truncate-to-bytes';
|
|
|
8
8
|
import type { MessageOwner } from '../store/message-owner';
|
|
9
9
|
import type { MessageRecord } from '../store/message-record';
|
|
10
10
|
import { StateStore } from '../store/state-store';
|
|
11
|
+
import type { TrailEntry } from '../store/trail-entry';
|
|
11
12
|
import { ANSWER_BYTE_CAP } from './answer-byte-cap';
|
|
12
13
|
import { AttachRegistry } from './attach-registry';
|
|
13
14
|
import { buildFleetEvents } from './build-fleet-events';
|
|
15
|
+
import { buildMessageTrailEntry } from './build-message-trail-entry';
|
|
16
|
+
import { buildReportTrailEntry } from './build-report-trail-entry';
|
|
14
17
|
import { buildSessionEvent } from './build-session-event';
|
|
15
18
|
import { buildSessionMessageEvent } from './build-session-message-event';
|
|
16
19
|
import { buildSessionReportEvent } from './build-session-report-event';
|
|
@@ -89,6 +92,9 @@ export interface DaemonOptions {
|
|
|
89
92
|
|
|
90
93
|
export interface DaemonHandle {
|
|
91
94
|
readonly stop: () => Promise<void>;
|
|
95
|
+
|
|
96
|
+
// How many client-protocol connections are open right now.
|
|
97
|
+
readonly countClients: () => number;
|
|
92
98
|
}
|
|
93
99
|
|
|
94
100
|
// How long a started Claude session may go without a tap before a message to
|
|
@@ -150,6 +156,27 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
150
156
|
const registry = new PermissionRegistry();
|
|
151
157
|
const eventSignal = new EventSignal();
|
|
152
158
|
|
|
159
|
+
// A trail write that fails never fails the message request or report behind it.
|
|
160
|
+
const recordTrailEntry = async (entry: TrailEntry) => {
|
|
161
|
+
try {
|
|
162
|
+
await store.recordTrailEntry(entry);
|
|
163
|
+
} catch {
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
eventSignal.emit();
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
// Notes a message status change in the trail before broadcasting it, so a
|
|
171
|
+
// client reading the trail on the broadcast finds the change already there.
|
|
172
|
+
const recordMessageStatus = async (sessionID: SessionID, record: MessageRecord) => {
|
|
173
|
+
const s = mgr.sessions.find((x) => x.id === sessionID);
|
|
174
|
+
|
|
175
|
+
await recordTrailEntry(buildMessageTrailEntry(sessionID, s?.agentSessionID, record));
|
|
176
|
+
|
|
177
|
+
emitEvent(buildSessionMessageEvent(sessionID, record), findHookScope(sessionID));
|
|
178
|
+
};
|
|
179
|
+
|
|
153
180
|
registry.onRequested = (req) => {
|
|
154
181
|
emitEvent(
|
|
155
182
|
{
|
|
@@ -187,7 +214,6 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
187
214
|
// Hands one pending message to the session's tap, once, and reports whether
|
|
188
215
|
// it did. The event goes to the tap connection alone: it never reaches
|
|
189
216
|
// other clients, the events socket, or hooks.
|
|
190
|
-
// oxlint-disable-next-line prefer-readonly-parameter-types -- every field is readonly; the branded id has no readonly form to wrap it in
|
|
191
217
|
const sendInboxMessage = (sessionID: SessionID, record: MessageRecord): boolean => {
|
|
192
218
|
const tap = taps.claimDelivery(sessionID, record.id);
|
|
193
219
|
|
|
@@ -242,8 +268,13 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
242
268
|
|
|
243
269
|
if (sender !== undefined) {
|
|
244
270
|
const capped = { ...report, text: truncateToBytes(report.text, ANSWER_BYTE_CAP) };
|
|
271
|
+
const reportedAt = Date.now();
|
|
272
|
+
|
|
273
|
+
await recordTrailEntry(
|
|
274
|
+
buildReportTrailEntry(sender.id, sender.agentSessionID, capped, reportedAt),
|
|
275
|
+
);
|
|
245
276
|
|
|
246
|
-
emitEvent(buildSessionReportEvent(sender.id, capped,
|
|
277
|
+
emitEvent(buildSessionReportEvent(sender.id, capped, reportedAt), findHookScope(sender.id));
|
|
247
278
|
}
|
|
248
279
|
|
|
249
280
|
return;
|
|
@@ -261,7 +292,7 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
261
292
|
);
|
|
262
293
|
|
|
263
294
|
if (answered !== null) {
|
|
264
|
-
|
|
295
|
+
await recordMessageStatus(e.atcId, answered);
|
|
265
296
|
}
|
|
266
297
|
} catch {}
|
|
267
298
|
};
|
|
@@ -496,6 +527,21 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
496
527
|
eventSignal.emit();
|
|
497
528
|
};
|
|
498
529
|
|
|
530
|
+
// A headless turn has no terminal hooks of its own, so the daemon writes
|
|
531
|
+
// its start and end to the trail under a synthetic event name.
|
|
532
|
+
const recordHeadlessTurnEvent = (sessionID: SessionID, ev: Readonly<AdapterEvent>) => {
|
|
533
|
+
const s = mgr.sessions.find((x) => x.id === sessionID);
|
|
534
|
+
|
|
535
|
+
void recordHookEvent(
|
|
536
|
+
{
|
|
537
|
+
atcId: sessionID,
|
|
538
|
+
event: 'HeadlessTurn',
|
|
539
|
+
payload: s?.agentSessionID === undefined ? {} : { session_id: s.agentSessionID },
|
|
540
|
+
},
|
|
541
|
+
ev,
|
|
542
|
+
);
|
|
543
|
+
};
|
|
544
|
+
|
|
499
545
|
const reporter = startHookServer((e) => {
|
|
500
546
|
if (e.event === 'Report') {
|
|
501
547
|
void applyReport(e);
|
|
@@ -503,11 +549,16 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
503
549
|
return;
|
|
504
550
|
}
|
|
505
551
|
|
|
506
|
-
const
|
|
552
|
+
const before = mgr.sessions.find((s) => s.id === e.atcId);
|
|
553
|
+
const previousAgentSessionID = before?.agentSessionID;
|
|
507
554
|
const ev = mgr.applyHook(e);
|
|
508
555
|
|
|
556
|
+
// A headless session's trail comes from its runs alone: hook reports from
|
|
557
|
+
// its dying terminal, or from a run's own CLI, stay out of it.
|
|
558
|
+
const trailEvent = before?.kind === 'headless' ? null : ev;
|
|
559
|
+
|
|
509
560
|
if (e.event !== 'Statusline') {
|
|
510
|
-
void recordHookEvent(e,
|
|
561
|
+
void recordHookEvent(e, trailEvent);
|
|
511
562
|
}
|
|
512
563
|
|
|
513
564
|
const kind = ev?.kind ?? null;
|
|
@@ -516,6 +567,7 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
516
567
|
|
|
517
568
|
if (currentAgentSessionID !== undefined && currentAgentSessionID !== previousAgentSessionID) {
|
|
518
569
|
void store.updateMessageOwner(e.atcId, previousAgentSessionID, currentAgentSessionID);
|
|
570
|
+
void store.updateTrailOwner(e.atcId, currentAgentSessionID);
|
|
519
571
|
}
|
|
520
572
|
|
|
521
573
|
if (kind === 'ended') {
|
|
@@ -633,7 +685,8 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
633
685
|
prompt,
|
|
634
686
|
settleMs: opts.ejectSettleMs ?? 4000,
|
|
635
687
|
runtime,
|
|
636
|
-
startHeadlessTurn: (sid, p) =>
|
|
688
|
+
startHeadlessTurn: (sid, p) =>
|
|
689
|
+
startHeadlessTurn(mgr, findRuntime, sid, p, recordHeadlessTurnEvent),
|
|
637
690
|
});
|
|
638
691
|
|
|
639
692
|
return 'ok';
|
|
@@ -743,7 +796,15 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
743
796
|
return 'busy';
|
|
744
797
|
}
|
|
745
798
|
|
|
746
|
-
return startHeadlessTurn(
|
|
799
|
+
return startHeadlessTurn(
|
|
800
|
+
mgr,
|
|
801
|
+
findRuntime,
|
|
802
|
+
sessionID,
|
|
803
|
+
data.trimEnd(),
|
|
804
|
+
recordHeadlessTurnEvent,
|
|
805
|
+
)
|
|
806
|
+
? 'ok'
|
|
807
|
+
: 'dead';
|
|
747
808
|
}
|
|
748
809
|
|
|
749
810
|
if (s.pty === null) {
|
|
@@ -886,12 +947,12 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
886
947
|
|
|
887
948
|
try {
|
|
888
949
|
await store.writeMessage(record);
|
|
950
|
+
|
|
951
|
+
await recordMessageStatus(sessionID, record);
|
|
889
952
|
} finally {
|
|
890
953
|
written.resolve();
|
|
891
954
|
}
|
|
892
955
|
|
|
893
|
-
emitEvent(buildSessionMessageEvent(sessionID, record), findHookScope(sessionID));
|
|
894
|
-
|
|
895
956
|
await drainInbox(sessionID);
|
|
896
957
|
|
|
897
958
|
return record;
|
|
@@ -948,7 +1009,7 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
948
1009
|
void drainInbox(sessionID);
|
|
949
1010
|
|
|
950
1011
|
if (delivered !== null) {
|
|
951
|
-
|
|
1012
|
+
await recordMessageStatus(sessionID, delivered);
|
|
952
1013
|
|
|
953
1014
|
return delivered;
|
|
954
1015
|
}
|
|
@@ -1014,7 +1075,7 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
1014
1075
|
}
|
|
1015
1076
|
};
|
|
1016
1077
|
|
|
1017
|
-
return { stop: stopDaemon };
|
|
1078
|
+
return { stop: stopDaemon, countClients: () => clients.size };
|
|
1018
1079
|
}
|
|
1019
1080
|
|
|
1020
1081
|
// Tells a tap its subscription is over so the `atc tap` process behind it
|
package/src/daemon/sessions.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { writeFileSync } from 'node:fs';
|
|
|
2
2
|
import { spawn } from 'bun-pty';
|
|
3
3
|
import type { IPty } from 'bun-pty';
|
|
4
4
|
import type { AdapterEvent, AgentAdapter, AgentID } from '../agents/agent-adapter';
|
|
5
|
+
import { truncateDetail } from '../agents/truncate-detail';
|
|
5
6
|
import type { AgentSessionID } from '../shared/agent-session-id';
|
|
6
7
|
import { collectCleanEnv } from '../shared/collect-clean-env';
|
|
7
8
|
import { socketPath, statusFile } from '../shared/config';
|
|
@@ -302,7 +303,9 @@ export class SessionManager {
|
|
|
302
303
|
return true;
|
|
303
304
|
}
|
|
304
305
|
|
|
305
|
-
|
|
306
|
+
// A result, when given, becomes the session's latest result the way a
|
|
307
|
+
// terminal turn's final message does.
|
|
308
|
+
updateSurfaceState(id: SessionID, state: SessionState, msg: string, result?: string) {
|
|
306
309
|
const s = this.sessions.find((x) => x.id === id);
|
|
307
310
|
|
|
308
311
|
if (!s || s.kind !== 'headless') {
|
|
@@ -313,6 +316,15 @@ export class SessionManager {
|
|
|
313
316
|
s.lastMsg = msg;
|
|
314
317
|
s.unread = this.focusedId !== s.id;
|
|
315
318
|
|
|
319
|
+
if (result !== undefined) {
|
|
320
|
+
s.result = truncateToBytes(result, 16_384);
|
|
321
|
+
s.lastDetail = truncateDetail(result);
|
|
322
|
+
|
|
323
|
+
if (s.agentSessionID !== undefined) {
|
|
324
|
+
void this.store.updateFleetEntry(s.agentSessionID, { result: s.result });
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
316
328
|
this.onEvent('state', s);
|
|
317
329
|
this.emitChange();
|
|
318
330
|
}
|
|
@@ -6,6 +6,7 @@ import { isRecord } from '../shared/report';
|
|
|
6
6
|
import type { SessionID } from '../shared/session-id';
|
|
7
7
|
import { buildHeadlessEnv } from './build-headless-env';
|
|
8
8
|
import { resolveHeadlessExecutable } from './resolve-headless-executable';
|
|
9
|
+
import { truncateSummary } from './truncate-summary';
|
|
9
10
|
|
|
10
11
|
const PERMISSION_MODES = [
|
|
11
12
|
'default',
|
|
@@ -40,7 +41,7 @@ interface HeadlessRunHandle {
|
|
|
40
41
|
/**
|
|
41
42
|
* Runs one headless Agent SDK turn over a session, rendering its structured
|
|
42
43
|
* messages as plain lines into the session's output pipe. The run ends in
|
|
43
|
-
* done (with the
|
|
44
|
+
* done (with the turn's whole result) or needs_you (errors, turn limits,
|
|
44
45
|
* anything a human must look at).
|
|
45
46
|
*/
|
|
46
47
|
export function startHeadlessRun(
|
|
@@ -89,7 +90,7 @@ export function startHeadlessRun(
|
|
|
89
90
|
}
|
|
90
91
|
|
|
91
92
|
if (message.subtype === 'success') {
|
|
92
|
-
hooks.onDone(
|
|
93
|
+
hooks.onDone(message.result);
|
|
93
94
|
continue;
|
|
94
95
|
}
|
|
95
96
|
|
|
@@ -170,9 +171,3 @@ export function renderSdkMessage(message: RenderableMessage): string | null {
|
|
|
170
171
|
|
|
171
172
|
return null;
|
|
172
173
|
}
|
|
173
|
-
|
|
174
|
-
function truncateSummary(text: string): string {
|
|
175
|
-
const flat = text.replaceAll('\n', ' ');
|
|
176
|
-
|
|
177
|
-
return flat.length <= 200 ? flat : `${flat.slice(0, 199)}…`;
|
|
178
|
-
}
|