@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
package/src/protocol/protocol.ts
CHANGED
|
@@ -5,7 +5,7 @@ import { isRecord } from '../shared/report';
|
|
|
5
5
|
* per line. Three message kinds, distinguished by which fields are present —
|
|
6
6
|
* request (id + m), response (id + ok or err), event (ev).
|
|
7
7
|
*/
|
|
8
|
-
export const PROTOCOL_V =
|
|
8
|
+
export const PROTOCOL_V = 4;
|
|
9
9
|
|
|
10
10
|
// Control lines are capped before buffering; PTY output is split into chunks
|
|
11
11
|
// so a queued response is delayed by at most one chunk.
|
|
@@ -20,8 +20,40 @@ const ERROR_CODES = [
|
|
|
20
20
|
'no_such_session',
|
|
21
21
|
'session_dead',
|
|
22
22
|
'unsupported',
|
|
23
|
+
'unsupported_operation',
|
|
24
|
+
'unknown_target',
|
|
25
|
+
'target_unavailable',
|
|
26
|
+
'target_changed',
|
|
27
|
+
'target_config_invalid',
|
|
28
|
+
'target_forbidden',
|
|
29
|
+
'not_a_git_repo',
|
|
30
|
+
'no_commits',
|
|
31
|
+
'unreadable_tree',
|
|
32
|
+
'has_submodules',
|
|
33
|
+
'lfs_unsupported',
|
|
34
|
+
'workspace_dirty',
|
|
35
|
+
'no_origin',
|
|
36
|
+
'invalid_git_url',
|
|
37
|
+
'unpushed_head',
|
|
38
|
+
'credential_in_url',
|
|
39
|
+
'credential_missing',
|
|
40
|
+
'ref_not_found',
|
|
41
|
+
'clone_failed',
|
|
42
|
+
'sanitize_failed',
|
|
43
|
+
'tar_failed',
|
|
44
|
+
'workspace_exists',
|
|
45
|
+
'transfer_failed',
|
|
46
|
+
'workspace_mismatch',
|
|
47
|
+
'host_unavailable',
|
|
48
|
+
'auth_not_configured',
|
|
49
|
+
'host_leased',
|
|
50
|
+
'confirmation_required',
|
|
51
|
+
'confirm_token_invalid',
|
|
23
52
|
'already_answered',
|
|
24
53
|
'too_slow',
|
|
54
|
+
'stale_epoch',
|
|
55
|
+
'idempotency_conflict',
|
|
56
|
+
'outcome_unknown',
|
|
25
57
|
'internal',
|
|
26
58
|
] as const;
|
|
27
59
|
|
|
@@ -30,6 +62,9 @@ export type ErrorCode = (typeof ERROR_CODES)[number];
|
|
|
30
62
|
interface ProtocolError {
|
|
31
63
|
readonly code: ErrorCode;
|
|
32
64
|
readonly msg: string;
|
|
65
|
+
|
|
66
|
+
// Structured detail an error code defines for itself.
|
|
67
|
+
readonly data?: Readonly<Record<string, unknown>>;
|
|
33
68
|
}
|
|
34
69
|
|
|
35
70
|
export interface RequestMsg {
|
|
@@ -37,6 +72,10 @@ export interface RequestMsg {
|
|
|
37
72
|
readonly id: number;
|
|
38
73
|
readonly m: string;
|
|
39
74
|
readonly p?: Readonly<Record<string, unknown>>;
|
|
75
|
+
|
|
76
|
+
// The principal the request acts as, which narrows what the connection
|
|
77
|
+
// may reach for this request alone.
|
|
78
|
+
readonly as?: string;
|
|
40
79
|
}
|
|
41
80
|
|
|
42
81
|
export interface ResponseMsg {
|
|
@@ -61,7 +100,9 @@ export type DecodedMsg =
|
|
|
61
100
|
/**
|
|
62
101
|
* Classifies one NDJSON line. Unknown fields pass through untouched so
|
|
63
102
|
* additive evolution never breaks a peer; a line that parses but fits no
|
|
64
|
-
* message kind is malformed, and the caller closes the connection.
|
|
103
|
+
* message kind is malformed, and the caller closes the connection. An error
|
|
104
|
+
* code this build does not know decodes as `internal` with its message
|
|
105
|
+
* kept, so a peer that adds a code never breaks one that predates it.
|
|
65
106
|
*/
|
|
66
107
|
export function decodeMessage(line: string): DecodedMsg {
|
|
67
108
|
let parsed: unknown;
|
|
@@ -86,6 +127,13 @@ export function decodeMessage(line: string): DecodedMsg {
|
|
|
86
127
|
|
|
87
128
|
if (typeof parsed['m'] === 'string') {
|
|
88
129
|
const p = parsed['p'];
|
|
130
|
+
const as = parsed['as'];
|
|
131
|
+
|
|
132
|
+
// A principal that is not a string would otherwise drop out and leave the
|
|
133
|
+
// request with the connection's whole reach.
|
|
134
|
+
if (as !== undefined && (typeof as !== 'string' || as === '')) {
|
|
135
|
+
return { kind: 'malformed', reason: 'as must be a non-empty string' };
|
|
136
|
+
}
|
|
89
137
|
|
|
90
138
|
return {
|
|
91
139
|
kind: 'request',
|
|
@@ -94,21 +142,22 @@ export function decodeMessage(line: string): DecodedMsg {
|
|
|
94
142
|
id: parsed['id'],
|
|
95
143
|
m: parsed['m'],
|
|
96
144
|
...(isRecord(p) ? { p } : {}),
|
|
145
|
+
...(as === undefined ? {} : { as }),
|
|
97
146
|
},
|
|
98
147
|
};
|
|
99
148
|
}
|
|
100
149
|
|
|
101
150
|
const ok = parsed['ok'];
|
|
102
|
-
const err = parsed['err'];
|
|
151
|
+
const err = parseProtocolError(parsed['err']);
|
|
103
152
|
|
|
104
|
-
if (isRecord(ok) ||
|
|
153
|
+
if (isRecord(ok) || err !== null) {
|
|
105
154
|
return {
|
|
106
155
|
kind: 'response',
|
|
107
156
|
msg: {
|
|
108
157
|
v: parsed['v'],
|
|
109
158
|
id: parsed['id'],
|
|
110
159
|
...(isRecord(ok) ? { ok } : {}),
|
|
111
|
-
...(
|
|
160
|
+
...(err === null ? {} : { err }),
|
|
112
161
|
},
|
|
113
162
|
};
|
|
114
163
|
}
|
|
@@ -120,10 +169,17 @@ export function encodeMessage(msg: EventMsg | RequestMsg | ResponseMsg): string
|
|
|
120
169
|
return `${JSON.stringify(msg)}\n`;
|
|
121
170
|
}
|
|
122
171
|
|
|
123
|
-
function
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
172
|
+
function parseProtocolError(value: unknown): ProtocolError | null {
|
|
173
|
+
if (!isRecord(value) || typeof value['msg'] !== 'string' || typeof value['code'] !== 'string') {
|
|
174
|
+
return null;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const raw = value['code'];
|
|
178
|
+
const data = value['data'];
|
|
179
|
+
|
|
180
|
+
return {
|
|
181
|
+
code: ERROR_CODES.find((code) => code === raw) ?? 'internal',
|
|
182
|
+
msg: value['msg'],
|
|
183
|
+
...(isRecord(data) ? { data } : {}),
|
|
184
|
+
};
|
|
129
185
|
}
|
|
@@ -16,9 +16,72 @@ const SESSION_DEFAULTED = z.object({
|
|
|
16
16
|
session: buildDefaultedString('').transform(toSessionID),
|
|
17
17
|
});
|
|
18
18
|
|
|
19
|
+
// The idempotency key a spawn or message may carry, at most 200 characters.
|
|
20
|
+
const IDEMPOTENCY_KEY = z
|
|
21
|
+
.string({ error: 'idempotencyKey must be a string' })
|
|
22
|
+
.min(1, 'idempotencyKey must not be empty')
|
|
23
|
+
.max(200, 'idempotencyKey must be at most 200 characters')
|
|
24
|
+
.optional();
|
|
25
|
+
|
|
26
|
+
// A full commit id, SHA-1 or SHA-256.
|
|
27
|
+
const COMMIT_ID = /^(?:[\da-f]{40}|[\da-f]{64})$/u;
|
|
28
|
+
|
|
29
|
+
// The daemon environment variable a git workspace's token is read from.
|
|
30
|
+
const CREDENTIAL_REF = z.strictObject({
|
|
31
|
+
kind: z.literal('env'),
|
|
32
|
+
name: z.string().regex(/^[A-Za-z_]\w*$/u, 'a credentialRef names an environment variable'),
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Where a spawn's working directory comes from, materialized as a clean
|
|
37
|
+
* checkout into the spawn's `cwd` on its execution target. A `path` source
|
|
38
|
+
* is a directory on the daemon's host, resolved to its origin URL and
|
|
39
|
+
* pushed HEAD; `allowDirty: 'warn'` resolves a tree with uncommitted
|
|
40
|
+
* changes to HEAD and leaves the changes behind with a warning. A `git`
|
|
41
|
+
* source is a repository URL with exactly one of a branch or tag `ref` or
|
|
42
|
+
* a full commit `sha`, and an optional `credentialRef` naming the daemon
|
|
43
|
+
* environment variable that holds its token.
|
|
44
|
+
*/
|
|
45
|
+
const WORKSPACE_SOURCE = z.discriminatedUnion('kind', [
|
|
46
|
+
z.strictObject({
|
|
47
|
+
kind: z.literal('path'),
|
|
48
|
+
path: z
|
|
49
|
+
.string({ error: 'a path workspace requires an absolute path' })
|
|
50
|
+
.startsWith('/', 'a path workspace requires an absolute path'),
|
|
51
|
+
allowDirty: z.enum(['refuse', 'warn']).optional(),
|
|
52
|
+
}),
|
|
53
|
+
z
|
|
54
|
+
.strictObject({
|
|
55
|
+
kind: z.literal('git'),
|
|
56
|
+
url: z
|
|
57
|
+
.string({ error: 'a git workspace requires a url' })
|
|
58
|
+
.min(1, 'a git workspace requires a url')
|
|
59
|
+
.refine((url) => !url.startsWith('-'), 'a git workspace url must not start with -'),
|
|
60
|
+
ref: z
|
|
61
|
+
.string()
|
|
62
|
+
.min(1, 'a git workspace ref must not be empty')
|
|
63
|
+
.refine((ref) => !ref.startsWith('-'), 'a git workspace ref must not start with -')
|
|
64
|
+
.optional(),
|
|
65
|
+
sha: z.string().regex(COMMIT_ID, 'a git workspace sha is a full commit id').optional(),
|
|
66
|
+
credentialRef: CREDENTIAL_REF.optional(),
|
|
67
|
+
})
|
|
68
|
+
.refine((source) => (source.ref === undefined) !== (source.sha === undefined), {
|
|
69
|
+
message: 'a git workspace takes exactly one of ref or sha',
|
|
70
|
+
}),
|
|
71
|
+
]);
|
|
72
|
+
|
|
73
|
+
export type SpawnWorkspaceSource = z.infer<typeof WORKSPACE_SOURCE>;
|
|
74
|
+
|
|
75
|
+
// The refusal of a terminal size outside the range a terminal takes.
|
|
76
|
+
const TERMINAL_SIZE_ERROR = 'cols and rows must be whole numbers from 1 to 4096';
|
|
77
|
+
|
|
19
78
|
export const REQUEST_PARAM_SCHEMAS = {
|
|
20
79
|
'daemon.hello': z.object({
|
|
21
80
|
client: buildDefaultedString('unknown client'),
|
|
81
|
+
principal: z
|
|
82
|
+
.string({ error: 'daemon.hello principal must be a non-empty string' })
|
|
83
|
+
.min(1, 'daemon.hello principal must be a non-empty string')
|
|
84
|
+
.optional(),
|
|
22
85
|
}),
|
|
23
86
|
'daemon.ping': z.object({}),
|
|
24
87
|
'daemon.quit': z.object({}),
|
|
@@ -27,15 +90,15 @@ export const REQUEST_PARAM_SCHEMAS = {
|
|
|
27
90
|
'agents.list': z.object({}),
|
|
28
91
|
'fleet.list': z.object({}),
|
|
29
92
|
'fleet.restore': z.object({
|
|
30
|
-
cols:
|
|
31
|
-
rows:
|
|
93
|
+
cols: buildTerminalSize(80),
|
|
94
|
+
rows: buildTerminalSize(24),
|
|
32
95
|
}),
|
|
33
96
|
'session.spawn': z.object({
|
|
34
97
|
cwd: z.string({ error: 'session.spawn requires a cwd' }).min(1, 'session.spawn requires a cwd'),
|
|
35
98
|
name: buildDefaultedString(''),
|
|
36
99
|
prompt: buildDefaultedString(''),
|
|
37
|
-
cols:
|
|
38
|
-
rows:
|
|
100
|
+
cols: buildTerminalSize(80),
|
|
101
|
+
rows: buildTerminalSize(24),
|
|
39
102
|
|
|
40
103
|
resume: buildDefaultedBooleanOrString(false).transform((v) =>
|
|
41
104
|
typeof v === 'string' ? toAgentSessionID(v) : v,
|
|
@@ -45,6 +108,27 @@ export const REQUEST_PARAM_SCHEMAS = {
|
|
|
45
108
|
.min(1, 'session.spawn agent must be a non-empty agent id')
|
|
46
109
|
.optional(),
|
|
47
110
|
|
|
111
|
+
// Per-session overrides; the daemon checks each against what the agent
|
|
112
|
+
// advertises before anything spawns.
|
|
113
|
+
model: z.string({ error: 'session.spawn model must be a string' }).optional(),
|
|
114
|
+
effort: z.string({ error: 'session.spawn effort must be a string' }).optional(),
|
|
115
|
+
|
|
116
|
+
// A retry carrying the same key replays the first spawn's answer instead
|
|
117
|
+
// of spawning again.
|
|
118
|
+
idempotencyKey: IDEMPOTENCY_KEY,
|
|
119
|
+
|
|
120
|
+
// The execution target the session runs on; absent runs on the default
|
|
121
|
+
// target, and a target the daemon cannot use refuses the spawn.
|
|
122
|
+
target: z
|
|
123
|
+
.string({ error: 'session.spawn target must be a non-empty target id' })
|
|
124
|
+
.min(1, 'session.spawn target must be a non-empty target id')
|
|
125
|
+
.optional(),
|
|
126
|
+
|
|
127
|
+
// Where the session's working directory comes from: absent runs the
|
|
128
|
+
// session in cwd as it stands, and a source materializes a clean
|
|
129
|
+
// checkout into cwd first.
|
|
130
|
+
workspace: WORKSPACE_SOURCE.optional(),
|
|
131
|
+
|
|
48
132
|
// The session the new one is a sub-session of; absent or empty spawns a
|
|
49
133
|
// top-level session.
|
|
50
134
|
parent: z.preprocess(
|
|
@@ -54,21 +138,33 @@ export const REQUEST_PARAM_SCHEMAS = {
|
|
|
54
138
|
}),
|
|
55
139
|
'session.kill': SESSION_DEFAULTED,
|
|
56
140
|
'session.ack': SESSION_DEFAULTED,
|
|
141
|
+
|
|
142
|
+
// Without a token, a forget that destroys a host answers with one; the
|
|
143
|
+
// forget that carries it destroys the host.
|
|
144
|
+
'session.forget': SESSION_DEFAULTED.extend({
|
|
145
|
+
confirmToken: z
|
|
146
|
+
.string({ error: 'session.forget confirmToken must be a string' })
|
|
147
|
+
.min(1, 'session.forget confirmToken must not be empty')
|
|
148
|
+
.optional(),
|
|
149
|
+
}),
|
|
57
150
|
'session.update': SESSION_DEFAULTED.extend({
|
|
58
151
|
name: buildOptionalString(),
|
|
59
152
|
pinned: buildOptionalBoolean(),
|
|
60
153
|
}),
|
|
61
154
|
'session.attach': SESSION_DEFAULTED.extend({
|
|
62
|
-
cols:
|
|
63
|
-
rows:
|
|
155
|
+
cols: buildTerminalSize(80),
|
|
156
|
+
rows: buildTerminalSize(24),
|
|
64
157
|
}),
|
|
65
158
|
'session.detach': SESSION_DEFAULTED,
|
|
66
159
|
'session.input': SESSION_DEFAULTED.extend({
|
|
67
160
|
d: buildDefaultedString(''),
|
|
68
161
|
}),
|
|
162
|
+
'session.submit': SESSION_DEFAULTED.extend({
|
|
163
|
+
text: buildDefaultedString(''),
|
|
164
|
+
}),
|
|
69
165
|
'session.resize': SESSION_DEFAULTED.extend({
|
|
70
|
-
cols:
|
|
71
|
-
rows:
|
|
166
|
+
cols: buildTerminalSize(0),
|
|
167
|
+
rows: buildTerminalSize(0),
|
|
72
168
|
}).refine((v) => v.cols >= 1 && v.rows >= 1, {
|
|
73
169
|
message: 'session.resize requires positive cols and rows',
|
|
74
170
|
}),
|
|
@@ -78,8 +174,8 @@ export const REQUEST_PARAM_SCHEMAS = {
|
|
|
78
174
|
prompt: buildDefaultedNonEmptyString(EJECT_DEFAULT_PROMPT),
|
|
79
175
|
}),
|
|
80
176
|
'session.adopt': SESSION_DEFAULTED.extend({
|
|
81
|
-
cols:
|
|
82
|
-
rows:
|
|
177
|
+
cols: buildTerminalSize(80),
|
|
178
|
+
rows: buildTerminalSize(24),
|
|
83
179
|
}),
|
|
84
180
|
'permission.respond': z
|
|
85
181
|
.object({
|
|
@@ -108,6 +204,10 @@ export const REQUEST_PARAM_SCHEMAS = {
|
|
|
108
204
|
'session.message': SESSION_DEFAULTED.extend({
|
|
109
205
|
from: buildDefaultedNonEmptyString('unknown'),
|
|
110
206
|
text: buildDefaultedString(''),
|
|
207
|
+
|
|
208
|
+
// A retry carrying the same key replays the first send's message
|
|
209
|
+
// instead of sending another.
|
|
210
|
+
idempotencyKey: IDEMPOTENCY_KEY,
|
|
111
211
|
}).refine((v) => v.text !== '', { message: 'session.message requires text' }),
|
|
112
212
|
'session.tap': SESSION_DEFAULTED,
|
|
113
213
|
'message.get': z
|
|
@@ -125,6 +225,22 @@ function buildDefaultedString(fallback: string) {
|
|
|
125
225
|
return z.preprocess((v) => (typeof v === 'string' ? v : undefined), z.string().default(fallback));
|
|
126
226
|
}
|
|
127
227
|
|
|
228
|
+
// A terminal dimension: absent or not a number takes the fallback, and a
|
|
229
|
+
// number must be a whole number in range, since the terminal emulator and
|
|
230
|
+
// the PTY both assume one and a fractional size throws only after the
|
|
231
|
+
// process starts.
|
|
232
|
+
function buildTerminalSize(fallback: number) {
|
|
233
|
+
return z.preprocess(
|
|
234
|
+
(v) => (typeof v === 'number' ? v : undefined),
|
|
235
|
+
z
|
|
236
|
+
.number({ error: TERMINAL_SIZE_ERROR })
|
|
237
|
+
.int(TERMINAL_SIZE_ERROR)
|
|
238
|
+
.min(1, TERMINAL_SIZE_ERROR)
|
|
239
|
+
.max(4096, TERMINAL_SIZE_ERROR)
|
|
240
|
+
.default(fallback),
|
|
241
|
+
);
|
|
242
|
+
}
|
|
243
|
+
|
|
128
244
|
function buildDefaultedNumber(fallback: number) {
|
|
129
245
|
return z.preprocess((v) => (typeof v === 'number' ? v : undefined), z.number().default(fallback));
|
|
130
246
|
}
|
package/src/report.ts
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { mkdirSync, rmSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
1
4
|
import { sendReport } from './shared/report';
|
|
2
5
|
import { REPORT_KINDS } from './shared/report-kinds';
|
|
6
|
+
import { sendBridgeRequest } from './shared/send-bridge-request';
|
|
3
7
|
|
|
4
8
|
interface ReportOptions {
|
|
5
9
|
readonly message: string;
|
|
@@ -19,7 +23,8 @@ interface ReportOptions {
|
|
|
19
23
|
* message, or every given message at once, plus that turn's id when one is
|
|
20
24
|
* given; a `note` carries
|
|
21
25
|
* it as text for the user under the given label, `progress` when none is
|
|
22
|
-
* given.
|
|
26
|
+
* given. Inside a remote host it goes to the session bridge instead. Always
|
|
27
|
+
* exits 0 so it never blocks the session it reports on.
|
|
23
28
|
*/
|
|
24
29
|
export async function runReport(kind: string, options: ReportOptions): Promise<void> {
|
|
25
30
|
try {
|
|
@@ -37,7 +42,9 @@ export async function runReport(kind: string, options: ReportOptions): Promise<v
|
|
|
37
42
|
|
|
38
43
|
const payload = buildReportPayload(kind, options, stdin);
|
|
39
44
|
|
|
40
|
-
if (payload !== null) {
|
|
45
|
+
if (payload !== null && process.env['ATC_BRIDGE'] === '1') {
|
|
46
|
+
await sendBridgeReport(sock, process.env['ATC_OUTBOX'] ?? '', payload);
|
|
47
|
+
} else if (payload !== null) {
|
|
41
48
|
const line = `${JSON.stringify({ atcId, event: 'Report', payload })}\n`;
|
|
42
49
|
|
|
43
50
|
await sendReport(sock, line, 2000);
|
|
@@ -48,6 +55,34 @@ export async function runReport(kind: string, options: ReportOptions): Promise<v
|
|
|
48
55
|
process.exit(0);
|
|
49
56
|
}
|
|
50
57
|
|
|
58
|
+
// Inside a remote host, a report goes to the session bridge under an id of
|
|
59
|
+
// its own, and waits in the outbox until the bridge takes or refuses it, so
|
|
60
|
+
// the session's tap sends it again after a dropped connection. A resent
|
|
61
|
+
// report lands once, and a refused one is never resent.
|
|
62
|
+
async function sendBridgeReport(
|
|
63
|
+
sock: string,
|
|
64
|
+
outbox: string,
|
|
65
|
+
payload: Readonly<Record<string, string | readonly string[]>>,
|
|
66
|
+
): Promise<void> {
|
|
67
|
+
const reportID = randomUUID();
|
|
68
|
+
const file = outbox === '' ? null : join(outbox, `${reportID}.json`);
|
|
69
|
+
|
|
70
|
+
if (file !== null) {
|
|
71
|
+
try {
|
|
72
|
+
mkdirSync(outbox, { recursive: true });
|
|
73
|
+
writeFileSync(file, JSON.stringify({ reportID, payload }));
|
|
74
|
+
} catch {}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const answer = await sendBridgeRequest(sock, 'report', { reportID, payload }, 2000);
|
|
78
|
+
|
|
79
|
+
const isFinal = answer?.['ok'] === true || answer?.['code'] === 'forbidden';
|
|
80
|
+
|
|
81
|
+
if (isFinal && file !== null) {
|
|
82
|
+
rmSync(file, { force: true });
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
51
86
|
function buildReportPayload(
|
|
52
87
|
kind: string,
|
|
53
88
|
options: ReportOptions,
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
import { readFileSync, readdirSync, rmSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { z } from 'zod';
|
|
4
|
+
import { openBridgeSocket } from './shared/open-bridge-socket';
|
|
5
|
+
import type { BridgeSocket } from './shared/open-bridge-socket';
|
|
6
|
+
|
|
7
|
+
const INBOX_MESSAGE_SCHEMA = z.looseObject({
|
|
8
|
+
ev: z.literal('InboxMessage'),
|
|
9
|
+
message: z.string(),
|
|
10
|
+
from: z.string(),
|
|
11
|
+
text: z.string(),
|
|
12
|
+
sentAt: z.number(),
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
const OUTBOX_REPORT_SCHEMA = z.object({
|
|
16
|
+
reportID: z.string().min(1),
|
|
17
|
+
payload: z.record(z.string(), z.unknown()),
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
// The wait before the first reconnect, doubling up to the longest.
|
|
21
|
+
const FIRST_RETRY_MS = 250;
|
|
22
|
+
const LONGEST_RETRY_MS = 5000;
|
|
23
|
+
|
|
24
|
+
// How many message ids the tap remembers having printed.
|
|
25
|
+
const SEEN_LIMIT = 10_000;
|
|
26
|
+
|
|
27
|
+
// How one connection to the bridge ended: the inbox closed for good, the
|
|
28
|
+
// bridge refused the tap, or the connection dropped after or before it
|
|
29
|
+
// opened the tap.
|
|
30
|
+
type TapEnd = 'closed' | 'refused' | 'lost' | 'unreachable';
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Streams the session's inbox to stdout from inside a remote host, through
|
|
34
|
+
* the session bridge at the given socket. It prints each message once as
|
|
35
|
+
* one NDJSON line and acks it after the line is written. A dropped
|
|
36
|
+
* connection, as a host's sleep or a daemon restart leaves it, reconnects
|
|
37
|
+
* with a growing wait of at most 5 seconds and no end; each new connection
|
|
38
|
+
* replays the messages not yet acked, and one already printed is acked
|
|
39
|
+
* again and not printed twice. Each connection also sends the reports the
|
|
40
|
+
* outbox still holds. Exits 0 once the inbox closes and 1 when the bridge
|
|
41
|
+
* refuses the tap.
|
|
42
|
+
*/
|
|
43
|
+
export async function runBridgeTap(socketPath: string, outbox: string): Promise<void> {
|
|
44
|
+
const seen = new Set<string>();
|
|
45
|
+
|
|
46
|
+
let wait = FIRST_RETRY_MS;
|
|
47
|
+
|
|
48
|
+
for (;;) {
|
|
49
|
+
const end = await runTapConnection(socketPath, outbox, seen);
|
|
50
|
+
|
|
51
|
+
if (end === 'closed') {
|
|
52
|
+
process.exit(0);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (end === 'refused') {
|
|
56
|
+
console.error('atc tap: the session bridge refused the tap');
|
|
57
|
+
process.exit(1);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (end === 'lost') {
|
|
61
|
+
wait = FIRST_RETRY_MS;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
await Bun.sleep(wait);
|
|
65
|
+
|
|
66
|
+
wait = Math.min(wait * 2, LONGEST_RETRY_MS);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async function runTapConnection(
|
|
71
|
+
socketPath: string,
|
|
72
|
+
outbox: string,
|
|
73
|
+
|
|
74
|
+
// oxlint-disable-next-line prefer-readonly-parameter-types -- the ids the tap printed, which every connection adds to
|
|
75
|
+
seen: Set<string>,
|
|
76
|
+
): Promise<TapEnd> {
|
|
77
|
+
const ended = Promise.withResolvers<TapEnd>();
|
|
78
|
+
let opened = false;
|
|
79
|
+
let printing: Promise<void> = Promise.resolve();
|
|
80
|
+
let socket: BridgeSocket;
|
|
81
|
+
|
|
82
|
+
// The outbox file behind each report this connection sent, by request id.
|
|
83
|
+
let sent = new Map<string, string>();
|
|
84
|
+
|
|
85
|
+
const onLine = (line: Readonly<Record<string, unknown>>) => {
|
|
86
|
+
if (line['ev'] === 'InboxClosed') {
|
|
87
|
+
ended.resolve('closed');
|
|
88
|
+
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (line['id'] === 'tap.open') {
|
|
93
|
+
if (line['ok'] !== true) {
|
|
94
|
+
ended.resolve('refused');
|
|
95
|
+
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
opened = true;
|
|
100
|
+
sent = sendOutboxReports(socket, outbox);
|
|
101
|
+
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// A report the bridge took, or one it refuses outright, leaves the
|
|
106
|
+
// outbox: no resend would change the answer. Only the file this
|
|
107
|
+
// connection sent under the answered id is removed.
|
|
108
|
+
if (typeof line['id'] === 'string' && line['id'].startsWith('report:')) {
|
|
109
|
+
const path = sent.get(line['id']);
|
|
110
|
+
|
|
111
|
+
if (path !== undefined && (line['ok'] === true || line['code'] === 'forbidden')) {
|
|
112
|
+
sent.delete(line['id']);
|
|
113
|
+
|
|
114
|
+
rmSync(path, { force: true });
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (line['ok'] === false && line['code'] === 'stale_binding') {
|
|
121
|
+
ended.resolve('refused');
|
|
122
|
+
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const msg = INBOX_MESSAGE_SCHEMA.safeParse(line);
|
|
127
|
+
|
|
128
|
+
if (!msg.success) {
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const message = msg.data;
|
|
133
|
+
const previous = printing;
|
|
134
|
+
|
|
135
|
+
printing = (async () => {
|
|
136
|
+
await previous;
|
|
137
|
+
|
|
138
|
+
if (!seen.has(message.message)) {
|
|
139
|
+
const printed = `${JSON.stringify({ id: message.message, from: message.from, text: message.text, sentAt: message.sentAt })}\n`;
|
|
140
|
+
|
|
141
|
+
try {
|
|
142
|
+
await Bun.write(Bun.stdout, printed);
|
|
143
|
+
} catch {
|
|
144
|
+
process.exit(1);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
updateSeenIDs(seen, message.message);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
socket.writeLine({
|
|
151
|
+
v: 1,
|
|
152
|
+
id: `ack:${message.message}`,
|
|
153
|
+
op: 'tap.ack',
|
|
154
|
+
message: message.message,
|
|
155
|
+
});
|
|
156
|
+
})();
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
try {
|
|
160
|
+
socket = await openBridgeSocket(socketPath, onLine);
|
|
161
|
+
} catch {
|
|
162
|
+
return 'unreachable';
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
void (async () => {
|
|
166
|
+
await socket.closed;
|
|
167
|
+
|
|
168
|
+
const end: TapEnd = opened ? 'lost' : 'unreachable';
|
|
169
|
+
|
|
170
|
+
ended.resolve(end);
|
|
171
|
+
})();
|
|
172
|
+
|
|
173
|
+
socket.writeLine({ v: 1, id: 'tap.open', op: 'tap.open' });
|
|
174
|
+
|
|
175
|
+
const end = await ended.promise;
|
|
176
|
+
|
|
177
|
+
await printing.catch(() => {});
|
|
178
|
+
|
|
179
|
+
socket.end();
|
|
180
|
+
|
|
181
|
+
return end;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Sends every report the outbox holds, and returns the file behind each
|
|
185
|
+
// request id it sent; a file that is no report is removed, since no answer
|
|
186
|
+
// would ever clear it.
|
|
187
|
+
// oxlint-disable-next-line prefer-readonly-parameter-types -- a socket is a live handle
|
|
188
|
+
function sendOutboxReports(socket: BridgeSocket, outbox: string): Map<string, string> {
|
|
189
|
+
const sent = new Map<string, string>();
|
|
190
|
+
|
|
191
|
+
let files: string[];
|
|
192
|
+
|
|
193
|
+
try {
|
|
194
|
+
files = readdirSync(outbox).filter((file) => file.endsWith('.json'));
|
|
195
|
+
} catch {
|
|
196
|
+
return sent;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
for (const file of files) {
|
|
200
|
+
const path = join(outbox, file);
|
|
201
|
+
let report: z.infer<typeof OUTBOX_REPORT_SCHEMA> | null = null;
|
|
202
|
+
|
|
203
|
+
try {
|
|
204
|
+
const parsed = OUTBOX_REPORT_SCHEMA.safeParse(JSON.parse(readFileSync(path, 'utf8')));
|
|
205
|
+
|
|
206
|
+
report = parsed.success ? parsed.data : null;
|
|
207
|
+
} catch {}
|
|
208
|
+
|
|
209
|
+
if (report === null) {
|
|
210
|
+
rmSync(path, { force: true });
|
|
211
|
+
continue;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const id = `report:${report.reportID}`;
|
|
215
|
+
|
|
216
|
+
sent.set(id, path);
|
|
217
|
+
|
|
218
|
+
socket.writeLine({
|
|
219
|
+
v: 1,
|
|
220
|
+
id,
|
|
221
|
+
op: 'report',
|
|
222
|
+
reportID: report.reportID,
|
|
223
|
+
payload: report.payload,
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
return sent;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// oxlint-disable-next-line prefer-readonly-parameter-types -- the set grows in place
|
|
231
|
+
function updateSeenIDs(seen: Set<string>, id: string): void {
|
|
232
|
+
seen.add(id);
|
|
233
|
+
|
|
234
|
+
for (const oldest of seen) {
|
|
235
|
+
if (seen.size <= SEEN_LIMIT) {
|
|
236
|
+
break;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
seen.delete(oldest);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
@@ -3,6 +3,6 @@ import type { Tagged } from 'type-fest';
|
|
|
3
3
|
/**
|
|
4
4
|
* The session id an agent CLI issues for its own resume mechanism — what
|
|
5
5
|
* `claude --resume <id>` takes. Adapters mint it from a hook payload or a
|
|
6
|
-
* stored fleet row, and
|
|
6
|
+
* stored fleet row, and a fleet row holds at most one.
|
|
7
7
|
*/
|
|
8
8
|
export type AgentSessionID = Tagged<string, 'AgentSessionID'>;
|
|
@@ -3,14 +3,22 @@
|
|
|
3
3
|
* scrubbed out. A child that inherits those variables behaves as part of
|
|
4
4
|
* the parent session — Claude transcripts nest, and Grok joins the
|
|
5
5
|
* parent's in-process dashboard — which breaks resume and isolation.
|
|
6
|
+
*
|
|
7
|
+
* Variables named in `withheld` are left out of what the child inherits,
|
|
8
|
+
* such as a workspace credential that only the daemon's own git commands
|
|
9
|
+
* may see. The extras the caller sets explicitly are added after, so one
|
|
10
|
+
* of them is kept even when it shares a withheld name.
|
|
6
11
|
*/
|
|
7
12
|
export function collectCleanEnv(
|
|
8
13
|
extra: Readonly<Record<string, string>> = {},
|
|
14
|
+
withheld: readonly string[] = [],
|
|
9
15
|
): Record<string, string> {
|
|
10
16
|
const env: Record<string, string> = {};
|
|
11
17
|
|
|
18
|
+
const skipped = new Set(withheld);
|
|
19
|
+
|
|
12
20
|
for (const [key, value] of Object.entries(process.env)) {
|
|
13
|
-
if (value === undefined || isParentSessionKey(key)) {
|
|
21
|
+
if (value === undefined || isParentSessionKey(key) || skipped.has(key)) {
|
|
14
22
|
continue;
|
|
15
23
|
}
|
|
16
24
|
|