@zgeoff/atc 2.11.0 → 2.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +10 -10
- package/package.json +2 -1
- package/src/agents/agent-adapter.ts +98 -10
- package/src/agents/build-args-without-flags.ts +25 -0
- package/src/agents/build-atc-bridge-files.ts +13 -0
- package/src/agents/build-claude-override-args.ts +26 -0
- package/src/agents/build-claude-query-options.ts +72 -0
- package/src/agents/build-cli-command.ts +7 -5
- package/src/{daemon → agents}/build-headless-env.ts +12 -6
- package/src/agents/build-hook-settings.ts +8 -2
- package/src/agents/build-restore-mode-args.ts +23 -0
- package/src/agents/claude-adapter.ts +112 -18
- package/src/agents/claude-effort-levels.ts +5 -0
- package/src/agents/codex-adapter.ts +48 -2
- package/src/agents/find-claude-permission-mode.ts +24 -0
- package/src/agents/find-flag-value.ts +27 -0
- package/src/agents/gateway-adapter.ts +83 -13
- package/src/agents/grok-adapter.ts +24 -1
- package/src/agents/make-claude-headless-runner.ts +46 -0
- package/src/agents/plan-pasted-line-input.ts +22 -0
- package/src/agents/plan-typed-line-input.ts +8 -0
- package/src/agents/resolve-claude-permission-mode.ts +13 -0
- package/src/{daemon/start-headless-run.ts → agents/start-claude-headless-run.ts} +6 -42
- package/src/agents/write-atc-bridge.ts +2 -6
- package/src/cli.ts +23 -9
- package/src/client/daemon-client.ts +14 -2
- package/src/client/index.ts +50 -1
- package/src/client/spawn-picker.ts +27 -4
- package/src/client/ui.ts +8 -0
- package/src/daemon/build-agent-list.ts +37 -1
- package/src/daemon/build-config-revision.ts +27 -0
- package/src/daemon/build-execution-targets.ts +73 -0
- package/src/daemon/build-fleet-events.ts +2 -1
- package/src/daemon/build-imp-provider.ts +68 -0
- package/src/daemon/build-payload-hash.ts +31 -0
- package/src/daemon/build-report-trail-entry.ts +4 -2
- package/src/daemon/build-scoped-context.ts +235 -0
- package/src/daemon/build-session-lifecycle.ts +52 -0
- package/src/daemon/build-tar-archive.ts +85 -0
- package/src/daemon/build-target-access.ts +33 -0
- package/src/daemon/build-target-forbidden-error.ts +13 -0
- package/src/daemon/build-target-identity.ts +22 -0
- package/src/daemon/build-target-list.ts +50 -0
- package/src/daemon/daemon-connection.ts +425 -96
- package/src/daemon/daemon.ts +730 -96
- package/src/daemon/effect-remains-error.ts +13 -0
- package/src/daemon/execution-provider.ts +208 -0
- package/src/daemon/find-execution-refusal.ts +104 -0
- package/src/daemon/hooks.ts +5 -17
- package/src/daemon/idempotency-ledger.ts +164 -0
- package/src/daemon/imp-client-port.ts +343 -0
- package/src/daemon/imp-harness.ts +618 -0
- package/src/daemon/imp-port-error.ts +18 -0
- package/src/daemon/imp-port.ts +246 -0
- package/src/daemon/imp-provider.ts +601 -0
- package/src/daemon/is-binding-current.ts +43 -0
- package/src/daemon/local-pty-provider.ts +142 -0
- package/src/daemon/materialize-workspace.ts +571 -0
- package/src/daemon/mint-session-id.ts +5 -5
- package/src/daemon/parse-hook-line.ts +31 -0
- package/src/daemon/parse-spawn-overrides.ts +94 -0
- package/src/daemon/permission-registry.ts +14 -4
- package/src/daemon/pick-session-state.ts +15 -0
- package/src/daemon/restore-fleet.ts +103 -38
- package/src/daemon/screen-model.ts +7 -0
- package/src/daemon/session-runtime.ts +10 -0
- package/src/daemon/sessions.ts +835 -105
- package/src/daemon/start-headless-turn.ts +12 -3
- package/src/daemon/start-session-bridge.ts +294 -0
- package/src/daemon/target-access.ts +36 -0
- package/src/mcp/answer-mcp-request.ts +12 -4
- package/src/mcp/answer-rpc-request.ts +28 -1
- package/src/mcp/build-principal-caller.ts +15 -0
- package/src/mcp/build-spawn-descriptions.ts +45 -0
- package/src/mcp/build-tool-list.ts +32 -4
- package/src/mcp/mcp-tools.ts +107 -10
- package/src/mcp/parse-idempotency-key.ts +29 -0
- package/src/mcp/reconnecting-caller.ts +39 -7
- package/src/mcp/require-daemon-features.ts +10 -0
- package/src/mcp/run-tool.ts +87 -16
- package/src/mcp/types.ts +2 -0
- package/src/protocol/daemon-error.ts +5 -1
- package/src/protocol/daemon-features.ts +35 -0
- package/src/protocol/protocol.ts +67 -11
- package/src/protocol/request-param-schemas.ts +126 -10
- package/src/report.ts +37 -2
- package/src/run-bridge-tap.ts +241 -0
- package/src/shared/agent-session-id.ts +1 -1
- package/src/shared/collect-clean-env.ts +9 -1
- package/src/shared/collect-principals.ts +51 -0
- package/src/shared/collect-targets.ts +144 -0
- package/src/shared/config.ts +139 -14
- package/src/shared/daemon-id.ts +8 -0
- package/src/shared/format-json-kind.ts +21 -0
- package/src/shared/open-bridge-socket.ts +86 -0
- package/src/shared/send-bridge-request.ts +47 -0
- package/src/shared/sort-json-keys.ts +19 -0
- package/src/shared/to-daemon-id.ts +11 -0
- package/src/statusline.ts +34 -3
- package/src/store/fleet-entry.ts +61 -9
- package/src/store/idempotency-record.ts +48 -0
- package/src/store/message-owner.ts +1 -1
- package/src/store/run-migrations.ts +330 -5
- package/src/store/state-store.ts +607 -44
- package/src/store/trail-entry.ts +4 -0
- package/src/store/workspace-materialization.ts +70 -0
- package/src/tap.ts +11 -0
- package/src/workspace/check-url-credentials.ts +59 -0
- package/src/workspace/check-workspace-completeness.ts +90 -0
- package/src/workspace/create-workspace-clone.ts +251 -0
- package/src/workspace/normalize-git-url.ts +59 -0
- package/src/workspace/read-workspace-tar.ts +38 -0
- package/src/workspace/repository-env-vars.ts +22 -0
- package/src/workspace/resolve-path-source.ts +170 -0
- package/src/workspace/run-git.ts +87 -0
- package/src/workspace/sanitize-workspace-clone.ts +146 -0
- package/src/workspace/workspace-provenance.ts +11 -0
- package/src/workspace/workspace-source.ts +8 -0
- /package/src/{daemon → agents}/resolve-headless-executable.ts +0 -0
|
@@ -1,12 +1,12 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
1
2
|
import type { SessionID } from '../shared/session-id';
|
|
2
3
|
|
|
3
|
-
let counter = 0;
|
|
4
|
-
|
|
5
4
|
/**
|
|
6
|
-
* A fresh atc session id:
|
|
7
|
-
*
|
|
5
|
+
* A fresh atc session id: a random uuid. Every session the session manager
|
|
6
|
+
* spawns gets one, and the fleet row keeps it, so the id stays the same
|
|
7
|
+
* across daemon restarts and fleet restores.
|
|
8
8
|
*/
|
|
9
9
|
export function mintSessionID(): SessionID {
|
|
10
10
|
// oxlint-disable-next-line no-unsafe-type-assertion -- this is where atc mints a session id
|
|
11
|
-
return
|
|
11
|
+
return randomUUID() as SessionID;
|
|
12
12
|
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { isRecord } from '../shared/report';
|
|
2
|
+
import { toSessionID } from '../shared/to-session-id';
|
|
3
|
+
import type { HookEvent } from './hooks';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* One reporter line as a hook event, or null for a line that is not one.
|
|
7
|
+
*/
|
|
8
|
+
export function parseHookLine(line: string): HookEvent | null {
|
|
9
|
+
let parsed: unknown;
|
|
10
|
+
|
|
11
|
+
try {
|
|
12
|
+
parsed = JSON.parse(line);
|
|
13
|
+
} catch {
|
|
14
|
+
return null;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
if (
|
|
18
|
+
!isRecord(parsed) ||
|
|
19
|
+
typeof parsed['atcId'] !== 'string' ||
|
|
20
|
+
typeof parsed['event'] !== 'string' ||
|
|
21
|
+
!isRecord(parsed['payload'])
|
|
22
|
+
) {
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
return {
|
|
27
|
+
atcId: toSessionID(parsed['atcId']),
|
|
28
|
+
event: parsed['event'],
|
|
29
|
+
payload: parsed['payload'],
|
|
30
|
+
};
|
|
31
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import type { SpawnOverrides } from '../agents/agent-adapter';
|
|
2
|
+
import type { AgentEntry, SpawnOptionEntry } from './build-agent-list';
|
|
3
|
+
|
|
4
|
+
interface RequestedOverrides {
|
|
5
|
+
readonly model?: string | undefined;
|
|
6
|
+
readonly effort?: string | undefined;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
interface SpawnRefusal {
|
|
10
|
+
readonly ok: false;
|
|
11
|
+
readonly code: 'bad_args' | 'unsupported';
|
|
12
|
+
readonly message: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
type ParsedOverrides = { readonly ok: true; readonly overrides: SpawnOverrides } | SpawnRefusal;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Checks a spawn's model and effort against the options the agent list
|
|
19
|
+
* shows for that agent, so a spawn accepts exactly what `agents.list`
|
|
20
|
+
* advertises. An option the agent cannot take on this host is unsupported,
|
|
21
|
+
* never ignored. A value outside an option's closed set, or one that could
|
|
22
|
+
* pass for a flag, is bad_args.
|
|
23
|
+
*/
|
|
24
|
+
export function parseSpawnOverrides(
|
|
25
|
+
agent: AgentEntry,
|
|
26
|
+
requested: RequestedOverrides,
|
|
27
|
+
): ParsedOverrides {
|
|
28
|
+
const model = requested.model;
|
|
29
|
+
const effort = requested.effort;
|
|
30
|
+
|
|
31
|
+
const refusal =
|
|
32
|
+
(model === undefined
|
|
33
|
+
? null
|
|
34
|
+
: findOptionRefusal(agent.id, 'model', agent.spawnOptions.model, model)) ??
|
|
35
|
+
(effort === undefined
|
|
36
|
+
? null
|
|
37
|
+
: findOptionRefusal(agent.id, 'effort', agent.spawnOptions.effort, effort));
|
|
38
|
+
|
|
39
|
+
if (refusal !== null) {
|
|
40
|
+
return refusal;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return {
|
|
44
|
+
ok: true,
|
|
45
|
+
overrides: {
|
|
46
|
+
...(model === undefined ? {} : { model }),
|
|
47
|
+
...(effort === undefined ? {} : { effort }),
|
|
48
|
+
},
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const MAX_VALUE_LENGTH = 200;
|
|
53
|
+
|
|
54
|
+
// oxlint-disable-next-line no-control-regex -- control characters are what a value is refused for
|
|
55
|
+
const CONTROL_CHARACTER = /[\u0000-\u001F\u007F]/u;
|
|
56
|
+
|
|
57
|
+
function findOptionRefusal(
|
|
58
|
+
agentID: string,
|
|
59
|
+
name: 'model' | 'effort',
|
|
60
|
+
option: SpawnOptionEntry,
|
|
61
|
+
value: string,
|
|
62
|
+
): SpawnRefusal | null {
|
|
63
|
+
if (!option.available) {
|
|
64
|
+
const reason = option.supported ? 'is not installed on this host' : `takes no ${name}`;
|
|
65
|
+
|
|
66
|
+
return { ok: false, code: 'unsupported', message: `agent '${agentID}' ${reason}` };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (value === '' || value.length > MAX_VALUE_LENGTH) {
|
|
70
|
+
return {
|
|
71
|
+
ok: false,
|
|
72
|
+
code: 'bad_args',
|
|
73
|
+
message: `${name} must be 1 to ${MAX_VALUE_LENGTH} characters`,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (value.startsWith('-') || CONTROL_CHARACTER.test(value)) {
|
|
78
|
+
return {
|
|
79
|
+
ok: false,
|
|
80
|
+
code: 'bad_args',
|
|
81
|
+
message: `${name} must not start with '-' or hold control characters`,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (option.values !== null && !option.values.includes(value)) {
|
|
86
|
+
return {
|
|
87
|
+
ok: false,
|
|
88
|
+
code: 'bad_args',
|
|
89
|
+
message: `agent '${agentID}' takes ${name} ${option.values.join(', ')}; got '${value}'`,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
@@ -30,7 +30,9 @@ export class PermissionRegistry {
|
|
|
30
30
|
|
|
31
31
|
private readonly pending = new Map<string, PendingRequest>();
|
|
32
32
|
|
|
33
|
-
|
|
33
|
+
// Each answered request's session, kept to tell already_answered from
|
|
34
|
+
// unknown.
|
|
35
|
+
private readonly resolved = new Map<string, SessionID>();
|
|
34
36
|
|
|
35
37
|
private counter = 0;
|
|
36
38
|
|
|
@@ -72,6 +74,14 @@ export class PermissionRegistry {
|
|
|
72
74
|
return 'ok';
|
|
73
75
|
}
|
|
74
76
|
|
|
77
|
+
/**
|
|
78
|
+
* The session a request belongs to, answered or not, or null for a
|
|
79
|
+
* request this registry never opened.
|
|
80
|
+
*/
|
|
81
|
+
findSessionID(id: string): SessionID | null {
|
|
82
|
+
return this.pending.get(id)?.req.sessionID ?? this.resolved.get(id) ?? null;
|
|
83
|
+
}
|
|
84
|
+
|
|
75
85
|
answerAll(sessionID: SessionID, decision: string): void {
|
|
76
86
|
for (const [id, entry] of this.pending) {
|
|
77
87
|
if (entry.req.sessionID === sessionID) {
|
|
@@ -90,12 +100,12 @@ export class PermissionRegistry {
|
|
|
90
100
|
clearTimeout(entry.timer);
|
|
91
101
|
|
|
92
102
|
this.pending.delete(id);
|
|
93
|
-
this.resolved.
|
|
103
|
+
this.resolved.set(id, entry.req.sessionID);
|
|
94
104
|
|
|
95
|
-
// The resolved
|
|
105
|
+
// The resolved map only exists to tell already_answered from unknown;
|
|
96
106
|
// cap it so a long-lived daemon cannot grow it without bound.
|
|
97
107
|
if (this.resolved.size > 1000) {
|
|
98
|
-
for (const oldest of this.resolved) {
|
|
108
|
+
for (const oldest of this.resolved.keys()) {
|
|
99
109
|
this.resolved.delete(oldest);
|
|
100
110
|
break;
|
|
101
111
|
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { SessionLifecycle } from './build-session-lifecycle';
|
|
2
|
+
|
|
3
|
+
type SessionState = 'running' | 'needs_you' | 'done' | 'exited';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The state a session lists with: `exited` whenever its harness is not
|
|
7
|
+
* running, suspended inside a sleeping host included, and otherwise the
|
|
8
|
+
* attention its hooks last reported.
|
|
9
|
+
*/
|
|
10
|
+
export function pickSessionState(
|
|
11
|
+
lifecycle: Readonly<SessionLifecycle>,
|
|
12
|
+
attention: SessionState,
|
|
13
|
+
): SessionState {
|
|
14
|
+
return lifecycle.harness === 'running' ? attention : 'exited';
|
|
15
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import
|
|
1
|
+
import { DaemonError } from '../protocol/daemon-error';
|
|
2
2
|
import type { SessionID } from '../shared/session-id';
|
|
3
|
+
import type { FleetEntry } from '../store/fleet-entry';
|
|
3
4
|
import type { StateStore } from '../store/state-store';
|
|
4
|
-
import { ScreenModel } from './screen-model';
|
|
5
5
|
import type { SessionRuntime } from './session-runtime';
|
|
6
6
|
import type { Session, SessionManager } from './sessions';
|
|
7
7
|
|
|
@@ -33,15 +33,17 @@ export async function restoreFleet(params: RestoreFleetParams): Promise<number>
|
|
|
33
33
|
const rows = params.rows;
|
|
34
34
|
const capMs = params.capMs;
|
|
35
35
|
|
|
36
|
-
const hasLiveSession = (
|
|
36
|
+
const hasLiveSession = (entry: FleetEntry) =>
|
|
37
37
|
mgr.sessions.some(
|
|
38
38
|
(s) =>
|
|
39
|
-
s
|
|
39
|
+
isSameSession(s, entry) &&
|
|
40
40
|
(s.pty !== null || (s.kind === 'headless' && s.state !== 'exited')),
|
|
41
41
|
);
|
|
42
42
|
|
|
43
|
-
const hasAnySession = (
|
|
44
|
-
|
|
43
|
+
const hasAnySession = (entry: FleetEntry) => mgr.sessions.some((s) => isSameSession(s, entry));
|
|
44
|
+
|
|
45
|
+
const findRecency = (entry: FleetEntry) =>
|
|
46
|
+
entry.agentSessionID === undefined ? '' : (recency.get(entry.agentSessionID) ?? '');
|
|
45
47
|
|
|
46
48
|
const recency = await store.collectFleetRecency();
|
|
47
49
|
const stored = await store.loadFleet();
|
|
@@ -52,14 +54,8 @@ export async function restoreFleet(params: RestoreFleetParams): Promise<number>
|
|
|
52
54
|
// dedupe against every listed session, so repeated restores never double
|
|
53
55
|
// up the killed archive.
|
|
54
56
|
const kept = stored
|
|
55
|
-
.filter((entry) =>
|
|
56
|
-
|
|
57
|
-
? !hasAnySession(entry.agentSessionID)
|
|
58
|
-
: !hasLiveSession(entry.agentSessionID),
|
|
59
|
-
)
|
|
60
|
-
.toSorted((a, b) =>
|
|
61
|
-
(recency.get(b.agentSessionID) ?? '').localeCompare(recency.get(a.agentSessionID) ?? ''),
|
|
62
|
-
);
|
|
57
|
+
.filter((entry) => (entry.exited === true ? !hasAnySession(entry) : !hasLiveSession(entry)))
|
|
58
|
+
.toSorted((a, b) => findRecency(b).localeCompare(findRecency(a)));
|
|
63
59
|
|
|
64
60
|
// Sub-sessions register after every top-level entry, so each one links
|
|
65
61
|
// to a parent that is already listed; the wrangling session also boots
|
|
@@ -72,30 +68,41 @@ export async function restoreFleet(params: RestoreFleetParams): Promise<number>
|
|
|
72
68
|
// The whole fleet registers as terminal-less sessions up front, so the
|
|
73
69
|
// list shows every incoming session immediately instead of revealing them
|
|
74
70
|
// one boot at a time. Exited entries only register — they stay killed
|
|
75
|
-
// until revived by hand, so no terminal is adopted for them.
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
71
|
+
// until revived by hand, so no terminal is adopted for them. An entry
|
|
72
|
+
// whose session is still listed, dead, revives that session in place
|
|
73
|
+
// rather than listing a second session under the same id.
|
|
74
|
+
const registered = entries.map((entry) => {
|
|
75
|
+
const listed = mgr.sessions.find((s) => s.id === entry.sessionID);
|
|
76
|
+
|
|
77
|
+
return listed === undefined
|
|
78
|
+
? { session: mgr.restore(entry), revive: false }
|
|
79
|
+
: { session: listed, revive: true };
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
const queued = registered
|
|
83
|
+
.filter((r) => r.revive || r.session.state !== 'exited')
|
|
84
|
+
.map((r) => r.session);
|
|
85
|
+
|
|
86
|
+
// A session whose target refuses to start a terminal stays listed without
|
|
87
|
+
// one, and the restore moves on to the next.
|
|
88
|
+
const adoptQueued = async (s: Session): Promise<boolean> => {
|
|
89
|
+
const adopted = await tryAdoptTerminal(mgr, s.id, cols, rows);
|
|
90
|
+
|
|
91
|
+
return adopted !== null;
|
|
93
92
|
};
|
|
94
93
|
|
|
94
|
+
// A session that announced itself, or whose terminal died, before the
|
|
95
|
+
// wait began has nothing left to wait for.
|
|
95
96
|
const waitForBoot = (sessionID: SessionID): Promise<void> => {
|
|
96
97
|
const runtime = findRuntime(sessionID);
|
|
97
|
-
|
|
98
|
-
|
|
98
|
+
const session = mgr.sessions.find((s) => s.id === sessionID);
|
|
99
|
+
|
|
100
|
+
if (
|
|
101
|
+
runtime === undefined ||
|
|
102
|
+
runtime.startedAt !== null ||
|
|
103
|
+
session === undefined ||
|
|
104
|
+
session.pty === null
|
|
105
|
+
) {
|
|
99
106
|
return Promise.resolve();
|
|
100
107
|
}
|
|
101
108
|
|
|
@@ -124,11 +131,26 @@ export async function restoreFleet(params: RestoreFleetParams): Promise<number>
|
|
|
124
131
|
return registered.length;
|
|
125
132
|
}
|
|
126
133
|
|
|
127
|
-
// The first terminal attaches
|
|
128
|
-
// once; each later one waits for the previous session to report it has
|
|
134
|
+
// The first terminal attaches before the restore answers so a caller can
|
|
135
|
+
// attach at once; each later one waits for the previous session to report it has
|
|
129
136
|
// booted, so a heavy fleet comes up one process at a time instead of all
|
|
130
137
|
// at once. A per-session cap keeps a session that never reports from
|
|
131
138
|
// stalling the rest.
|
|
139
|
+
// A session whose revive fails is logged and left without a terminal,
|
|
140
|
+
// and the restore moves on: one host's failure never keeps the sessions on
|
|
141
|
+
// other hosts down, and a later one's runs where nothing awaits it.
|
|
142
|
+
const tryAdoptQueued = async (s: Session): Promise<boolean> => {
|
|
143
|
+
try {
|
|
144
|
+
return await adoptQueued(s);
|
|
145
|
+
} catch (error) {
|
|
146
|
+
mgr.log(
|
|
147
|
+
`atc could not revive session ${s.id} (${error instanceof Error ? error.message : String(error)})`,
|
|
148
|
+
);
|
|
149
|
+
|
|
150
|
+
return false;
|
|
151
|
+
}
|
|
152
|
+
};
|
|
153
|
+
|
|
132
154
|
const adoptRest = async (previous: Session | null) => {
|
|
133
155
|
let prev = previous;
|
|
134
156
|
|
|
@@ -137,13 +159,56 @@ export async function restoreFleet(params: RestoreFleetParams): Promise<number>
|
|
|
137
159
|
await waitForBoot(prev.id);
|
|
138
160
|
}
|
|
139
161
|
|
|
140
|
-
|
|
162
|
+
const booted = await tryAdoptQueued(s);
|
|
163
|
+
|
|
164
|
+
prev = booted ? s : null;
|
|
141
165
|
}
|
|
142
166
|
};
|
|
143
167
|
|
|
144
|
-
const
|
|
168
|
+
const firstAdopted = await tryAdoptQueued(first);
|
|
169
|
+
|
|
170
|
+
const firstBooted = firstAdopted ? first : null;
|
|
145
171
|
|
|
146
172
|
void adoptRest(firstBooted);
|
|
147
173
|
|
|
148
174
|
return registered.length;
|
|
149
175
|
}
|
|
176
|
+
|
|
177
|
+
// A stored entry matches a listed session by its atc session id, or by its
|
|
178
|
+
// agent session id when a listed session resumed the same agent session.
|
|
179
|
+
function isSameSession(s: Session, entry: FleetEntry): boolean {
|
|
180
|
+
return (
|
|
181
|
+
s.id === entry.sessionID ||
|
|
182
|
+
(entry.agentSessionID !== undefined && s.agentSessionID === entry.agentSessionID)
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// The refusals that leave one session without a terminal: its target is
|
|
187
|
+
// misconfigured, gone from the config, changed, has no provider here, or
|
|
188
|
+
// cannot start a terminal.
|
|
189
|
+
const REFUSED_ADOPT_CODES: ReadonlySet<string> = new Set([
|
|
190
|
+
'unsupported_operation',
|
|
191
|
+
'unknown_target',
|
|
192
|
+
'target_unavailable',
|
|
193
|
+
'target_changed',
|
|
194
|
+
'target_config_invalid',
|
|
195
|
+
'host_unavailable',
|
|
196
|
+
'auth_not_configured',
|
|
197
|
+
]);
|
|
198
|
+
|
|
199
|
+
async function tryAdoptTerminal(
|
|
200
|
+
mgr: SessionManager,
|
|
201
|
+
id: SessionID,
|
|
202
|
+
cols: number,
|
|
203
|
+
rows: number,
|
|
204
|
+
): Promise<Session | null> {
|
|
205
|
+
try {
|
|
206
|
+
return await mgr.adoptTerminal(id, cols, rows);
|
|
207
|
+
} catch (error) {
|
|
208
|
+
if (error instanceof DaemonError && REFUSED_ADOPT_CODES.has(error.code)) {
|
|
209
|
+
return null;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
throw error;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
@@ -99,6 +99,13 @@ export class ScreenModel {
|
|
|
99
99
|
return RESET_INPUT_MODES + this.renderVisibleScreen() + this.renderInputModes();
|
|
100
100
|
}
|
|
101
101
|
|
|
102
|
+
// Whether the TUI has turned bracketed paste on, as of the output parsed
|
|
103
|
+
// so far. It reads the mode without waiting, so output still queued for
|
|
104
|
+
// parsing never delays the caller; a TUI turns the mode on once, at start.
|
|
105
|
+
hasBracketedPaste(): boolean {
|
|
106
|
+
return this.term.modes.bracketedPasteMode;
|
|
107
|
+
}
|
|
108
|
+
|
|
102
109
|
// The visible rows of whichever buffer the session is showing, as plain
|
|
103
110
|
// text with no escape sequences: one line per row, trailing blanks
|
|
104
111
|
// trimmed from each row, trailing blank rows dropped. Drains pending
|
|
@@ -46,6 +46,16 @@ export class SessionRuntime {
|
|
|
46
46
|
// later dropped may be restarting, so it keeps the inbox open.
|
|
47
47
|
tapAttached = false;
|
|
48
48
|
|
|
49
|
+
// Returns the boot-scoped state to how a fresh terminal starts, at the
|
|
50
|
+
// dims it boots with: no SessionStart yet and no tap since. Every path
|
|
51
|
+
// that boots a new terminal for an existing session runs it, so a revived
|
|
52
|
+
// session never inherits its previous process's message gate.
|
|
53
|
+
resetBoot(dims: Dims): void {
|
|
54
|
+
this.dims = dims;
|
|
55
|
+
this.startedAt = null;
|
|
56
|
+
this.tapAttached = false;
|
|
57
|
+
}
|
|
58
|
+
|
|
49
59
|
dispose(): void {
|
|
50
60
|
clearTimeout(this.resizeTimer);
|
|
51
61
|
clearTimeout(this.detectTimer);
|