@zgeoff/atc 3.4.1 → 3.5.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/package.json +1 -1
- package/src/agents/agent-adapter.ts +4 -0
- package/src/agents/build-claude-query-options.ts +1 -0
- package/src/agents/build-headless-env.ts +4 -0
- package/src/agents/claude-adapter.ts +4 -3
- package/src/agents/gateway-adapter.ts +2 -2
- package/src/agents/plan-claude-line-input.ts +44 -0
- package/src/client/daemon-client.ts +6 -1
- package/src/collect-restart-env.ts +6 -3
- package/src/daemon/build-scoped-context.ts +2 -0
- package/src/daemon/check-session-scope.ts +235 -0
- package/src/daemon/daemon-connection.ts +74 -0
- package/src/daemon/daemon-context.ts +21 -0
- package/src/daemon/daemon.ts +21 -0
- package/src/daemon/find-host-branch.ts +28 -0
- package/src/daemon/materialize-workspace.ts +6 -1
- package/src/daemon/merge-recorded-scope.ts +50 -0
- package/src/daemon/published-records.ts +269 -0
- package/src/daemon/request-access-classes.ts +1 -0
- package/src/daemon/run-host-git.ts +52 -0
- package/src/daemon/sessions.ts +185 -53
- package/src/daemon/start-headless-turn.ts +3 -0
- package/src/federation/id-rules.ts +11 -2
- package/src/mcp/mcp-tools.ts +51 -1
- package/src/mcp/require-daemon-features.ts +1 -0
- package/src/mcp/run-tool.ts +14 -0
- package/src/protocol/daemon-features.ts +5 -0
- package/src/protocol/parse-declared-scope.ts +158 -0
- package/src/protocol/protocol.ts +1 -0
- package/src/protocol/published-record.ts +62 -0
- package/src/protocol/request-param-schemas.ts +16 -0
- package/src/store/parse-published-record.ts +59 -0
- package/src/store/run-migrations.ts +23 -0
- package/src/store/state-store.ts +32 -0
- package/src/test-utils/build-mock-published-record.ts +39 -0
- package/src/test-utils/build-stub-brokered-agent-adapter.ts +3 -1
- package/src/test-utils/build-stub-daemon-context.ts +2 -0
- package/src/test-utils/build-stub-daemon-requests.ts +3 -3
- package/src/test-utils/build-stub-harness-relay.ts +2 -2
- package/src/test-utils/build-stub-held-gh.ts +5 -7
- package/src/test-utils/build-stub-held-inbox-source.ts +2 -2
- package/src/test-utils/build-stub-held-zoxide.ts +4 -6
- package/src/test-utils/build-stub-host-hold.ts +7 -6
- package/src/test-utils/build-stub-launch-admission.ts +20 -0
- package/src/test-utils/build-stub-pregranted-broker-host.ts +22 -0
- package/src/test-utils/build-stub-terminal.ts +3 -3
- package/src/test-utils/create-git-fixture.ts +2 -2
- package/src/test-utils/create-stub-claude.ts +3 -1
- package/src/test-utils/create-stub-imp-port.ts +46 -37
- package/src/test-utils/create-stub-systemd.ts +6 -6
- package/src/test-utils/isolate-home.ts +8 -5
- package/src/test-utils/read-query-plan.ts +10 -6
- package/src/test-utils/register-test-cleanup.ts +10 -10
- package/src/test-utils/{run-legacy-daemon.ts → run-stub-legacy-daemon.ts} +1 -1
- package/src/test-utils/setup-mcp-home.ts +7 -6
- package/src/test-utils/setup-mcp-http.ts +6 -6
- package/src/test-utils/setup-temp-dir.ts +4 -10
- package/src/test-utils/start-cut-proxy.ts +2 -4
- package/src/test-utils/start-daemon-process.ts +13 -13
- package/src/test-utils/start-git-http-server.ts +2 -4
- package/src/test-utils/start-mcp-stdio.ts +5 -5
- package/src/test-utils/start-stub-answering-listener.ts +3 -3
- package/src/test-utils/start-stub-closing-listener.ts +3 -3
- package/src/test-utils/start-stub-dropping-daemon.ts +2 -3
- package/src/test-utils/start-stub-impd-info.ts +3 -7
- package/src/test-utils/start-stub-impd.ts +3 -5
- package/src/test-utils/start-stub-legacy-daemon.ts +2 -3
- package/src/test-utils/start-stub-mcp-server.ts +3 -5
- package/src/test-utils/start-stub-recording-listener.ts +3 -3
- package/src/test-utils/start-stub-refusing-listener.ts +3 -3
- package/src/test-utils/start-stub-reporter-socket.ts +3 -9
- package/src/test-utils/start-stub-restore-daemon.ts +2 -7
- package/src/test-utils/start-stub-session-bridge.ts +3 -9
- package/src/test-utils/start-stub-silent-server.ts +3 -6
- package/src/test-utils/start-stub-source-daemon.ts +3 -3
- package/src/test-utils/start-stub-stalled-client.ts +3 -3
- package/src/test-utils/start-stub-stalled-listener.ts +3 -3
- package/src/test-utils/start-stub-tcp-daemon.ts +3 -3
- package/src/test-utils/start-stub-telemetry-collector.ts +2 -2
- package/src/test-utils/start-stub-unanswering-listener.ts +3 -3
- package/src/test-utils/start-stub-unanswering-unix-listener.ts +3 -3
- package/src/test-utils/start-test-daemon.ts +62 -18
- package/src/test-utils/start-tui-harness.ts +17 -10
- package/src/test-utils/subscribe-to-socket-lines.ts +5 -9
- package/src/test-utils/{can-bind-addresses.ts → try-bind-addresses.ts} +4 -3
- /package/src/test-utils/{run-source-daemon.ts → run-stub-source-daemon.ts} +0 -0
package/src/daemon/sessions.ts
CHANGED
|
@@ -12,7 +12,9 @@ import type { AdapterEvent } from '../protocol/adapter-event';
|
|
|
12
12
|
import { countSessionStates } from '../protocol/count-session-states';
|
|
13
13
|
import { DaemonError } from '../protocol/daemon-error';
|
|
14
14
|
import type { HookEvent } from '../protocol/hook-event';
|
|
15
|
+
import type { DeclaredScope } from '../protocol/parse-declared-scope';
|
|
15
16
|
import type { ErrorCode } from '../protocol/protocol';
|
|
17
|
+
import type { PublishedRecord } from '../protocol/published-record';
|
|
16
18
|
import type { SessionState } from '../protocol/session-state';
|
|
17
19
|
import { sortSessionViews } from '../protocol/sort-session-views';
|
|
18
20
|
import type { AgentID } from '../shared/agent-id';
|
|
@@ -50,6 +52,7 @@ import { loadOAuthStates } from './load-oauth-states';
|
|
|
50
52
|
import { LocalPTYProvider } from './local-pty-provider';
|
|
51
53
|
import { mintSessionID } from './mint-session-id';
|
|
52
54
|
import { pickSessionState } from './pick-session-state';
|
|
55
|
+
import type { PublishedRecords } from './published-records';
|
|
53
56
|
import type { RuntimeAuthBinder } from './runtime-auth-binder';
|
|
54
57
|
|
|
55
58
|
export type SessionEventKind = 'added' | 'state' | 'renamed' | 'removed';
|
|
@@ -191,6 +194,9 @@ export interface Session {
|
|
|
191
194
|
// A session's ready workspace and the variables its harnesses go without.
|
|
192
195
|
interface MaterializedSpawn {
|
|
193
196
|
readonly workspace: SessionWorkspace;
|
|
197
|
+
|
|
198
|
+
// The branch the checkout is on, null for a detached checkout.
|
|
199
|
+
readonly branch: string | null;
|
|
194
200
|
readonly withheldEnv: readonly string[];
|
|
195
201
|
}
|
|
196
202
|
|
|
@@ -354,6 +360,10 @@ export class SessionManager {
|
|
|
354
360
|
// from impd's broker; with none, every such start is refused.
|
|
355
361
|
authBinder: RuntimeAuthBinder | null = null;
|
|
356
362
|
|
|
363
|
+
// Publishes each session's record before its harness starts; with none,
|
|
364
|
+
// sessions start without a record.
|
|
365
|
+
records: PublishedRecords | null = null;
|
|
366
|
+
|
|
357
367
|
// Whether any registered adapter has a screen detector, decided once at
|
|
358
368
|
// construction since the registry never changes afterward. Lets a hot path
|
|
359
369
|
// that only cares about this answer skip walking live sessions to find it.
|
|
@@ -709,25 +719,48 @@ export class SessionManager {
|
|
|
709
719
|
this.adopting.add(id);
|
|
710
720
|
|
|
711
721
|
let plan: HarnessPlan;
|
|
722
|
+
let recordPath: string | null;
|
|
723
|
+
const resume = s.agentSessionID;
|
|
712
724
|
|
|
725
|
+
// The record is placed again while the host still counts as readying,
|
|
726
|
+
// so a host that lost its copy, such as one that booted cold, has it
|
|
727
|
+
// back before the harness starts.
|
|
713
728
|
try {
|
|
714
|
-
const setup = await this.
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
+
const setup = await this.withHostReadying(s.hostKey, async () => {
|
|
730
|
+
const planned = await this.setupHarnessOnHost(
|
|
731
|
+
adapter,
|
|
732
|
+
provider,
|
|
733
|
+
s.id,
|
|
734
|
+
s.hostKey,
|
|
735
|
+
s.target,
|
|
736
|
+
{
|
|
737
|
+
prompt: '',
|
|
738
|
+
resume,
|
|
739
|
+
...(s.model === undefined ? {} : { model: s.model }),
|
|
740
|
+
...(s.effort === undefined ? {} : { effort: s.effort }),
|
|
741
|
+
},
|
|
742
|
+
authSetup,
|
|
743
|
+
s.cwd,
|
|
744
|
+
false,
|
|
745
|
+
);
|
|
746
|
+
|
|
747
|
+
const path =
|
|
748
|
+
this.records === null
|
|
749
|
+
? null
|
|
750
|
+
: await this.records.restoreCopy({
|
|
751
|
+
session: s.id,
|
|
752
|
+
target: s.target,
|
|
753
|
+
provider,
|
|
754
|
+
host: s.hostKey,
|
|
755
|
+
dir: s.cwd,
|
|
756
|
+
workspace: s.workspace ?? null,
|
|
757
|
+
});
|
|
758
|
+
|
|
759
|
+
return { plan: planned.plan, recordPath: path };
|
|
760
|
+
});
|
|
729
761
|
|
|
730
762
|
plan = setup.plan;
|
|
763
|
+
recordPath = setup.recordPath;
|
|
731
764
|
} finally {
|
|
732
765
|
this.adopting.delete(id);
|
|
733
766
|
}
|
|
@@ -753,7 +786,12 @@ export class SessionManager {
|
|
|
753
786
|
bin: plan.bin,
|
|
754
787
|
args: plan.args,
|
|
755
788
|
cwd: s.cwd,
|
|
756
|
-
env: {
|
|
789
|
+
env: {
|
|
790
|
+
...plan.env,
|
|
791
|
+
ATC_SESSION_ID: s.id,
|
|
792
|
+
ATC_SOCKET: socketPath,
|
|
793
|
+
...(recordPath === null ? {} : { ATC_SESSION_RECORD: recordPath }),
|
|
794
|
+
},
|
|
757
795
|
withheldEnv: s.withheldEnv,
|
|
758
796
|
cols,
|
|
759
797
|
rows,
|
|
@@ -842,6 +880,67 @@ export class SessionManager {
|
|
|
842
880
|
});
|
|
843
881
|
}
|
|
844
882
|
|
|
883
|
+
// Adds a checked scope to a session's published record and returns the
|
|
884
|
+
// record as it stands after. The checks run on the session's host, so a
|
|
885
|
+
// remote host that is not awake refuses the change until a revive wakes
|
|
886
|
+
// it.
|
|
887
|
+
async updateScope(id: SessionID, scope: DeclaredScope): Promise<PublishedRecord | 'missing'> {
|
|
888
|
+
const s = this.sessions.find((x) => x.id === id);
|
|
889
|
+
|
|
890
|
+
if (s === undefined) {
|
|
891
|
+
return 'missing';
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
if (this.records === null) {
|
|
895
|
+
throw new DaemonError('unsupported', 'this daemon publishes no session records');
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
const provider = this.requireExecution(s, 'spawn').provider;
|
|
899
|
+
|
|
900
|
+
if (provider.remote && s.vm !== 'awake') {
|
|
901
|
+
throw new DaemonError(
|
|
902
|
+
'host_unavailable',
|
|
903
|
+
`the host of session ${id} is not awake; revive the session before adding to its scope`,
|
|
904
|
+
{ provider: provider.kind, problem: 'host_asleep', host: s.hostKey },
|
|
905
|
+
);
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
const subject = {
|
|
909
|
+
session: s.id,
|
|
910
|
+
target: s.target,
|
|
911
|
+
provider,
|
|
912
|
+
host: s.hostKey,
|
|
913
|
+
dir: s.cwd,
|
|
914
|
+
workspace: s.workspace ?? null,
|
|
915
|
+
};
|
|
916
|
+
|
|
917
|
+
const record = await this.records.updateScope(subject, scope);
|
|
918
|
+
|
|
919
|
+
return record;
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
// Whether the session is the caller or a session the caller sits under,
|
|
923
|
+
// following each parent link the fleet holds.
|
|
924
|
+
isCallerTree(id: SessionID, caller: SessionID): boolean {
|
|
925
|
+
let current: SessionID | null = caller;
|
|
926
|
+
|
|
927
|
+
const seen = new Set<SessionID>();
|
|
928
|
+
|
|
929
|
+
while (current !== null && !seen.has(current)) {
|
|
930
|
+
if (current === id) {
|
|
931
|
+
return true;
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
seen.add(current);
|
|
935
|
+
|
|
936
|
+
const at: SessionID = current;
|
|
937
|
+
|
|
938
|
+
current = this.sessions.find((x) => x.id === at)?.parent ?? null;
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
return false;
|
|
942
|
+
}
|
|
943
|
+
|
|
845
944
|
/**
|
|
846
945
|
* Renames and/or pins a session on a caller's behalf. A rename lands at
|
|
847
946
|
* user strength, so auto-summaries stop overwriting it while an
|
|
@@ -957,6 +1056,7 @@ export class SessionManager {
|
|
|
957
1056
|
materialize: SpawnMaterializer | null = null,
|
|
958
1057
|
requireInReach: () => void = () => {},
|
|
959
1058
|
autoDir = false,
|
|
1059
|
+
scope: DeclaredScope | null = null,
|
|
960
1060
|
): Promise<Session> {
|
|
961
1061
|
const adapter = this.findAdapter(agent);
|
|
962
1062
|
|
|
@@ -1091,23 +1191,51 @@ export class SessionManager {
|
|
|
1091
1191
|
|
|
1092
1192
|
// The host stays readying until its workspace is in place, so nothing
|
|
1093
1193
|
// gives its lease back or puts it to sleep in between.
|
|
1194
|
+
// The record is published while the host still counts as readying, so
|
|
1195
|
+
// nothing sleeps it between the record and the harness start.
|
|
1196
|
+
const published: { path: string | null; failure: { readonly error: unknown } | null } = {
|
|
1197
|
+
path: null,
|
|
1198
|
+
failure: null,
|
|
1199
|
+
};
|
|
1200
|
+
|
|
1094
1201
|
const prepared = await this.withHostReadying(hostKey, async () => {
|
|
1095
|
-
|
|
1096
|
-
|
|
1202
|
+
const ready =
|
|
1203
|
+
materialize === null
|
|
1204
|
+
? { setup: await setupHost(), materialized: null, root: null }
|
|
1205
|
+
: await this.materializeOnSpawnHost(
|
|
1206
|
+
provider,
|
|
1207
|
+
id,
|
|
1208
|
+
hostKey,
|
|
1209
|
+
target,
|
|
1210
|
+
cwd,
|
|
1211
|
+
execution.identity,
|
|
1212
|
+
materialize,
|
|
1213
|
+
setupHost,
|
|
1214
|
+
checkWorkspace,
|
|
1215
|
+
trustWorkspace,
|
|
1216
|
+
);
|
|
1217
|
+
|
|
1218
|
+
try {
|
|
1219
|
+
published.path =
|
|
1220
|
+
this.records === null
|
|
1221
|
+
? null
|
|
1222
|
+
: await this.records.createRecord(
|
|
1223
|
+
{
|
|
1224
|
+
session: id,
|
|
1225
|
+
target,
|
|
1226
|
+
provider,
|
|
1227
|
+
host: hostKey,
|
|
1228
|
+
dir: autoDir && ready.root !== null ? ready.root : cwd,
|
|
1229
|
+
workspace: ready.materialized?.workspace ?? null,
|
|
1230
|
+
...(ready.materialized === null ? {} : { branch: ready.materialized.branch }),
|
|
1231
|
+
},
|
|
1232
|
+
scope,
|
|
1233
|
+
);
|
|
1234
|
+
} catch (error) {
|
|
1235
|
+
published.failure = { error };
|
|
1097
1236
|
}
|
|
1098
1237
|
|
|
1099
|
-
return
|
|
1100
|
-
provider,
|
|
1101
|
-
id,
|
|
1102
|
-
hostKey,
|
|
1103
|
-
target,
|
|
1104
|
-
cwd,
|
|
1105
|
-
execution.identity,
|
|
1106
|
-
materialize,
|
|
1107
|
-
setupHost,
|
|
1108
|
-
checkWorkspace,
|
|
1109
|
-
trustWorkspace,
|
|
1110
|
-
);
|
|
1238
|
+
return ready;
|
|
1111
1239
|
});
|
|
1112
1240
|
|
|
1113
1241
|
// A spawn whose directory the daemon picked runs in the one it claimed,
|
|
@@ -1116,6 +1244,17 @@ export class SessionManager {
|
|
|
1116
1244
|
const setup = prepared.setup;
|
|
1117
1245
|
const materialized = prepared.materialized;
|
|
1118
1246
|
const readied: SpawnReadied = { attemptID: setup.attemptID, root: prepared.root };
|
|
1247
|
+
|
|
1248
|
+
// A scope that fails its check, or a record that cannot be placed,
|
|
1249
|
+
// takes back what the spawn readied before any harness starts.
|
|
1250
|
+
if (published.failure !== null) {
|
|
1251
|
+
await this.removeLocalTrust(localTrust.remove, id);
|
|
1252
|
+
await this.records?.remove(id);
|
|
1253
|
+
await this.removeFailedSpawnEffects(provider, id, hostKey, target, readied);
|
|
1254
|
+
|
|
1255
|
+
throw published.failure.error;
|
|
1256
|
+
}
|
|
1257
|
+
|
|
1119
1258
|
const plan = setup.plan;
|
|
1120
1259
|
const binding = this.mintBridgeBinding(id, target, execution.identity, hostKey);
|
|
1121
1260
|
let pty: HarnessHandle;
|
|
@@ -1132,7 +1271,12 @@ export class SessionManager {
|
|
|
1132
1271
|
bin: plan.bin,
|
|
1133
1272
|
args: plan.args,
|
|
1134
1273
|
cwd: dir,
|
|
1135
|
-
env: {
|
|
1274
|
+
env: {
|
|
1275
|
+
...plan.env,
|
|
1276
|
+
ATC_SESSION_ID: id,
|
|
1277
|
+
ATC_SOCKET: socketPath,
|
|
1278
|
+
...(published.path === null ? {} : { ATC_SESSION_RECORD: published.path }),
|
|
1279
|
+
},
|
|
1136
1280
|
withheldEnv: materialized?.withheldEnv ?? [],
|
|
1137
1281
|
cols,
|
|
1138
1282
|
rows,
|
|
@@ -1143,6 +1287,7 @@ export class SessionManager {
|
|
|
1143
1287
|
});
|
|
1144
1288
|
} catch (error) {
|
|
1145
1289
|
await this.removeLocalTrust(localTrust.remove, id);
|
|
1290
|
+
await this.records?.remove(id);
|
|
1146
1291
|
await this.removeFailedSpawnEffects(provider, id, hostKey, target, readied);
|
|
1147
1292
|
|
|
1148
1293
|
throw error;
|
|
@@ -1937,28 +2082,6 @@ export class SessionManager {
|
|
|
1937
2082
|
}
|
|
1938
2083
|
|
|
1939
2084
|
// Readies the host a harness is about to start on and plans the harness.
|
|
1940
|
-
// On a remote host the agent plans a guest spawn, whose files unpack into
|
|
1941
|
-
// the session's own guest folder, and the agent's sign-in check runs
|
|
1942
|
-
// there first. Every refusal comes before the harness starts. A harness
|
|
1943
|
-
// behind the broker has its binding created or verified before the host
|
|
1944
|
-
// is readied, and a binding this call created is taken back when a later
|
|
1945
|
-
// step fails; the attempt that created it comes back with the plan. A
|
|
1946
|
-
// spawn's new host of its own without a binding is destroyed instead.
|
|
1947
|
-
private setupHarness(
|
|
1948
|
-
adapter: AgentAdapter,
|
|
1949
|
-
provider: ExecutionProvider,
|
|
1950
|
-
id: SessionID,
|
|
1951
|
-
hostKey: SessionID,
|
|
1952
|
-
target: string,
|
|
1953
|
-
options: SpawnOptions,
|
|
1954
|
-
auth: HarnessAuthSetup | null,
|
|
1955
|
-
dir: string,
|
|
1956
|
-
): Promise<{ readonly plan: HarnessPlan; readonly attemptID: string | null }> {
|
|
1957
|
-
return this.withHostReadying(hostKey, () =>
|
|
1958
|
-
this.setupHarnessOnHost(adapter, provider, id, hostKey, target, options, auth, dir, false),
|
|
1959
|
-
);
|
|
1960
|
-
}
|
|
1961
|
-
|
|
1962
2085
|
// Counts a host as readying while run runs, so it is not idle then.
|
|
1963
2086
|
private async withHostReadying<T>(hostKey: SessionID, run: () => Promise<T>): Promise<T> {
|
|
1964
2087
|
this.readying.set(hostKey, (this.readying.get(hostKey) ?? 0) + 1);
|
|
@@ -1976,6 +2099,13 @@ export class SessionManager {
|
|
|
1976
2099
|
}
|
|
1977
2100
|
}
|
|
1978
2101
|
|
|
2102
|
+
// On a remote host the agent plans a guest spawn, whose files unpack into
|
|
2103
|
+
// the session's own guest folder, and the agent's sign-in check runs
|
|
2104
|
+
// there first. Every refusal comes before the harness starts. A harness
|
|
2105
|
+
// behind the broker has its binding created or verified before the host
|
|
2106
|
+
// is readied, and a binding this call created is taken back when a later
|
|
2107
|
+
// step fails; the attempt that created it comes back with the plan. A
|
|
2108
|
+
// spawn's new host of its own without a binding is destroyed instead.
|
|
1979
2109
|
private async setupHarnessOnHost(
|
|
1980
2110
|
adapter: AgentAdapter,
|
|
1981
2111
|
provider: ExecutionProvider,
|
|
@@ -3075,6 +3205,8 @@ export class SessionManager {
|
|
|
3075
3205
|
|
|
3076
3206
|
for (const id of removed) {
|
|
3077
3207
|
this.removedIDs.delete(id);
|
|
3208
|
+
|
|
3209
|
+
await this.records?.remove(id);
|
|
3078
3210
|
}
|
|
3079
3211
|
}
|
|
3080
3212
|
|
|
@@ -48,6 +48,9 @@ export function startHeadlessTurn(
|
|
|
48
48
|
...(s.model === undefined ? {} : { model: s.model }),
|
|
49
49
|
...(s.effort === undefined ? {} : { effort: s.effort }),
|
|
50
50
|
...(s.withheldEnv.length === 0 ? {} : { withheldEnv: s.withheldEnv }),
|
|
51
|
+
...(mgr.records === null || mgr.findProvider(s)?.remote !== false
|
|
52
|
+
? {}
|
|
53
|
+
: { recordPath: mgr.records.getLocalPath(sessionID) }),
|
|
51
54
|
},
|
|
52
55
|
{
|
|
53
56
|
onOutput: (text) => {
|
|
@@ -6,7 +6,8 @@
|
|
|
6
6
|
* `keep` passes a value that looks like an id but is none of atc's, such as
|
|
7
7
|
* an agent's own session id, while still rewriting ruled fields below it.
|
|
8
8
|
* `opaque` passes a whole value unread: text an agent or a terminal wrote,
|
|
9
|
-
*
|
|
9
|
+
* a cursor only the same daemon reads back, or the daemon id a session
|
|
10
|
+
* record holds as written.
|
|
10
11
|
*/
|
|
11
12
|
export type IDRule = 'id' | 'locator' | 'cursor' | 'keep' | 'opaque';
|
|
12
13
|
|
|
@@ -41,7 +42,15 @@ export const ID_RULES: Readonly<Record<string, ReadonlyMap<string, IDRule>>> = {
|
|
|
41
42
|
'session.forget': new Map([['confirmToken', 'opaque']]),
|
|
42
43
|
'session.list': buildPrefixedRules('sessions[].', DESCRIPTOR_RULES),
|
|
43
44
|
'session.spawn': buildPrefixedRules('session.', DESCRIPTOR_RULES),
|
|
44
|
-
'session.get':
|
|
45
|
+
'session.get': new Map([
|
|
46
|
+
...buildPrefixedRules('session.', DESCRIPTOR_RULES),
|
|
47
|
+
['sessionRecord.session', 'id'],
|
|
48
|
+
['sessionRecord.daemonID', 'opaque'],
|
|
49
|
+
]),
|
|
50
|
+
'session.scope.add': new Map([
|
|
51
|
+
['record.session', 'id'],
|
|
52
|
+
['record.daemonID', 'opaque'],
|
|
53
|
+
]),
|
|
45
54
|
'session.message': new Map([['message', 'id']]),
|
|
46
55
|
'message.get': new Map([
|
|
47
56
|
['message', 'id'],
|
package/src/mcp/mcp-tools.ts
CHANGED
|
@@ -41,6 +41,40 @@ const DIRS_INPUT: Readonly<Record<string, unknown>> = z.toJSONSchema(
|
|
|
41
41
|
{ io: 'input' },
|
|
42
42
|
);
|
|
43
43
|
|
|
44
|
+
// The scope a session may touch beyond its own workspace, which atc checks
|
|
45
|
+
// entry by entry on the session's host.
|
|
46
|
+
const SCOPE_WORKTREE = z.strictObject({ path: z.string() });
|
|
47
|
+
const SCOPE_BRANCH = z.strictObject({ name: z.string(), repo: z.string().optional() });
|
|
48
|
+
const SCOPE_PULL_REQUEST_NUMBER = z.number().int();
|
|
49
|
+
|
|
50
|
+
const SCOPE_PULL_REQUEST = z.strictObject({
|
|
51
|
+
number: SCOPE_PULL_REQUEST_NUMBER,
|
|
52
|
+
repo: z.string().optional(),
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
const SCOPE_FIELD = z
|
|
56
|
+
.strictObject({
|
|
57
|
+
worktrees: z
|
|
58
|
+
.array(SCOPE_WORKTREE)
|
|
59
|
+
.optional()
|
|
60
|
+
.describe('Absolute paths of git worktrees on the session host, each its worktree top level'),
|
|
61
|
+
branches: z
|
|
62
|
+
.array(SCOPE_BRANCH)
|
|
63
|
+
.optional()
|
|
64
|
+
.describe(
|
|
65
|
+
"Branches that exist in repo, an absolute repository path on the session host; repo defaults to the session's directory",
|
|
66
|
+
),
|
|
67
|
+
pullRequests: z
|
|
68
|
+
.array(SCOPE_PULL_REQUEST)
|
|
69
|
+
.optional()
|
|
70
|
+
.describe(
|
|
71
|
+
"GitHub pull requests of repo, as owner/name; repo defaults to the GitHub repository of the workspace's origin",
|
|
72
|
+
),
|
|
73
|
+
})
|
|
74
|
+
.describe(
|
|
75
|
+
"Worktrees, branches, and pull requests the session may touch beyond its own workspace. atc checks each entry on the session's host, refuses an invalid or unknown entry with scope_invalid naming it, and records the rest in the session's record, which the session reads at $ATC_SESSION_RECORD.",
|
|
76
|
+
);
|
|
77
|
+
|
|
44
78
|
const SPAWN_INPUT: Readonly<Record<string, unknown>> = z.toJSONSchema(
|
|
45
79
|
z.strictObject({
|
|
46
80
|
daemon: DAEMON_FIELD,
|
|
@@ -65,6 +99,7 @@ const SPAWN_INPUT: Readonly<Record<string, unknown>> = z.toJSONSchema(
|
|
|
65
99
|
trustClonedWorkspace: SPAWN_SCHEMA.shape.trustClonedWorkspace.describe(
|
|
66
100
|
"Trust the exact verified clone for this launch. An explicit true or false overrides the configured target trustClonedWorkspace default; omitting both keeps trust off. Requires a workspace source and either stock Claude on the local target, which adds trust for the clone root alone to the user's Claude config, or, on an imp target, a brokered Claude gateway or stock Claude signed in through the broker, each with isolated guest config; other launches are refused. Accepts repository configuration and helpers without changing tool permission mode. Existing guest config is preserved.",
|
|
67
101
|
),
|
|
102
|
+
scope: SCOPE_FIELD.optional(),
|
|
68
103
|
detached: z
|
|
69
104
|
.boolean()
|
|
70
105
|
.optional()
|
|
@@ -76,6 +111,11 @@ const SPAWN_INPUT: Readonly<Record<string, unknown>> = z.toJSONSchema(
|
|
|
76
111
|
{ io: 'input' },
|
|
77
112
|
);
|
|
78
113
|
|
|
114
|
+
const SCOPE_ADD_INPUT: Readonly<Record<string, unknown>> = z.toJSONSchema(
|
|
115
|
+
SESSION_ID_BASE.extend({ scope: SCOPE_FIELD }).strict(),
|
|
116
|
+
{ io: 'input' },
|
|
117
|
+
);
|
|
118
|
+
|
|
79
119
|
const SESSION_READ_INPUT: Readonly<Record<string, unknown>> = z.toJSONSchema(
|
|
80
120
|
SESSION_ID_BASE.extend({
|
|
81
121
|
cursor: z
|
|
@@ -420,6 +460,7 @@ export const MCP_TOOLS: readonly MCPToolDefinition[] = [
|
|
|
420
460
|
target: 'spawn.target',
|
|
421
461
|
workspace: 'spawn.workspace',
|
|
422
462
|
trustClonedWorkspace: 'spawn.workspace.trust',
|
|
463
|
+
scope: 'session.record',
|
|
423
464
|
},
|
|
424
465
|
},
|
|
425
466
|
},
|
|
@@ -428,7 +469,7 @@ export const MCP_TOOLS: readonly MCPToolDefinition[] = [
|
|
|
428
469
|
annotations: AGENT_FACING_DESTRUCTIVE,
|
|
429
470
|
scope: 'spawn',
|
|
430
471
|
description:
|
|
431
|
-
"Type a line of text into a running session and submit it, as if the operator typed it and pressed enter. atc submits the line the way the session's agent accepts one. Use it to answer a session that is waiting on input. A result of sent means atc wrote the line and its submit key to the session; it does not confirm that the agent took the line or answered it. Read the session's screen or events for that. The tool sends no raw keystrokes.",
|
|
472
|
+
"Type a line of text into a running session and submit it, as if the operator typed it and pressed enter. atc submits the line the way the session's agent accepts one. Use it to answer a session that is waiting on input. atc pastes the line, so its newlines stay in it, and Claude takes a line of about 800 characters or more as pasted text, not as typed words. On a Claude session, a line that starts with a slash command and an argument, such as /goal finish the release, has the command name typed and only the argument pasted, so the command runs at any length. A result of sent means atc wrote the line and its submit key to the session; it does not confirm that the agent took the line or answered it. Read the session's screen or events for that. The tool sends no raw keystrokes.",
|
|
432
473
|
inputSchema: {
|
|
433
474
|
type: 'object',
|
|
434
475
|
properties: {
|
|
@@ -450,6 +491,15 @@ export const MCP_TOOLS: readonly MCPToolDefinition[] = [
|
|
|
450
491
|
'Read the current terminal screen of a session as plain text, without attaching to it. Use it to see what a session printed or what it is waiting on before answering it with atc_session_input. A killed session keeps its last screen.',
|
|
451
492
|
inputSchema: SESSION_INPUT,
|
|
452
493
|
},
|
|
494
|
+
{
|
|
495
|
+
name: 'atc_session_scope_add',
|
|
496
|
+
annotations: ADDITIVE,
|
|
497
|
+
scope: 'spawn',
|
|
498
|
+
description:
|
|
499
|
+
"Add worktrees, branches, or pull requests to the scope a session's record holds, as checked by atc on the session's host. Entries the record already holds change nothing, and atc never removes an entry. A session can never add to its own scope or to that of a session it is a sub-session of; ask whoever started it. Returns the record as it stands after.",
|
|
500
|
+
inputSchema: SCOPE_ADD_INPUT,
|
|
501
|
+
requires: { tool: 'session.record' },
|
|
502
|
+
},
|
|
453
503
|
{
|
|
454
504
|
name: 'atc_session_update',
|
|
455
505
|
annotations: ADDITIVE,
|
|
@@ -27,6 +27,7 @@ const FEATURE_USES: Readonly<Record<DaemonFeature, string>> = {
|
|
|
27
27
|
'transport.tcp': 'a TCP connection to the daemon',
|
|
28
28
|
'idempotency.replayOnly': 'a resend that only replays a held idempotency key',
|
|
29
29
|
'session.auth': 'session.auth.revoke and session.auth.rebind',
|
|
30
|
+
'session.record': "atc_session_scope_add and atc_session_spawn's scope",
|
|
30
31
|
};
|
|
31
32
|
|
|
32
33
|
/**
|
package/src/mcp/run-tool.ts
CHANGED
|
@@ -58,6 +58,7 @@ export function runTool(
|
|
|
58
58
|
...(args['trustClonedWorkspace'] === undefined
|
|
59
59
|
? {}
|
|
60
60
|
: { trustClonedWorkspace: args['trustClonedWorkspace'] }),
|
|
61
|
+
...(args['scope'] === undefined ? {} : { scope: args['scope'] }),
|
|
61
62
|
cols: 100,
|
|
62
63
|
rows: 30,
|
|
63
64
|
};
|
|
@@ -85,6 +86,9 @@ export function runTool(
|
|
|
85
86
|
? ['spawn.workspace.autoDir']
|
|
86
87
|
: [];
|
|
87
88
|
|
|
89
|
+
const scopeFeatures: readonly DaemonFeature[] =
|
|
90
|
+
args['scope'] === undefined ? [] : ['session.record'];
|
|
91
|
+
|
|
88
92
|
const required = [
|
|
89
93
|
...optionFeatures,
|
|
90
94
|
...keyFeatures,
|
|
@@ -92,6 +96,7 @@ export function runTool(
|
|
|
92
96
|
...workspaceFeatures,
|
|
93
97
|
...trustFeatures,
|
|
94
98
|
...autoDirFeatures,
|
|
99
|
+
...scopeFeatures,
|
|
95
100
|
];
|
|
96
101
|
|
|
97
102
|
const ok =
|
|
@@ -141,6 +146,15 @@ export function runTool(
|
|
|
141
146
|
|
|
142
147
|
return { text: 'updated', structured: null };
|
|
143
148
|
})
|
|
149
|
+
.with('atc_session_scope_add', async () => {
|
|
150
|
+
const ok = await caller.sendRequest(
|
|
151
|
+
'session.scope.add',
|
|
152
|
+
{ session: args['session'], scope: args['scope'] },
|
|
153
|
+
['session.record'],
|
|
154
|
+
);
|
|
155
|
+
|
|
156
|
+
return { text: JSON.stringify(ok), structured: ok };
|
|
157
|
+
})
|
|
144
158
|
.with('atc_session_kill', async () => {
|
|
145
159
|
await caller.sendRequest('session.kill', { session: args['session'] });
|
|
146
160
|
|
|
@@ -90,6 +90,11 @@ export const DAEMON_FEATURES = [
|
|
|
90
90
|
// `session.auth.revoke` and `session.auth.rebind` exist, open to the
|
|
91
91
|
// daemon's owner only.
|
|
92
92
|
'session.auth',
|
|
93
|
+
|
|
94
|
+
// Every session has a published record: `session.spawn` takes `scope`,
|
|
95
|
+
// `session.scope.add` exists, `session.get` returns `sessionRecord`, and
|
|
96
|
+
// `daemon.hello` takes `session`, the session the client runs inside.
|
|
97
|
+
'session.record',
|
|
93
98
|
] as const;
|
|
94
99
|
|
|
95
100
|
export type DaemonFeature = (typeof DAEMON_FEATURES)[number];
|