@zgeoff/atc 2.14.0 → 2.15.1
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/package.json +2 -1
- package/src/agents/agent-adapter.ts +3 -38
- package/src/agents/build-hook-settings.ts +1 -1
- package/src/agents/claude-adapter.ts +3 -3
- package/src/agents/codex-adapter.ts +3 -3
- package/src/agents/gateway-adapter.ts +3 -3
- package/src/agents/grok-adapter.ts +3 -3
- package/src/agents/start-claude-headless-run.ts +1 -1
- package/src/client/boot-daemon.ts +2 -2
- package/src/client/collect-agent-picks.ts +1 -1
- package/src/client/daemon-client.ts +7 -23
- package/src/client/format-overlay-agent-mark.ts +1 -1
- package/src/client/index.ts +4 -2
- package/src/client/pick-tab-target.ts +2 -2
- package/src/client/spawn-picker.ts +1 -1
- package/src/client/to-mirror-session.ts +3 -3
- package/src/client/ui.ts +3 -3
- package/src/daemon/build-imp-provider.ts +70 -23
- package/src/daemon/build-message-trail-entry.ts +1 -1
- package/src/daemon/build-report-trail-entry.ts +1 -1
- package/src/daemon/build-scoped-context.ts +1 -1
- package/src/daemon/build-session-message-event.ts +1 -1
- package/src/daemon/daemon-connection.ts +9 -253
- package/src/daemon/daemon-context.ts +240 -0
- package/src/daemon/daemon.ts +9 -5
- package/src/daemon/execution-provider.ts +12 -0
- package/src/daemon/hooks.ts +6 -24
- package/src/daemon/idempotency-ledger.ts +78 -18
- package/src/daemon/imp-client-port.ts +83 -27
- package/src/daemon/imp-harness.ts +41 -0
- package/src/daemon/imp-provider.ts +5 -11
- package/src/daemon/local-pty-provider.ts +45 -0
- package/src/daemon/parse-hook-line.ts +1 -1
- package/src/daemon/read-imp-token.ts +30 -0
- package/src/daemon/sessions.ts +134 -107
- package/src/daemon/start-headless-turn.ts +3 -3
- package/src/daemon/start-session-bridge.ts +2 -2
- package/src/mcp/collect-clients.ts +1 -1
- package/src/mcp/collect-grants.ts +1 -1
- package/src/mcp/find-client-name.ts +1 -1
- package/src/mcp/mcp-auth-schema.ts +37 -0
- package/src/mcp/open-mcp-auth.ts +2 -2
- package/src/mcp/reconnecting-caller.ts +15 -6
- package/src/mcp/remove-client.ts +1 -1
- package/src/mcp/revoke-grant.ts +1 -1
- package/src/mcp/types.ts +0 -38
- package/src/mcp-http-server.ts +2 -1
- package/src/mcp-server.ts +3 -14
- package/src/protocol/adapter-event.ts +25 -0
- package/src/protocol/count-session-states.ts +16 -0
- package/src/protocol/daemon-channel.ts +17 -0
- package/src/protocol/hook-event.ts +11 -0
- package/src/protocol/line-decoder.ts +34 -0
- package/src/{shared → protocol}/open-bridge-socket.ts +4 -7
- package/src/protocol/pinned-group-key.ts +2 -0
- package/src/protocol/session-state.ts +5 -0
- package/src/protocol/sort-grouped-session-views.ts +29 -0
- package/src/protocol/sort-session-views.ts +41 -0
- package/src/protocol/sortable-session-view.ts +13 -0
- package/src/report.ts +1 -1
- package/src/run-bridge-tap.ts +2 -2
- package/src/shared/agent-id.ts +6 -0
- package/src/shared/collect-gateways.ts +1 -1
- package/src/shared/to-agent-id.ts +11 -0
- package/src/statusline.ts +1 -1
- package/src/store/fleet-entry.ts +6 -3
- package/src/store/state-store.ts +80 -19
- /package/src/{shared → protocol}/send-bridge-request.ts +0 -0
- /package/src/{store → shared}/bun-sqlite-driver.ts +0 -0
- /package/src/{agents → shared}/truncate-detail.ts +0 -0
- /package/src/{daemon → shared}/truncate-summary.ts +0 -0
package/src/daemon/sessions.ts
CHANGED
|
@@ -1,21 +1,25 @@
|
|
|
1
1
|
import { writeFileSync } from 'node:fs';
|
|
2
2
|
import type {
|
|
3
|
-
AdapterEvent,
|
|
4
3
|
AgentAdapter,
|
|
5
|
-
AgentID,
|
|
6
4
|
SpawnOptions,
|
|
7
5
|
SpawnOverrides,
|
|
8
6
|
SpawnPlan,
|
|
9
7
|
} from '../agents/agent-adapter';
|
|
10
|
-
import {
|
|
8
|
+
import type { AdapterEvent } from '../protocol/adapter-event';
|
|
9
|
+
import { countSessionStates } from '../protocol/count-session-states';
|
|
11
10
|
import { DaemonError } from '../protocol/daemon-error';
|
|
11
|
+
import type { HookEvent } from '../protocol/hook-event';
|
|
12
12
|
import type { ErrorCode } from '../protocol/protocol';
|
|
13
|
+
import type { SessionState } from '../protocol/session-state';
|
|
14
|
+
import { sortSessionViews } from '../protocol/sort-session-views';
|
|
15
|
+
import type { AgentID } from '../shared/agent-id';
|
|
13
16
|
import type { AgentSessionID } from '../shared/agent-session-id';
|
|
14
17
|
import type { TargetConfigError } from '../shared/collect-targets';
|
|
15
18
|
import { socketPath, statusFile } from '../shared/config';
|
|
16
19
|
import type { DaemonID } from '../shared/daemon-id';
|
|
17
20
|
import { resolveRepoRoot } from '../shared/resolve-repo-root';
|
|
18
21
|
import type { SessionID } from '../shared/session-id';
|
|
22
|
+
import { truncateDetail } from '../shared/truncate-detail';
|
|
19
23
|
import { truncateToBytes } from '../shared/truncate-to-bytes';
|
|
20
24
|
import type { FleetEntry, FleetEntryUpdate, FleetStore } from '../store/fleet-entry';
|
|
21
25
|
import type { SessionWorkspace } from '../store/workspace-materialization';
|
|
@@ -31,14 +35,11 @@ import type {
|
|
|
31
35
|
HarnessRelay,
|
|
32
36
|
} from './execution-provider';
|
|
33
37
|
import { findExecutionRefusal } from './find-execution-refusal';
|
|
34
|
-
import type { HookEvent } from './hooks';
|
|
35
38
|
import type { BridgeBinding } from './is-binding-current';
|
|
36
39
|
import { LocalPTYProvider } from './local-pty-provider';
|
|
37
40
|
import { mintSessionID } from './mint-session-id';
|
|
38
41
|
import { pickSessionState } from './pick-session-state';
|
|
39
42
|
|
|
40
|
-
export type SessionState = 'running' | 'needs_you' | 'done' | 'exited';
|
|
41
|
-
|
|
42
43
|
export type SessionEventKind = 'added' | 'state' | 'renamed' | 'removed';
|
|
43
44
|
|
|
44
45
|
// The over-the-wire view of a session: everything but the harness handle,
|
|
@@ -181,6 +182,10 @@ interface MaterializedSpawn {
|
|
|
181
182
|
// stored identity ran on.
|
|
182
183
|
const LOCAL_TARGET_IDENTITY = buildTargetIdentity('local-pty', {});
|
|
183
184
|
|
|
185
|
+
// How long a failed spawn's rollback waits for the killed process to exit,
|
|
186
|
+
// once after its kill and once more after a forced kill.
|
|
187
|
+
const FAILED_SPAWN_EXIT_WAIT_MS = 2000;
|
|
188
|
+
|
|
184
189
|
// The target a session runs on and the identity it is bound to there; null
|
|
185
190
|
// binds a new session to the target as it stands now.
|
|
186
191
|
interface TargetBinding {
|
|
@@ -232,6 +237,17 @@ export class SessionManager {
|
|
|
232
237
|
// of the same session does not start a second harness.
|
|
233
238
|
private readonly adopting = new Set<SessionID>();
|
|
234
239
|
|
|
240
|
+
// Sessions dropped from the list on purpose whose rows the next fleet
|
|
241
|
+
// write deletes; each stays here until a write carrying it lands.
|
|
242
|
+
private readonly removedIDs = new Set<SessionID>();
|
|
243
|
+
|
|
244
|
+
// Failed spawns whose rollback is running, and those whose killed process
|
|
245
|
+
// it could not confirm gone, each with the harness it ran in, so no revive
|
|
246
|
+
// starts a second harness while the first may still run.
|
|
247
|
+
private readonly rollingBack = new Set<SessionID>();
|
|
248
|
+
|
|
249
|
+
private readonly unconfirmedKills = new Map<SessionID, HarnessHandle>();
|
|
250
|
+
|
|
235
251
|
// The epoch the next harness start or attach takes, unique across every
|
|
236
252
|
// session this manager holds.
|
|
237
253
|
private nextBridgeEpoch = 1;
|
|
@@ -471,6 +487,12 @@ export class SessionManager {
|
|
|
471
487
|
return null;
|
|
472
488
|
}
|
|
473
489
|
|
|
490
|
+
const settled = await this.isRollbackSettled(id);
|
|
491
|
+
|
|
492
|
+
if (!settled) {
|
|
493
|
+
return null;
|
|
494
|
+
}
|
|
495
|
+
|
|
474
496
|
const adapter = this.findAdapter(s.agent);
|
|
475
497
|
|
|
476
498
|
if (adapter === null) {
|
|
@@ -494,8 +516,14 @@ export class SessionManager {
|
|
|
494
516
|
this.adopting.delete(id);
|
|
495
517
|
}
|
|
496
518
|
|
|
497
|
-
// A kill
|
|
498
|
-
|
|
519
|
+
// A kill, a second adopt, or a failed spawn's rollback can land while
|
|
520
|
+
// the host wakes.
|
|
521
|
+
if (
|
|
522
|
+
s.pty !== null ||
|
|
523
|
+
!this.sessions.includes(s) ||
|
|
524
|
+
this.rollingBack.has(id) ||
|
|
525
|
+
this.unconfirmedKills.has(id)
|
|
526
|
+
) {
|
|
499
527
|
return null;
|
|
500
528
|
}
|
|
501
529
|
|
|
@@ -889,8 +917,11 @@ export class SessionManager {
|
|
|
889
917
|
|
|
890
918
|
// Takes back a spawn that failed after its process started: the process
|
|
891
919
|
// dies and the session leaves the list and the fleet, so the spawn leaves
|
|
892
|
-
// nothing behind. It resolves only once the
|
|
893
|
-
// durable, and throws when the kill
|
|
920
|
+
// nothing behind. It resolves only once the process has exited and the
|
|
921
|
+
// fleet without the session is durable, and throws when the kill, the
|
|
922
|
+
// exit, or that write cannot be confirmed. A session whose exit is not
|
|
923
|
+
// confirmed stays listed, so the spawn's caller can still find it, and
|
|
924
|
+
// no revive starts it until its process is found gone. A session that
|
|
894
925
|
// never registered is left alone.
|
|
895
926
|
async removeFailedSpawn(id: SessionID): Promise<void> {
|
|
896
927
|
const s = this.sessions.find((x) => x.id === id);
|
|
@@ -899,13 +930,87 @@ export class SessionManager {
|
|
|
899
930
|
return;
|
|
900
931
|
}
|
|
901
932
|
|
|
902
|
-
s.pty
|
|
903
|
-
s.pty = null;
|
|
933
|
+
const pty = s.pty;
|
|
904
934
|
|
|
905
|
-
this.
|
|
906
|
-
this.emitChange();
|
|
935
|
+
this.rollingBack.add(id);
|
|
907
936
|
|
|
908
|
-
|
|
937
|
+
try {
|
|
938
|
+
if (pty !== null) {
|
|
939
|
+
await this.killFailedSpawn(id, pty);
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
// A harness other than the killed one would outlive the rollback.
|
|
943
|
+
if (s.pty !== null && s.pty !== pty) {
|
|
944
|
+
throw new Error(`session ${id} started another harness while its spawn rolled back`);
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
s.pty = null;
|
|
948
|
+
|
|
949
|
+
this.remove(s);
|
|
950
|
+
this.emitChange();
|
|
951
|
+
|
|
952
|
+
await this.writeFleet();
|
|
953
|
+
} finally {
|
|
954
|
+
this.rollingBack.delete(id);
|
|
955
|
+
}
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
// Kills a failed spawn's harness and waits for its exit. A kill that
|
|
959
|
+
// throws or an exit it cannot confirm leaves the harness recorded as
|
|
960
|
+
// possibly running, which blocks revives of the session.
|
|
961
|
+
private async killFailedSpawn(id: SessionID, pty: HarnessHandle): Promise<void> {
|
|
962
|
+
this.unconfirmedKills.set(id, pty);
|
|
963
|
+
pty.kill();
|
|
964
|
+
|
|
965
|
+
const exited = await this.waitForKilledExit(pty);
|
|
966
|
+
|
|
967
|
+
if (!exited) {
|
|
968
|
+
throw new Error(`session ${id} did not exit after its kill`);
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
this.unconfirmedKills.delete(id);
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
// Waits for a killed harness to exit, ending it with a signal it cannot
|
|
975
|
+
// ignore when the first wait runs out and its provider can send one.
|
|
976
|
+
private async waitForKilledExit(pty: HarnessHandle): Promise<boolean> {
|
|
977
|
+
const exited = await pty.waitForExit(FAILED_SPAWN_EXIT_WAIT_MS);
|
|
978
|
+
|
|
979
|
+
if (exited) {
|
|
980
|
+
return true;
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
if (pty.killForced === undefined) {
|
|
984
|
+
return false;
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
pty.killForced();
|
|
988
|
+
|
|
989
|
+
return pty.waitForExit(FAILED_SPAWN_EXIT_WAIT_MS);
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
// Whether a failed spawn's rollback leaves the session free to revive:
|
|
993
|
+
// none is running, and any process it killed is gone now. A session whose
|
|
994
|
+
// killed process may still run stays blocked until a later check finds it
|
|
995
|
+
// gone.
|
|
996
|
+
private async isRollbackSettled(id: SessionID): Promise<boolean> {
|
|
997
|
+
if (this.rollingBack.has(id)) {
|
|
998
|
+
return false;
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
const killed = this.unconfirmedKills.get(id);
|
|
1002
|
+
|
|
1003
|
+
if (killed !== undefined) {
|
|
1004
|
+
const gone = await killed.waitForExit(0);
|
|
1005
|
+
|
|
1006
|
+
if (!gone) {
|
|
1007
|
+
return false;
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
this.unconfirmedKills.delete(id);
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
return true;
|
|
909
1014
|
}
|
|
910
1015
|
|
|
911
1016
|
collectDescriptors(): SessionDescriptor[] {
|
|
@@ -1350,6 +1455,8 @@ export class SessionManager {
|
|
|
1350
1455
|
private remove(s: Session) {
|
|
1351
1456
|
this.sessions = this.sessions.filter((x) => x.id !== s.id);
|
|
1352
1457
|
|
|
1458
|
+
this.removedIDs.add(s.id);
|
|
1459
|
+
|
|
1353
1460
|
if (this.focusedId === s.id) {
|
|
1354
1461
|
this.focusedId = null;
|
|
1355
1462
|
}
|
|
@@ -1383,8 +1490,11 @@ export class SessionManager {
|
|
|
1383
1490
|
}
|
|
1384
1491
|
|
|
1385
1492
|
// Persisted continuously so a crash (or quit) leaves a restorable fleet.
|
|
1386
|
-
//
|
|
1387
|
-
//
|
|
1493
|
+
// A write covers the listed sessions and the ones dropped on purpose
|
|
1494
|
+
// since the last write; a stored row the daemon has not restored yet
|
|
1495
|
+
// stays as it is, so a write before `R` keeps it restorable. Deliberate
|
|
1496
|
+
// kills rewrite the fleet; unexpected session/atc deaths do not, so the
|
|
1497
|
+
// last known fleet survives for `R` restore. Sessions whose
|
|
1388
1498
|
// terminal is gone persist as exited entries, so the killed archive
|
|
1389
1499
|
// survives a daemon restart too. A session the agent has not yet given
|
|
1390
1500
|
// its own session id persists as well, under its atc session id alone.
|
|
@@ -1416,7 +1526,13 @@ export class SessionManager {
|
|
|
1416
1526
|
});
|
|
1417
1527
|
}
|
|
1418
1528
|
|
|
1419
|
-
|
|
1529
|
+
const removed = [...this.removedIDs];
|
|
1530
|
+
|
|
1531
|
+
await this.store.writeFleet(fleet, removed);
|
|
1532
|
+
|
|
1533
|
+
for (const id of removed) {
|
|
1534
|
+
this.removedIDs.delete(id);
|
|
1535
|
+
}
|
|
1420
1536
|
}
|
|
1421
1537
|
|
|
1422
1538
|
// A write nobody awaits that fails leaves the stored rows as they were,
|
|
@@ -1467,95 +1583,6 @@ export class SessionManager {
|
|
|
1467
1583
|
}
|
|
1468
1584
|
}
|
|
1469
1585
|
|
|
1470
|
-
export function countSessionStates(
|
|
1471
|
-
list: readonly { readonly state: SessionState }[],
|
|
1472
|
-
): Record<SessionState, number> {
|
|
1473
|
-
const c = { needs_you: 0, running: 0, done: 0, exited: 0 };
|
|
1474
|
-
|
|
1475
|
-
for (const s of list) {
|
|
1476
|
-
c[s.state]++;
|
|
1477
|
-
}
|
|
1478
|
-
|
|
1479
|
-
return c;
|
|
1480
|
-
}
|
|
1481
|
-
|
|
1482
|
-
interface SortableSessionView {
|
|
1483
|
-
readonly id: string;
|
|
1484
|
-
readonly parent: string | null;
|
|
1485
|
-
readonly state: SessionState;
|
|
1486
|
-
readonly pinned: boolean;
|
|
1487
|
-
readonly lastAttachedAt: number;
|
|
1488
|
-
readonly createdAt: number;
|
|
1489
|
-
}
|
|
1490
|
-
|
|
1491
|
-
// Overlay order: pinned sessions first in most-recently-attached order, then
|
|
1492
|
-
// everyone else by urgency — who needs you, finished turns, busy, dead —
|
|
1493
|
-
// with most-recently-attached breaking ties inside each state. A
|
|
1494
|
-
// sub-session sits directly under its parent, ranked among its siblings
|
|
1495
|
-
// alone, so its attention never moves the parent's row; a sub-session whose
|
|
1496
|
-
// parent is not listed ranks as a top-level row.
|
|
1497
|
-
export function sortSessionViews<T extends SortableSessionView>(list: readonly T[]): T[] {
|
|
1498
|
-
const rank: Record<SessionState, number> = {
|
|
1499
|
-
needs_you: 0,
|
|
1500
|
-
done: 1,
|
|
1501
|
-
running: 2,
|
|
1502
|
-
exited: 3,
|
|
1503
|
-
};
|
|
1504
|
-
|
|
1505
|
-
const ranked = [...list].toSorted((a, b) => {
|
|
1506
|
-
if (a.pinned !== b.pinned) {
|
|
1507
|
-
return a.pinned ? -1 : 1;
|
|
1508
|
-
}
|
|
1509
|
-
|
|
1510
|
-
const recency = b.lastAttachedAt - a.lastAttachedAt || b.createdAt - a.createdAt;
|
|
1511
|
-
|
|
1512
|
-
return a.pinned ? recency : rank[a.state] - rank[b.state] || recency;
|
|
1513
|
-
});
|
|
1514
|
-
|
|
1515
|
-
const listed = new Set(ranked.map((s) => s.id));
|
|
1516
|
-
|
|
1517
|
-
const sorted: T[] = [];
|
|
1518
|
-
|
|
1519
|
-
for (const s of ranked) {
|
|
1520
|
-
if (s.parent !== null && listed.has(s.parent)) {
|
|
1521
|
-
continue;
|
|
1522
|
-
}
|
|
1523
|
-
|
|
1524
|
-
sorted.push(s, ...ranked.filter((child) => child.parent === s.id));
|
|
1525
|
-
}
|
|
1526
|
-
|
|
1527
|
-
return sorted;
|
|
1528
|
-
}
|
|
1529
|
-
|
|
1530
|
-
// Never a filesystem path, so a repository can't collide with it.
|
|
1531
|
-
export const PINNED_GROUP_KEY = ' pinned';
|
|
1532
|
-
|
|
1533
|
-
// Overlay display order for the grouped view: the flat sort with each
|
|
1534
|
-
// repository's sessions pulled together at the position of its best-ranked
|
|
1535
|
-
// member, so the renderer's adjacency-based headers appear once per group.
|
|
1536
|
-
// Pinned sessions form their own leading group. A sub-session keys by its
|
|
1537
|
-
// parent, so a set never splits across groups.
|
|
1538
|
-
export function sortGroupedSessionViews<
|
|
1539
|
-
T extends SortableSessionView & { readonly repoRoot: string },
|
|
1540
|
-
>(list: readonly T[]): T[] {
|
|
1541
|
-
const byID = new Map(list.map((s) => [s.id, s]));
|
|
1542
|
-
const buckets = new Map<string, T[]>();
|
|
1543
|
-
|
|
1544
|
-
for (const s of sortSessionViews(list)) {
|
|
1545
|
-
const owner = (s.parent === null ? undefined : byID.get(s.parent)) ?? s;
|
|
1546
|
-
const key = owner.pinned ? PINNED_GROUP_KEY : owner.repoRoot;
|
|
1547
|
-
const bucket = buckets.get(key);
|
|
1548
|
-
|
|
1549
|
-
if (bucket === undefined) {
|
|
1550
|
-
buckets.set(key, [s]);
|
|
1551
|
-
} else {
|
|
1552
|
-
bucket.push(s);
|
|
1553
|
-
}
|
|
1554
|
-
}
|
|
1555
|
-
|
|
1556
|
-
return [...buckets.values()].flat();
|
|
1557
|
-
}
|
|
1558
|
-
|
|
1559
1586
|
function buildLifecycle(s: Session): SessionLifecycle {
|
|
1560
1587
|
return buildSessionLifecycle({
|
|
1561
1588
|
desired: s.desired,
|
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
import type { AdapterEvent } from '../
|
|
2
|
-
import { truncateDetail } from '../agents/truncate-detail';
|
|
1
|
+
import type { AdapterEvent } from '../protocol/adapter-event';
|
|
3
2
|
import type { SessionID } from '../shared/session-id';
|
|
3
|
+
import { truncateDetail } from '../shared/truncate-detail';
|
|
4
|
+
import { truncateSummary } from '../shared/truncate-summary';
|
|
4
5
|
import type { SessionRuntime } from './session-runtime';
|
|
5
6
|
import type { SessionManager } from './sessions';
|
|
6
|
-
import { truncateSummary } from './truncate-summary';
|
|
7
7
|
|
|
8
8
|
/**
|
|
9
9
|
* Starts one headless turn for a session: finds its adapter's headless
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
|
+
import type { HookEvent } from '../protocol/hook-event';
|
|
2
3
|
import type { EventMsg } from '../protocol/protocol';
|
|
3
4
|
import type { MessageID } from '../shared/message-id';
|
|
4
5
|
import type { SessionID } from '../shared/session-id';
|
|
5
6
|
import { toMessageID } from '../shared/to-message-id';
|
|
6
7
|
import type { MessageRecord } from '../store/message-record';
|
|
7
|
-
import type { TapClient } from './daemon-
|
|
8
|
+
import type { TapClient } from './daemon-context';
|
|
8
9
|
import type { HarnessRelay } from './execution-provider';
|
|
9
|
-
import type { HookEvent } from './hooks';
|
|
10
10
|
import { isBindingCurrent } from './is-binding-current';
|
|
11
11
|
import type { BridgeBinding } from './is-binding-current';
|
|
12
12
|
import { parseHookLine } from './parse-hook-line';
|
|
@@ -2,7 +2,7 @@ import type { Kysely } from 'kysely';
|
|
|
2
2
|
import { GRANT_SCOPES } from '../shared/grant-scope';
|
|
3
3
|
import type { GrantScope } from '../shared/grant-scope';
|
|
4
4
|
import { collectJSONStrings } from './collect-json-strings';
|
|
5
|
-
import type { MCPAuthSchema } from './
|
|
5
|
+
import type { MCPAuthSchema } from './mcp-auth-schema';
|
|
6
6
|
|
|
7
7
|
/**
|
|
8
8
|
* One authorization a client holds: every refresh token rotated from one
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The columns of the authorization server's database that atc reads and
|
|
3
|
+
* writes itself. better-auth owns the tables and their migrations; dates are
|
|
4
|
+
* ISO 8601 strings and string lists are JSON arrays.
|
|
5
|
+
*/
|
|
6
|
+
export interface MCPAuthSchema {
|
|
7
|
+
readonly oauthClient: {
|
|
8
|
+
readonly id: string;
|
|
9
|
+
readonly clientId: string;
|
|
10
|
+
readonly name: string | null;
|
|
11
|
+
readonly redirectUris: string;
|
|
12
|
+
readonly createdAt: string | null;
|
|
13
|
+
};
|
|
14
|
+
readonly oauthAccessToken: {
|
|
15
|
+
readonly id: string;
|
|
16
|
+
readonly token: string;
|
|
17
|
+
readonly clientId: string;
|
|
18
|
+
readonly sessionId: string | null;
|
|
19
|
+
readonly authorizationCodeId: string | null;
|
|
20
|
+
};
|
|
21
|
+
readonly oauthRefreshToken: {
|
|
22
|
+
readonly id: string;
|
|
23
|
+
readonly clientId: string;
|
|
24
|
+
readonly sessionId: string | null;
|
|
25
|
+
readonly authorizationCodeId: string | null;
|
|
26
|
+
readonly scopes: string;
|
|
27
|
+
readonly createdAt: string;
|
|
28
|
+
readonly expiresAt: string;
|
|
29
|
+
readonly revoked: string | null;
|
|
30
|
+
};
|
|
31
|
+
readonly oauthConsent: { readonly id: string; readonly clientId: string };
|
|
32
|
+
readonly oauthResource: { readonly identifier: string };
|
|
33
|
+
readonly session: { readonly id: string; readonly expiresAt: string };
|
|
34
|
+
|
|
35
|
+
// When each grant last reached /mcp, keyed by the grant's authorization code id.
|
|
36
|
+
readonly atc_grant_use: { readonly grant_id: string; readonly last_used_at: string };
|
|
37
|
+
}
|
package/src/mcp/open-mcp-auth.ts
CHANGED
|
@@ -8,11 +8,11 @@ import { betterAuth } from 'better-auth';
|
|
|
8
8
|
import type { BetterAuthPlugin } from 'better-auth';
|
|
9
9
|
import { getMigrations } from 'better-auth/db/migration';
|
|
10
10
|
import { Kysely, SqliteAdapter, SqliteIntrospector, SqliteQueryCompiler, sql } from 'kysely';
|
|
11
|
+
import { BunSqliteDriver } from '../shared/bun-sqlite-driver';
|
|
11
12
|
import { GRANT_SCOPES } from '../shared/grant-scope';
|
|
12
|
-
import { BunSqliteDriver } from '../store/bun-sqlite-driver';
|
|
13
13
|
import { buildOwnerPlugin } from './build-owner-plugin';
|
|
14
14
|
import { deriveTokenHash } from './derive-token-hash';
|
|
15
|
-
import type { MCPAuthSchema } from './
|
|
15
|
+
import type { MCPAuthSchema } from './mcp-auth-schema';
|
|
16
16
|
|
|
17
17
|
interface MCPAuthOptions {
|
|
18
18
|
readonly dbPath: string;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto';
|
|
2
|
-
import {
|
|
2
|
+
import type { DaemonChannel } from '../protocol/daemon-channel';
|
|
3
3
|
import type { DaemonFeature } from '../protocol/daemon-features';
|
|
4
4
|
import { parseDaemonFeatures } from '../protocol/parse-daemon-features';
|
|
5
5
|
import { requireDaemonFeatures } from './require-daemon-features';
|
|
@@ -30,7 +30,7 @@ const KEYED_METHODS: ReadonlyMap<string, DaemonFeature> = new Map<string, Daemon
|
|
|
30
30
|
|
|
31
31
|
// One handshaken connection and the features its daemon announced.
|
|
32
32
|
interface DaemonConnection {
|
|
33
|
-
readonly client:
|
|
33
|
+
readonly client: DaemonChannel;
|
|
34
34
|
readonly features: ReadonlySet<DaemonFeature>;
|
|
35
35
|
}
|
|
36
36
|
|
|
@@ -48,13 +48,22 @@ export class ReconnectingCaller implements FleetCaller {
|
|
|
48
48
|
|
|
49
49
|
private readonly build: string;
|
|
50
50
|
|
|
51
|
+
private readonly openChannel: (socketPath: string) => Promise<DaemonChannel>;
|
|
52
|
+
|
|
51
53
|
private client: Promise<DaemonConnection> | null = null;
|
|
52
54
|
|
|
53
|
-
private readonly closed = new WeakSet<
|
|
55
|
+
private readonly closed = new WeakSet<DaemonChannel>();
|
|
54
56
|
|
|
55
|
-
|
|
57
|
+
// The last argument connects to the daemon at a socket path; each channel
|
|
58
|
+
// it returns is handshaken here before a request rides it.
|
|
59
|
+
constructor(
|
|
60
|
+
socketPath: string,
|
|
61
|
+
build: string,
|
|
62
|
+
openChannel: (socketPath: string) => Promise<DaemonChannel>,
|
|
63
|
+
) {
|
|
56
64
|
this.socketPath = socketPath;
|
|
57
65
|
this.build = build;
|
|
66
|
+
this.openChannel = openChannel;
|
|
58
67
|
}
|
|
59
68
|
|
|
60
69
|
// The required features are checked against each connection right before
|
|
@@ -141,10 +150,10 @@ export class ReconnectingCaller implements FleetCaller {
|
|
|
141
150
|
}
|
|
142
151
|
};
|
|
143
152
|
|
|
144
|
-
let client:
|
|
153
|
+
let client: DaemonChannel;
|
|
145
154
|
|
|
146
155
|
try {
|
|
147
|
-
client = await
|
|
156
|
+
client = await this.openChannel(this.socketPath);
|
|
148
157
|
} catch (error) {
|
|
149
158
|
resetClient();
|
|
150
159
|
throw error;
|
package/src/mcp/remove-client.ts
CHANGED
package/src/mcp/revoke-grant.ts
CHANGED
package/src/mcp/types.ts
CHANGED
|
@@ -25,44 +25,6 @@ export interface ToolContext {
|
|
|
25
25
|
readonly sender: { readonly kind: 'fixed' | 'default'; readonly name: string };
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
-
/**
|
|
29
|
-
* The columns of the authorization server's database that atc reads and
|
|
30
|
-
* writes itself. better-auth owns the tables and their migrations; dates are
|
|
31
|
-
* ISO 8601 strings and string lists are JSON arrays.
|
|
32
|
-
*/
|
|
33
|
-
export interface MCPAuthSchema {
|
|
34
|
-
readonly oauthClient: {
|
|
35
|
-
readonly id: string;
|
|
36
|
-
readonly clientId: string;
|
|
37
|
-
readonly name: string | null;
|
|
38
|
-
readonly redirectUris: string;
|
|
39
|
-
readonly createdAt: string | null;
|
|
40
|
-
};
|
|
41
|
-
readonly oauthAccessToken: {
|
|
42
|
-
readonly id: string;
|
|
43
|
-
readonly token: string;
|
|
44
|
-
readonly clientId: string;
|
|
45
|
-
readonly sessionId: string | null;
|
|
46
|
-
readonly authorizationCodeId: string | null;
|
|
47
|
-
};
|
|
48
|
-
readonly oauthRefreshToken: {
|
|
49
|
-
readonly id: string;
|
|
50
|
-
readonly clientId: string;
|
|
51
|
-
readonly sessionId: string | null;
|
|
52
|
-
readonly authorizationCodeId: string | null;
|
|
53
|
-
readonly scopes: string;
|
|
54
|
-
readonly createdAt: string;
|
|
55
|
-
readonly expiresAt: string;
|
|
56
|
-
readonly revoked: string | null;
|
|
57
|
-
};
|
|
58
|
-
readonly oauthConsent: { readonly id: string; readonly clientId: string };
|
|
59
|
-
readonly oauthResource: { readonly identifier: string };
|
|
60
|
-
readonly session: { readonly id: string; readonly expiresAt: string };
|
|
61
|
-
|
|
62
|
-
// When each grant last reached /mcp, keyed by the grant's authorization code id.
|
|
63
|
-
readonly atc_grant_use: { readonly grant_id: string; readonly last_used_at: string };
|
|
64
|
-
}
|
|
65
|
-
|
|
66
28
|
/**
|
|
67
29
|
* The authorization server's store: the better-auth instance and the
|
|
68
30
|
* database under it.
|
package/src/mcp-http-server.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { mkdirSync } from 'node:fs';
|
|
2
2
|
import { bootDaemonClient } from './client/boot-daemon';
|
|
3
|
+
import { DaemonClient } from './client/daemon-client';
|
|
3
4
|
import { collectClients } from './mcp/collect-clients';
|
|
4
5
|
import { openMCPAuth } from './mcp/open-mcp-auth';
|
|
5
6
|
import { ReconnectingCaller } from './mcp/reconnecting-caller';
|
|
@@ -27,7 +28,7 @@ export async function runMCPHTTPServer(build: string, flags: MCPHTTPFlags): Prom
|
|
|
27
28
|
|
|
28
29
|
mkdirSync(stateDir, { recursive: true });
|
|
29
30
|
|
|
30
|
-
const caller = new ReconnectingCaller(boot.socketPath, build);
|
|
31
|
+
const caller = new ReconnectingCaller(boot.socketPath, build, (path) => DaemonClient.open(path));
|
|
31
32
|
|
|
32
33
|
const server = await startMCPHTTPServer({
|
|
33
34
|
caller,
|
package/src/mcp-server.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { bootDaemonClient } from './client/boot-daemon';
|
|
|
2
2
|
import { answerRPCRequest } from './mcp/answer-rpc-request';
|
|
3
3
|
import { requireDaemonFeatures } from './mcp/require-daemon-features';
|
|
4
4
|
import type { FleetCaller, ToolContext } from './mcp/types';
|
|
5
|
+
import { LineDecoder } from './protocol/line-decoder';
|
|
5
6
|
|
|
6
7
|
/**
|
|
7
8
|
* An MCP server over stdio bridging to the atc daemon: any MCP client —
|
|
@@ -34,26 +35,14 @@ export async function runMCPServer(build: string): Promise<void> {
|
|
|
34
35
|
sender: { kind: 'default', name: callerSessionID ?? 'mcp' },
|
|
35
36
|
};
|
|
36
37
|
|
|
37
|
-
const decoder = new
|
|
38
|
-
|
|
39
|
-
let buffer = '';
|
|
38
|
+
const decoder = new LineDecoder();
|
|
40
39
|
|
|
41
40
|
// Each request runs on its own, so a long poll never holds up the others;
|
|
42
41
|
// responses carry their request's id.
|
|
43
42
|
const inFlight = new Set<Promise<void>>();
|
|
44
43
|
|
|
45
44
|
for await (const chunk of Bun.stdin.stream()) {
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
const lines = buffer.split('\n');
|
|
49
|
-
|
|
50
|
-
buffer = lines.pop() ?? '';
|
|
51
|
-
|
|
52
|
-
for (const line of lines) {
|
|
53
|
-
if (line.trim() === '') {
|
|
54
|
-
continue;
|
|
55
|
-
}
|
|
56
|
-
|
|
45
|
+
for (const line of decoder.splitChunk(chunk)) {
|
|
57
46
|
const finished = Promise.withResolvers<void>();
|
|
58
47
|
|
|
59
48
|
inFlight.add(finished.promise);
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { AgentSessionID } from '../shared/agent-session-id';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* What an adapter makes of one hook event: the agent-neutral kind and the
|
|
5
|
+
* fields the daemon and the store act on.
|
|
6
|
+
*/
|
|
7
|
+
export interface AdapterEvent {
|
|
8
|
+
kind: 'started' | 'needs-input' | 'turn-done' | 'prompt-submitted' | 'ended' | 'heartbeat';
|
|
9
|
+
agentSessionID?: AgentSessionID;
|
|
10
|
+
message?: string;
|
|
11
|
+
|
|
12
|
+
// Fuller activity text than message: what the agent last said or was
|
|
13
|
+
// asked, for briefing. Bounded by the adapter.
|
|
14
|
+
detail?: string;
|
|
15
|
+
|
|
16
|
+
// Opaque handle the adapter can later pull a session name from.
|
|
17
|
+
nameSource?: string;
|
|
18
|
+
|
|
19
|
+
// Claude resume-existence path. Distinct from nameSource: a naming
|
|
20
|
+
// handle is not a resume gate.
|
|
21
|
+
transcriptSource?: string;
|
|
22
|
+
|
|
23
|
+
// The agent's whole final message for a finished turn; detail holds a bounded preview of it.
|
|
24
|
+
result?: string;
|
|
25
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { SessionState } from './session-state';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* How many of the sessions are in each state.
|
|
5
|
+
*/
|
|
6
|
+
export function countSessionStates(
|
|
7
|
+
list: readonly { readonly state: SessionState }[],
|
|
8
|
+
): Record<SessionState, number> {
|
|
9
|
+
const c = { needs_you: 0, running: 0, done: 0, exited: 0 };
|
|
10
|
+
|
|
11
|
+
for (const s of list) {
|
|
12
|
+
c[s.state]++;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
return c;
|
|
16
|
+
}
|