@zgeoff/atc 2.15.0 → 2.15.2
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 +2 -1
- 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-fleet-events.ts +10 -4
- package/src/daemon/build-grant-from-fleet-entry.ts +14 -0
- package/src/daemon/build-message-trail-entry.ts +1 -1
- package/src/daemon/build-report-trail-entry.ts +1 -1
- package/src/daemon/build-report-view.ts +10 -4
- package/src/daemon/build-scoped-context.ts +64 -77
- package/src/daemon/build-session-message-event.ts +1 -1
- package/src/daemon/daemon-connection.ts +206 -288
- package/src/daemon/daemon-context.ts +291 -0
- package/src/daemon/daemon.ts +250 -94
- package/src/daemon/drain-inbox.ts +85 -0
- package/src/daemon/execution-provider.ts +12 -0
- package/src/daemon/hooks.ts +1 -7
- package/src/daemon/idempotency-ledger.ts +78 -18
- package/src/daemon/imp-harness.ts +41 -0
- package/src/daemon/is-tree-in-reach.ts +32 -0
- package/src/daemon/local-pty-provider.ts +45 -0
- package/src/daemon/parse-hook-line.ts +1 -1
- package/src/daemon/sessions.ts +175 -123
- package/src/daemon/start-headless-turn.ts +3 -3
- package/src/daemon/start-session-bridge.ts +2 -2
- package/src/daemon/tap-registry.ts +43 -5
- 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/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/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/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/store/fleet-entry.ts +2 -2
- package/src/store/run-migrations.ts +55 -4
- package/src/store/state-store.ts +49 -13
- /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 {
|
|
@@ -236,6 +241,13 @@ export class SessionManager {
|
|
|
236
241
|
// write deletes; each stays here until a write carrying it lands.
|
|
237
242
|
private readonly removedIDs = new Set<SessionID>();
|
|
238
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
|
+
|
|
239
251
|
// The epoch the next harness start or attach takes, unique across every
|
|
240
252
|
// session this manager holds.
|
|
241
253
|
private nextBridgeEpoch = 1;
|
|
@@ -467,14 +479,27 @@ export class SessionManager {
|
|
|
467
479
|
// Adopts a headless session back into a terminal: a fresh PTY resumes the
|
|
468
480
|
// same agent session id. On a remote host the host wakes first, and a
|
|
469
481
|
// harness still running inside it is attached rather than started again;
|
|
470
|
-
// every other session left asleep on that host comes back with it.
|
|
471
|
-
|
|
482
|
+
// every other session left asleep on that host comes back with it. The
|
|
483
|
+
// caller's check runs again after each await, and a failed check leaves
|
|
484
|
+
// the session as it was.
|
|
485
|
+
async adoptTerminal(
|
|
486
|
+
id: SessionID,
|
|
487
|
+
cols: number,
|
|
488
|
+
rows: number,
|
|
489
|
+
canProceed: () => boolean = () => true,
|
|
490
|
+
): Promise<Session | null> {
|
|
472
491
|
const s = this.sessions.find((x) => x.id === id);
|
|
473
492
|
|
|
474
493
|
if (!s || s.pty !== null || s.agentSessionID === undefined || this.adopting.has(id)) {
|
|
475
494
|
return null;
|
|
476
495
|
}
|
|
477
496
|
|
|
497
|
+
const settled = await this.isRollbackSettled(id);
|
|
498
|
+
|
|
499
|
+
if (!settled || !canProceed()) {
|
|
500
|
+
return null;
|
|
501
|
+
}
|
|
502
|
+
|
|
478
503
|
const adapter = this.findAdapter(s.agent);
|
|
479
504
|
|
|
480
505
|
if (adapter === null) {
|
|
@@ -498,8 +523,16 @@ export class SessionManager {
|
|
|
498
523
|
this.adopting.delete(id);
|
|
499
524
|
}
|
|
500
525
|
|
|
501
|
-
// A kill
|
|
502
|
-
|
|
526
|
+
// A kill, a second adopt, a failed spawn's rollback, or a change that
|
|
527
|
+
// takes the session out of the caller's reach can land while the host
|
|
528
|
+
// wakes.
|
|
529
|
+
if (
|
|
530
|
+
!canProceed() ||
|
|
531
|
+
s.pty !== null ||
|
|
532
|
+
!this.sessions.includes(s) ||
|
|
533
|
+
this.rollingBack.has(id) ||
|
|
534
|
+
this.unconfirmedKills.has(id)
|
|
535
|
+
) {
|
|
503
536
|
return null;
|
|
504
537
|
}
|
|
505
538
|
|
|
@@ -705,6 +738,7 @@ export class SessionManager {
|
|
|
705
738
|
id: SessionID = mintSessionID(),
|
|
706
739
|
target = 'local',
|
|
707
740
|
materialized: MaterializedSpawn | null = null,
|
|
741
|
+
requireInReach: () => void = () => {},
|
|
708
742
|
): Promise<Session> {
|
|
709
743
|
const adapter = this.findAdapter(agent);
|
|
710
744
|
|
|
@@ -727,6 +761,10 @@ export class SessionManager {
|
|
|
727
761
|
...overrides,
|
|
728
762
|
});
|
|
729
763
|
|
|
764
|
+
// The caller's check runs again once the host is ready, before the
|
|
765
|
+
// harness starts.
|
|
766
|
+
requireInReach();
|
|
767
|
+
|
|
730
768
|
const binding = this.mintBridgeBinding(id, target, execution.identity, hostKey);
|
|
731
769
|
|
|
732
770
|
const pty = provider.spawnHarness({
|
|
@@ -893,8 +931,11 @@ export class SessionManager {
|
|
|
893
931
|
|
|
894
932
|
// Takes back a spawn that failed after its process started: the process
|
|
895
933
|
// dies and the session leaves the list and the fleet, so the spawn leaves
|
|
896
|
-
// nothing behind. It resolves only once the
|
|
897
|
-
// durable, and throws when the kill
|
|
934
|
+
// nothing behind. It resolves only once the process has exited and the
|
|
935
|
+
// fleet without the session is durable, and throws when the kill, the
|
|
936
|
+
// exit, or that write cannot be confirmed. A session whose exit is not
|
|
937
|
+
// confirmed stays listed, so the spawn's caller can still find it, and
|
|
938
|
+
// no revive starts it until its process is found gone. A session that
|
|
898
939
|
// never registered is left alone.
|
|
899
940
|
async removeFailedSpawn(id: SessionID): Promise<void> {
|
|
900
941
|
const s = this.sessions.find((x) => x.id === id);
|
|
@@ -903,13 +944,87 @@ export class SessionManager {
|
|
|
903
944
|
return;
|
|
904
945
|
}
|
|
905
946
|
|
|
906
|
-
s.pty
|
|
907
|
-
s.pty = null;
|
|
947
|
+
const pty = s.pty;
|
|
908
948
|
|
|
909
|
-
this.
|
|
910
|
-
this.emitChange();
|
|
949
|
+
this.rollingBack.add(id);
|
|
911
950
|
|
|
912
|
-
|
|
951
|
+
try {
|
|
952
|
+
if (pty !== null) {
|
|
953
|
+
await this.killFailedSpawn(id, pty);
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
// A harness other than the killed one would outlive the rollback.
|
|
957
|
+
if (s.pty !== null && s.pty !== pty) {
|
|
958
|
+
throw new Error(`session ${id} started another harness while its spawn rolled back`);
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
s.pty = null;
|
|
962
|
+
|
|
963
|
+
this.remove(s);
|
|
964
|
+
this.emitChange();
|
|
965
|
+
|
|
966
|
+
await this.writeFleet();
|
|
967
|
+
} finally {
|
|
968
|
+
this.rollingBack.delete(id);
|
|
969
|
+
}
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
// Kills a failed spawn's harness and waits for its exit. A kill that
|
|
973
|
+
// throws or an exit it cannot confirm leaves the harness recorded as
|
|
974
|
+
// possibly running, which blocks revives of the session.
|
|
975
|
+
private async killFailedSpawn(id: SessionID, pty: HarnessHandle): Promise<void> {
|
|
976
|
+
this.unconfirmedKills.set(id, pty);
|
|
977
|
+
pty.kill();
|
|
978
|
+
|
|
979
|
+
const exited = await this.waitForKilledExit(pty);
|
|
980
|
+
|
|
981
|
+
if (!exited) {
|
|
982
|
+
throw new Error(`session ${id} did not exit after its kill`);
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
this.unconfirmedKills.delete(id);
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
// Waits for a killed harness to exit, ending it with a signal it cannot
|
|
989
|
+
// ignore when the first wait runs out and its provider can send one.
|
|
990
|
+
private async waitForKilledExit(pty: HarnessHandle): Promise<boolean> {
|
|
991
|
+
const exited = await pty.waitForExit(FAILED_SPAWN_EXIT_WAIT_MS);
|
|
992
|
+
|
|
993
|
+
if (exited) {
|
|
994
|
+
return true;
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
if (pty.killForced === undefined) {
|
|
998
|
+
return false;
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
pty.killForced();
|
|
1002
|
+
|
|
1003
|
+
return pty.waitForExit(FAILED_SPAWN_EXIT_WAIT_MS);
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
// Whether a failed spawn's rollback leaves the session free to revive:
|
|
1007
|
+
// none is running, and any process it killed is gone now. A session whose
|
|
1008
|
+
// killed process may still run stays blocked until a later check finds it
|
|
1009
|
+
// gone.
|
|
1010
|
+
private async isRollbackSettled(id: SessionID): Promise<boolean> {
|
|
1011
|
+
if (this.rollingBack.has(id)) {
|
|
1012
|
+
return false;
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
const killed = this.unconfirmedKills.get(id);
|
|
1016
|
+
|
|
1017
|
+
if (killed !== undefined) {
|
|
1018
|
+
const gone = await killed.waitForExit(0);
|
|
1019
|
+
|
|
1020
|
+
if (!gone) {
|
|
1021
|
+
return false;
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
this.unconfirmedKills.delete(id);
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
return true;
|
|
913
1028
|
}
|
|
914
1029
|
|
|
915
1030
|
collectDescriptors(): SessionDescriptor[] {
|
|
@@ -1147,7 +1262,8 @@ export class SessionManager {
|
|
|
1147
1262
|
// every harness on it kept inside, and fails whole when the host stays
|
|
1148
1263
|
// awake. A dead session on a target that can destroy its host is not
|
|
1149
1264
|
// forgotten by a kill: forgetting it destroys the host, which takes a
|
|
1150
|
-
// confirmed forget.
|
|
1265
|
+
// confirmed forget. The set is taken before the first await, so a
|
|
1266
|
+
// sub-session spawned while the kill waits on a host is not part of it.
|
|
1151
1267
|
async kill(id: SessionID): Promise<void> {
|
|
1152
1268
|
const s = this.sessions.find((x) => x.id === id);
|
|
1153
1269
|
|
|
@@ -1155,11 +1271,15 @@ export class SessionManager {
|
|
|
1155
1271
|
return;
|
|
1156
1272
|
}
|
|
1157
1273
|
|
|
1274
|
+
const children = this.collectChildren(id);
|
|
1275
|
+
|
|
1158
1276
|
if (s.pty) {
|
|
1159
1277
|
await this.stopHarness(s);
|
|
1160
1278
|
|
|
1161
|
-
for (const child of
|
|
1162
|
-
|
|
1279
|
+
for (const child of children) {
|
|
1280
|
+
if (this.sessions.includes(child) && child.parent === id) {
|
|
1281
|
+
await this.tryStopHarness(child);
|
|
1282
|
+
}
|
|
1163
1283
|
}
|
|
1164
1284
|
} else {
|
|
1165
1285
|
if (this.findProvider(s)?.capabilities.destroy === true) {
|
|
@@ -1170,8 +1290,7 @@ export class SessionManager {
|
|
|
1170
1290
|
);
|
|
1171
1291
|
}
|
|
1172
1292
|
|
|
1173
|
-
this.
|
|
1174
|
-
this.remove(s);
|
|
1293
|
+
this.removeWithChildren(s, children);
|
|
1175
1294
|
}
|
|
1176
1295
|
|
|
1177
1296
|
await this.writeFleet();
|
|
@@ -1187,7 +1306,9 @@ export class SessionManager {
|
|
|
1187
1306
|
* kept asleep in that host is refused until the host wakes. Its dead
|
|
1188
1307
|
* sub-sessions on other hosts go with it, unless their own target can
|
|
1189
1308
|
* destroy their host, and its live ones become top-level. A failed destroy
|
|
1190
|
-
* throws before anything is forgotten.
|
|
1309
|
+
* throws before anything is forgotten. The sub-sessions it may forget are
|
|
1310
|
+
* taken before the first await; one spawned while the forget waits on a
|
|
1311
|
+
* host becomes top-level.
|
|
1191
1312
|
*/
|
|
1192
1313
|
async forget(id: SessionID): Promise<boolean> {
|
|
1193
1314
|
const s = this.sessions.find((x) => x.id === id);
|
|
@@ -1196,6 +1317,7 @@ export class SessionManager {
|
|
|
1196
1317
|
return false;
|
|
1197
1318
|
}
|
|
1198
1319
|
|
|
1320
|
+
const children = this.collectChildren(id);
|
|
1199
1321
|
const provider = this.findProvider(s);
|
|
1200
1322
|
const destroys = provider !== null && provider.capabilities.destroy && s.hostKey === s.id;
|
|
1201
1323
|
|
|
@@ -1229,8 +1351,7 @@ export class SessionManager {
|
|
|
1229
1351
|
this.killTerminal(s);
|
|
1230
1352
|
}
|
|
1231
1353
|
|
|
1232
|
-
this.
|
|
1233
|
-
this.remove(s);
|
|
1354
|
+
this.removeWithChildren(s, children);
|
|
1234
1355
|
|
|
1235
1356
|
await this.writeFleet();
|
|
1236
1357
|
|
|
@@ -1239,22 +1360,42 @@ export class SessionManager {
|
|
|
1239
1360
|
return destroys;
|
|
1240
1361
|
}
|
|
1241
1362
|
|
|
1242
|
-
//
|
|
1243
|
-
// target can destroy its host: forgetting that one destroys the host,
|
|
1244
|
-
// takes its own confirmed forget.
|
|
1245
|
-
// top-level
|
|
1246
|
-
|
|
1247
|
-
|
|
1363
|
+
// Removes a forgotten session and its dead sub-sessions, except one whose
|
|
1364
|
+
// own target can destroy its host: forgetting that one destroys the host,
|
|
1365
|
+
// which takes its own confirmed forget. Only a sub-session among the given
|
|
1366
|
+
// ones may go. Every sub-session that stays becomes top-level before the
|
|
1367
|
+
// session's removal is announced, and is announced after it, so no
|
|
1368
|
+
// announcement ever finds a sub-session whose parent is gone or a parent
|
|
1369
|
+
// standing without the sub-sessions that leave with it.
|
|
1370
|
+
private removeWithChildren(s: Session, forgettable: readonly Session[]): void {
|
|
1371
|
+
const kept: Session[] = [];
|
|
1372
|
+
const dropped: Session[] = [];
|
|
1373
|
+
|
|
1374
|
+
for (const child of this.collectChildren(s.id)) {
|
|
1248
1375
|
const live = child.pty !== null || (child.kind === 'headless' && child.state !== 'exited');
|
|
1249
1376
|
|
|
1250
|
-
if (
|
|
1377
|
+
if (
|
|
1378
|
+
live ||
|
|
1379
|
+
!forgettable.includes(child) ||
|
|
1380
|
+
this.findProvider(child)?.capabilities.destroy === true
|
|
1381
|
+
) {
|
|
1251
1382
|
child.parent = null;
|
|
1252
1383
|
|
|
1253
|
-
|
|
1384
|
+
kept.push(child);
|
|
1254
1385
|
} else {
|
|
1255
|
-
|
|
1386
|
+
dropped.push(child);
|
|
1256
1387
|
}
|
|
1257
1388
|
}
|
|
1389
|
+
|
|
1390
|
+
this.remove(s);
|
|
1391
|
+
|
|
1392
|
+
for (const child of kept) {
|
|
1393
|
+
this.onEvent('state', child);
|
|
1394
|
+
}
|
|
1395
|
+
|
|
1396
|
+
for (const child of dropped) {
|
|
1397
|
+
this.remove(child);
|
|
1398
|
+
}
|
|
1258
1399
|
}
|
|
1259
1400
|
|
|
1260
1401
|
/**
|
|
@@ -1482,95 +1623,6 @@ export class SessionManager {
|
|
|
1482
1623
|
}
|
|
1483
1624
|
}
|
|
1484
1625
|
|
|
1485
|
-
export function countSessionStates(
|
|
1486
|
-
list: readonly { readonly state: SessionState }[],
|
|
1487
|
-
): Record<SessionState, number> {
|
|
1488
|
-
const c = { needs_you: 0, running: 0, done: 0, exited: 0 };
|
|
1489
|
-
|
|
1490
|
-
for (const s of list) {
|
|
1491
|
-
c[s.state]++;
|
|
1492
|
-
}
|
|
1493
|
-
|
|
1494
|
-
return c;
|
|
1495
|
-
}
|
|
1496
|
-
|
|
1497
|
-
interface SortableSessionView {
|
|
1498
|
-
readonly id: string;
|
|
1499
|
-
readonly parent: string | null;
|
|
1500
|
-
readonly state: SessionState;
|
|
1501
|
-
readonly pinned: boolean;
|
|
1502
|
-
readonly lastAttachedAt: number;
|
|
1503
|
-
readonly createdAt: number;
|
|
1504
|
-
}
|
|
1505
|
-
|
|
1506
|
-
// Overlay order: pinned sessions first in most-recently-attached order, then
|
|
1507
|
-
// everyone else by urgency — who needs you, finished turns, busy, dead —
|
|
1508
|
-
// with most-recently-attached breaking ties inside each state. A
|
|
1509
|
-
// sub-session sits directly under its parent, ranked among its siblings
|
|
1510
|
-
// alone, so its attention never moves the parent's row; a sub-session whose
|
|
1511
|
-
// parent is not listed ranks as a top-level row.
|
|
1512
|
-
export function sortSessionViews<T extends SortableSessionView>(list: readonly T[]): T[] {
|
|
1513
|
-
const rank: Record<SessionState, number> = {
|
|
1514
|
-
needs_you: 0,
|
|
1515
|
-
done: 1,
|
|
1516
|
-
running: 2,
|
|
1517
|
-
exited: 3,
|
|
1518
|
-
};
|
|
1519
|
-
|
|
1520
|
-
const ranked = [...list].toSorted((a, b) => {
|
|
1521
|
-
if (a.pinned !== b.pinned) {
|
|
1522
|
-
return a.pinned ? -1 : 1;
|
|
1523
|
-
}
|
|
1524
|
-
|
|
1525
|
-
const recency = b.lastAttachedAt - a.lastAttachedAt || b.createdAt - a.createdAt;
|
|
1526
|
-
|
|
1527
|
-
return a.pinned ? recency : rank[a.state] - rank[b.state] || recency;
|
|
1528
|
-
});
|
|
1529
|
-
|
|
1530
|
-
const listed = new Set(ranked.map((s) => s.id));
|
|
1531
|
-
|
|
1532
|
-
const sorted: T[] = [];
|
|
1533
|
-
|
|
1534
|
-
for (const s of ranked) {
|
|
1535
|
-
if (s.parent !== null && listed.has(s.parent)) {
|
|
1536
|
-
continue;
|
|
1537
|
-
}
|
|
1538
|
-
|
|
1539
|
-
sorted.push(s, ...ranked.filter((child) => child.parent === s.id));
|
|
1540
|
-
}
|
|
1541
|
-
|
|
1542
|
-
return sorted;
|
|
1543
|
-
}
|
|
1544
|
-
|
|
1545
|
-
// Never a filesystem path, so a repository can't collide with it.
|
|
1546
|
-
export const PINNED_GROUP_KEY = ' pinned';
|
|
1547
|
-
|
|
1548
|
-
// Overlay display order for the grouped view: the flat sort with each
|
|
1549
|
-
// repository's sessions pulled together at the position of its best-ranked
|
|
1550
|
-
// member, so the renderer's adjacency-based headers appear once per group.
|
|
1551
|
-
// Pinned sessions form their own leading group. A sub-session keys by its
|
|
1552
|
-
// parent, so a set never splits across groups.
|
|
1553
|
-
export function sortGroupedSessionViews<
|
|
1554
|
-
T extends SortableSessionView & { readonly repoRoot: string },
|
|
1555
|
-
>(list: readonly T[]): T[] {
|
|
1556
|
-
const byID = new Map(list.map((s) => [s.id, s]));
|
|
1557
|
-
const buckets = new Map<string, T[]>();
|
|
1558
|
-
|
|
1559
|
-
for (const s of sortSessionViews(list)) {
|
|
1560
|
-
const owner = (s.parent === null ? undefined : byID.get(s.parent)) ?? s;
|
|
1561
|
-
const key = owner.pinned ? PINNED_GROUP_KEY : owner.repoRoot;
|
|
1562
|
-
const bucket = buckets.get(key);
|
|
1563
|
-
|
|
1564
|
-
if (bucket === undefined) {
|
|
1565
|
-
buckets.set(key, [s]);
|
|
1566
|
-
} else {
|
|
1567
|
-
bucket.push(s);
|
|
1568
|
-
}
|
|
1569
|
-
}
|
|
1570
|
-
|
|
1571
|
-
return [...buckets.values()].flat();
|
|
1572
|
-
}
|
|
1573
|
-
|
|
1574
1626
|
function buildLifecycle(s: Session): SessionLifecycle {
|
|
1575
1627
|
return buildSessionLifecycle({
|
|
1576
1628
|
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';
|
|
@@ -4,6 +4,23 @@ import type { SessionID } from '../shared/session-id';
|
|
|
4
4
|
interface TapEntry<TClient> {
|
|
5
5
|
readonly client: TClient;
|
|
6
6
|
readonly claimed: Set<MessageID>;
|
|
7
|
+
|
|
8
|
+
// Whether the tap also takes messages held under the session's agent
|
|
9
|
+
// session id, as opposed to its atc id alone.
|
|
10
|
+
readonly linked: boolean;
|
|
11
|
+
|
|
12
|
+
// Which attach made this tap; every attach makes a new one.
|
|
13
|
+
readonly generation: number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* A session's tap as a drain finds it before it waits: the attach that made
|
|
18
|
+
* it and whether it is linked. A delivery made for it lands only while the
|
|
19
|
+
* same attach holds the tap.
|
|
20
|
+
*/
|
|
21
|
+
export interface TapGeneration {
|
|
22
|
+
readonly generation: number;
|
|
23
|
+
readonly linked: boolean;
|
|
7
24
|
}
|
|
8
25
|
|
|
9
26
|
/**
|
|
@@ -13,14 +30,24 @@ interface TapEntry<TClient> {
|
|
|
13
30
|
export class TapRegistry<TClient> {
|
|
14
31
|
private readonly bySession = new Map<SessionID, TapEntry<TClient>>();
|
|
15
32
|
|
|
33
|
+
private nextGeneration = 0;
|
|
34
|
+
|
|
16
35
|
/**
|
|
17
36
|
* Makes the client the session's tap and returns the client it displaced,
|
|
18
|
-
* or null when there was none or it is the same connection.
|
|
37
|
+
* or null when there was none or it is the same connection. A linked tap
|
|
38
|
+
* also takes the messages held under the session's agent session id.
|
|
19
39
|
*/
|
|
20
|
-
attach(sessionID: SessionID, client: TClient): TClient | null {
|
|
40
|
+
attach(sessionID: SessionID, client: TClient, linked = true): TClient | null {
|
|
21
41
|
const previous = this.bySession.get(sessionID)?.client ?? null;
|
|
22
42
|
|
|
23
|
-
this.
|
|
43
|
+
this.nextGeneration += 1;
|
|
44
|
+
|
|
45
|
+
this.bySession.set(sessionID, {
|
|
46
|
+
client,
|
|
47
|
+
claimed: new Set(),
|
|
48
|
+
linked,
|
|
49
|
+
generation: this.nextGeneration,
|
|
50
|
+
});
|
|
24
51
|
|
|
25
52
|
return previous === client ? null : previous;
|
|
26
53
|
}
|
|
@@ -45,6 +72,12 @@ export class TapRegistry<TClient> {
|
|
|
45
72
|
return previous;
|
|
46
73
|
}
|
|
47
74
|
|
|
75
|
+
findTap(sessionID: SessionID): TapGeneration | null {
|
|
76
|
+
const entry = this.bySession.get(sessionID);
|
|
77
|
+
|
|
78
|
+
return entry === undefined ? null : { generation: entry.generation, linked: entry.linked };
|
|
79
|
+
}
|
|
80
|
+
|
|
48
81
|
hasTap(sessionID: SessionID): boolean {
|
|
49
82
|
return this.bySession.has(sessionID);
|
|
50
83
|
}
|
|
@@ -53,10 +86,15 @@ export class TapRegistry<TClient> {
|
|
|
53
86
|
return this.bySession.get(sessionID)?.client === client;
|
|
54
87
|
}
|
|
55
88
|
|
|
56
|
-
|
|
89
|
+
/**
|
|
90
|
+
* Claims the message for the session's tap and returns its client, or
|
|
91
|
+
* null when the tap already took it or another attach replaced the tap
|
|
92
|
+
* the delivery was made for.
|
|
93
|
+
*/
|
|
94
|
+
claimDelivery(sessionID: SessionID, messageID: MessageID, generation: number): TClient | null {
|
|
57
95
|
const entry = this.bySession.get(sessionID);
|
|
58
96
|
|
|
59
|
-
if (entry === undefined || entry.claimed.has(messageID)) {
|
|
97
|
+
if (entry === undefined || entry.generation !== generation || entry.claimed.has(messageID)) {
|
|
60
98
|
return null;
|
|
61
99
|
}
|
|
62
100
|
|
|
@@ -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;
|