@vibesdotdev/agent-execution 0.0.0-stage → 1.0.0-rc.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.
Files changed (191) hide show
  1. package/LICENSE +18 -0
  2. package/README.md +53 -2
  3. package/dist/code-session/agent-code-session.consumer.descriptor.d.ts +68 -0
  4. package/dist/code-session/agent-code-session.consumer.descriptor.d.ts.map +1 -0
  5. package/dist/code-session/agent-code-session.consumer.descriptor.js +31 -0
  6. package/dist/code-session/agent-code-session.consumer.descriptor.js.map +1 -0
  7. package/dist/code-session/agent-code-session.consumer.plugin.d.ts +18 -0
  8. package/dist/code-session/agent-code-session.consumer.plugin.d.ts.map +1 -0
  9. package/dist/code-session/agent-code-session.consumer.plugin.js +47 -0
  10. package/dist/code-session/agent-code-session.consumer.plugin.js.map +1 -0
  11. package/dist/code-session/agent-code-session.descriptor.d.ts +163 -0
  12. package/dist/code-session/agent-code-session.descriptor.d.ts.map +1 -0
  13. package/dist/code-session/agent-code-session.descriptor.js +129 -0
  14. package/dist/code-session/agent-code-session.descriptor.js.map +1 -0
  15. package/dist/code-session/agent-code-session.impl.consumer.d.ts +224 -0
  16. package/dist/code-session/agent-code-session.impl.consumer.d.ts.map +1 -0
  17. package/dist/code-session/agent-code-session.impl.consumer.js +2131 -0
  18. package/dist/code-session/agent-code-session.impl.consumer.js.map +1 -0
  19. package/dist/code-session/agent-code-session.kind.d.ts +15 -0
  20. package/dist/code-session/agent-code-session.kind.d.ts.map +1 -0
  21. package/dist/code-session/agent-code-session.kind.js +17 -0
  22. package/dist/code-session/agent-code-session.kind.js.map +1 -0
  23. package/dist/code-session/agent-code-session.plugin.d.ts +17 -0
  24. package/dist/code-session/agent-code-session.plugin.d.ts.map +1 -0
  25. package/dist/code-session/agent-code-session.plugin.js +28 -0
  26. package/dist/code-session/agent-code-session.plugin.js.map +1 -0
  27. package/dist/code-session/billing/gates.d.ts +52 -0
  28. package/dist/code-session/billing/gates.d.ts.map +1 -0
  29. package/dist/code-session/billing/gates.js +75 -0
  30. package/dist/code-session/billing/gates.js.map +1 -0
  31. package/dist/code-session/client/index.d.ts +2 -0
  32. package/dist/code-session/client/index.d.ts.map +1 -0
  33. package/dist/code-session/client/index.js +2 -0
  34. package/dist/code-session/client/index.js.map +1 -0
  35. package/dist/code-session/client/resolve.d.ts +20 -0
  36. package/dist/code-session/client/resolve.d.ts.map +1 -0
  37. package/dist/code-session/client/resolve.js +145 -0
  38. package/dist/code-session/client/resolve.js.map +1 -0
  39. package/dist/code-session/docs/agent-code-session.docs.descriptor.d.ts +4 -0
  40. package/dist/code-session/docs/agent-code-session.docs.descriptor.d.ts.map +1 -0
  41. package/dist/code-session/docs/agent-code-session.docs.descriptor.js +109 -0
  42. package/dist/code-session/docs/agent-code-session.docs.descriptor.js.map +1 -0
  43. package/dist/code-session/git-credential.d.ts +87 -0
  44. package/dist/code-session/git-credential.d.ts.map +1 -0
  45. package/dist/code-session/git-credential.js +107 -0
  46. package/dist/code-session/git-credential.js.map +1 -0
  47. package/dist/code-session/host-target.d.ts +76 -0
  48. package/dist/code-session/host-target.d.ts.map +1 -0
  49. package/dist/code-session/host-target.js +81 -0
  50. package/dist/code-session/host-target.js.map +1 -0
  51. package/dist/code-session/index.d.ts +20 -0
  52. package/dist/code-session/index.d.ts.map +1 -0
  53. package/dist/code-session/index.js +20 -0
  54. package/dist/code-session/index.js.map +1 -0
  55. package/dist/code-session/interfaces/code-session.d.ts +448 -0
  56. package/dist/code-session/interfaces/code-session.d.ts.map +1 -0
  57. package/dist/code-session/interfaces/code-session.js +373 -0
  58. package/dist/code-session/interfaces/code-session.js.map +1 -0
  59. package/dist/code-session/operation-lease.d.ts +48 -0
  60. package/dist/code-session/operation-lease.d.ts.map +1 -0
  61. package/dist/code-session/operation-lease.js +73 -0
  62. package/dist/code-session/operation-lease.js.map +1 -0
  63. package/dist/code-session/remote-setup-custody.d.ts +59 -0
  64. package/dist/code-session/remote-setup-custody.d.ts.map +1 -0
  65. package/dist/code-session/remote-setup-custody.js +78 -0
  66. package/dist/code-session/remote-setup-custody.js.map +1 -0
  67. package/dist/code-session/remote-workspace-fs.d.ts +119 -0
  68. package/dist/code-session/remote-workspace-fs.d.ts.map +1 -0
  69. package/dist/code-session/remote-workspace-fs.js +257 -0
  70. package/dist/code-session/remote-workspace-fs.js.map +1 -0
  71. package/dist/code-session/session-metadata.d.ts +40 -0
  72. package/dist/code-session/session-metadata.d.ts.map +1 -0
  73. package/dist/code-session/session-metadata.js +69 -0
  74. package/dist/code-session/session-metadata.js.map +1 -0
  75. package/dist/code-session-relay/agent-code-session-relay.descriptor.d.ts +78 -0
  76. package/dist/code-session-relay/agent-code-session-relay.descriptor.d.ts.map +1 -0
  77. package/dist/code-session-relay/agent-code-session-relay.descriptor.js +38 -0
  78. package/dist/code-session-relay/agent-code-session-relay.descriptor.js.map +1 -0
  79. package/dist/code-session-relay/agent-code-session-relay.impl.d.ts +120 -0
  80. package/dist/code-session-relay/agent-code-session-relay.impl.d.ts.map +1 -0
  81. package/dist/code-session-relay/agent-code-session-relay.impl.js +1392 -0
  82. package/dist/code-session-relay/agent-code-session-relay.impl.js.map +1 -0
  83. package/dist/code-session-relay/agent-code-session-relay.plugin.d.ts +14 -0
  84. package/dist/code-session-relay/agent-code-session-relay.plugin.d.ts.map +1 -0
  85. package/dist/code-session-relay/agent-code-session-relay.plugin.js +25 -0
  86. package/dist/code-session-relay/agent-code-session-relay.plugin.js.map +1 -0
  87. package/dist/code-session-relay/index.d.ts +13 -0
  88. package/dist/code-session-relay/index.d.ts.map +1 -0
  89. package/dist/code-session-relay/index.js +13 -0
  90. package/dist/code-session-relay/index.js.map +1 -0
  91. package/dist/code-session-relay/relay-dial.d.ts +72 -0
  92. package/dist/code-session-relay/relay-dial.d.ts.map +1 -0
  93. package/dist/code-session-relay/relay-dial.js +177 -0
  94. package/dist/code-session-relay/relay-dial.js.map +1 -0
  95. package/dist/index.d.ts +10 -0
  96. package/dist/index.d.ts.map +1 -0
  97. package/dist/index.js +10 -0
  98. package/dist/index.js.map +1 -0
  99. package/dist/sandbox/agent-sandbox.consumer.descriptor.d.ts +35 -0
  100. package/dist/sandbox/agent-sandbox.consumer.descriptor.d.ts.map +1 -0
  101. package/dist/sandbox/agent-sandbox.consumer.descriptor.js +19 -0
  102. package/dist/sandbox/agent-sandbox.consumer.descriptor.js.map +1 -0
  103. package/dist/sandbox/agent-sandbox.consumer.plugin.d.ts +9 -0
  104. package/dist/sandbox/agent-sandbox.consumer.plugin.d.ts.map +1 -0
  105. package/dist/sandbox/agent-sandbox.consumer.plugin.js +33 -0
  106. package/dist/sandbox/agent-sandbox.consumer.plugin.js.map +1 -0
  107. package/dist/sandbox/agent-sandbox.descriptor.d.ts +76 -0
  108. package/dist/sandbox/agent-sandbox.descriptor.d.ts.map +1 -0
  109. package/dist/sandbox/agent-sandbox.descriptor.js +57 -0
  110. package/dist/sandbox/agent-sandbox.descriptor.js.map +1 -0
  111. package/dist/sandbox/agent-sandbox.impl.consumer.d.ts +15 -0
  112. package/dist/sandbox/agent-sandbox.impl.consumer.d.ts.map +1 -0
  113. package/dist/sandbox/agent-sandbox.impl.consumer.js +23 -0
  114. package/dist/sandbox/agent-sandbox.impl.consumer.js.map +1 -0
  115. package/dist/sandbox/agent-sandbox.kind.d.ts +14 -0
  116. package/dist/sandbox/agent-sandbox.kind.d.ts.map +1 -0
  117. package/dist/sandbox/agent-sandbox.kind.js +16 -0
  118. package/dist/sandbox/agent-sandbox.kind.js.map +1 -0
  119. package/dist/sandbox/agent-sandbox.plugin.d.ts +15 -0
  120. package/dist/sandbox/agent-sandbox.plugin.d.ts.map +1 -0
  121. package/dist/sandbox/agent-sandbox.plugin.js +25 -0
  122. package/dist/sandbox/agent-sandbox.plugin.js.map +1 -0
  123. package/dist/sandbox/client/resolve.d.ts +21 -0
  124. package/dist/sandbox/client/resolve.d.ts.map +1 -0
  125. package/dist/sandbox/client/resolve.js +103 -0
  126. package/dist/sandbox/client/resolve.js.map +1 -0
  127. package/dist/sandbox/docs/agent-sandbox.docs.descriptor.d.ts +4 -0
  128. package/dist/sandbox/docs/agent-sandbox.docs.descriptor.d.ts.map +1 -0
  129. package/dist/sandbox/docs/agent-sandbox.docs.descriptor.js +75 -0
  130. package/dist/sandbox/docs/agent-sandbox.docs.descriptor.js.map +1 -0
  131. package/dist/sandbox/index.d.ts +15 -0
  132. package/dist/sandbox/index.d.ts.map +1 -0
  133. package/dist/sandbox/index.js +14 -0
  134. package/dist/sandbox/index.js.map +1 -0
  135. package/dist/sandbox/interfaces/sandbox.d.ts +61 -0
  136. package/dist/sandbox/interfaces/sandbox.d.ts.map +1 -0
  137. package/dist/sandbox/interfaces/sandbox.js +54 -0
  138. package/dist/sandbox/interfaces/sandbox.js.map +1 -0
  139. package/dist/schemas/code-session-resolution.d.ts +20 -0
  140. package/dist/schemas/code-session-resolution.d.ts.map +1 -0
  141. package/dist/schemas/code-session-resolution.js +11 -0
  142. package/dist/schemas/code-session-resolution.js.map +1 -0
  143. package/dist/schemas/session-operation.d.ts +73 -0
  144. package/dist/schemas/session-operation.d.ts.map +1 -0
  145. package/dist/schemas/session-operation.js +47 -0
  146. package/dist/schemas/session-operation.js.map +1 -0
  147. package/dist/schemas/voice.d.ts +87 -0
  148. package/dist/schemas/voice.d.ts.map +1 -0
  149. package/dist/schemas/voice.js +70 -0
  150. package/dist/schemas/voice.js.map +1 -0
  151. package/dist/voice/agent-voice.consumer.descriptor.d.ts +43 -0
  152. package/dist/voice/agent-voice.consumer.descriptor.d.ts.map +1 -0
  153. package/dist/voice/agent-voice.consumer.descriptor.js +20 -0
  154. package/dist/voice/agent-voice.consumer.descriptor.js.map +1 -0
  155. package/dist/voice/agent-voice.consumer.plugin.d.ts +4 -0
  156. package/dist/voice/agent-voice.consumer.plugin.d.ts.map +1 -0
  157. package/dist/voice/agent-voice.consumer.plugin.js +13 -0
  158. package/dist/voice/agent-voice.consumer.plugin.js.map +1 -0
  159. package/dist/voice/agent-voice.descriptor.d.ts +82 -0
  160. package/dist/voice/agent-voice.descriptor.d.ts.map +1 -0
  161. package/dist/voice/agent-voice.descriptor.js +79 -0
  162. package/dist/voice/agent-voice.descriptor.js.map +1 -0
  163. package/dist/voice/agent-voice.impl.consumer.d.ts +35 -0
  164. package/dist/voice/agent-voice.impl.consumer.d.ts.map +1 -0
  165. package/dist/voice/agent-voice.impl.consumer.js +50 -0
  166. package/dist/voice/agent-voice.impl.consumer.js.map +1 -0
  167. package/dist/voice/agent-voice.kind.d.ts +67 -0
  168. package/dist/voice/agent-voice.kind.d.ts.map +1 -0
  169. package/dist/voice/agent-voice.kind.js +26 -0
  170. package/dist/voice/agent-voice.kind.js.map +1 -0
  171. package/dist/voice/agent-voice.plugin.d.ts +4 -0
  172. package/dist/voice/agent-voice.plugin.d.ts.map +1 -0
  173. package/dist/voice/agent-voice.plugin.js +18 -0
  174. package/dist/voice/agent-voice.plugin.js.map +1 -0
  175. package/dist/voice/client.d.ts +38 -0
  176. package/dist/voice/client.d.ts.map +1 -0
  177. package/dist/voice/client.js +29 -0
  178. package/dist/voice/client.js.map +1 -0
  179. package/dist/voice/index.d.ts +14 -0
  180. package/dist/voice/index.d.ts.map +1 -0
  181. package/dist/voice/index.js +14 -0
  182. package/dist/voice/index.js.map +1 -0
  183. package/dist/voice/pipeline.d.ts +66 -0
  184. package/dist/voice/pipeline.d.ts.map +1 -0
  185. package/dist/voice/pipeline.js +330 -0
  186. package/dist/voice/pipeline.js.map +1 -0
  187. package/dist/voice/session.d.ts +29 -0
  188. package/dist/voice/session.d.ts.map +1 -0
  189. package/dist/voice/session.js +78 -0
  190. package/dist/voice/session.js.map +1 -0
  191. package/package.json +279 -4
@@ -0,0 +1,2131 @@
1
+ /**
2
+ * Consumer Agent Code Session — runtime-routed impl (WS3)
3
+ *
4
+ * A *persistent* code session: unlike the throwaway `agent/sandbox`, the
5
+ * workspace under the active Vibes profile SURVIVES across calls until
6
+ * an explicit `teardown`. Lifecycle:
7
+ *
8
+ * initialize → clone repo (git CLI) into the persistent workspace, register
9
+ * session metadata so `attach` can resume.
10
+ * attach → resume a known session (rehydrate from the metadata store).
11
+ * runCommand→ foreground (capture stdout/stderr/exit) OR background (a
12
+ * long-lived process handle kept in an in-process registry).
13
+ * read/write/patch → file ops scoped under the workspace root.
14
+ * gitStatus/Commit/Push → git CLI in the workspace.
15
+ * exposePort → record a port + return a localhost preview URL.
16
+ * teardown → kill background procs, remove the persistent workspace.
17
+ *
18
+ * All file ops route through the `filesystem/adapter` kind and all command
19
+ * execution through the `runtime/execution` kind (consumer impls back both):
20
+ * one-shot git/foreground commands use `execute()`; a long-lived background
21
+ * process uses `spawn()` and keeps its `ProcessHandle` live in an in-process
22
+ * registry. Raw `node:fs` / `Bun.spawn` are NOT used here — the impl stays
23
+ * swappable.
24
+ *
25
+ * Env is composed through the handed runtime's `composeChildEnv`: only host essentials +
26
+ * the caller's explicit env are forwarded; nothing from the host's own environment leaks
27
+ * into session processes.
28
+ *
29
+ * MVP host routing: a `{ kind:'host', hostId }` target runs LOCALLY (on this
30
+ * consumer worker/daemon). Dispatching to a *specific remote* Host daemon by
31
+ * `hostId` is a documented follow-up — the `hostId` is recorded on the session
32
+ * handle so a future remote-dispatch layer can target it.
33
+ */
34
+ import { getLogger } from '@vibesdotdev/logging';
35
+ import { sha256Hex } from '@vibesdotdev/shared/hashing';
36
+ import { CapabilityError, CaughtFailureSchema, composeChildEnv, createRuntimeImplementation, isCapabilityError, readEnv, settle, valueFreeFailure } from '@vibesdotdev/kernel';
37
+ import { acquireWireProcess } from '@vibesdotdev/kernel-rpc/execution';
38
+ import { getVibesRemoteFrom } from '@vibesdotdev/kernel-rpc';
39
+ import { createFileExclusiveChecked, resolveFileSystemAdapter } from '@vibesdotdev/filesystem';
40
+ import { getPathRoots, getRemotePathRoots } from '@vibesdotdev/paths';
41
+ import * as z from 'zod/v4';
42
+ import { CODE_SESSION_MIN_TRUST_LEVEL, AgentCodeSessionDescriptorSchema } from "./agent-code-session.descriptor.js";
43
+ import consumerCodeSessionDescriptor from "./agent-code-session.consumer.descriptor.js";
44
+ import { codeSessionCredentialArgs } from "./git-credential.js";
45
+ import { DEFAULT_SESSION_HOST_TARGET } from "./host-target.js";
46
+ import { CodeSessionOperationIdSchema, PersistedCodeSessionOperationSchema, codeSessionOperationFingerprint, codeSessionOperationKey, codeSessionTempName, parseCodeSessionCreateOrGetInput, parseCodeSessionId, parseCodeSessionInitializeInput, parseCodeSessionPath, parseExposePortInput, parseGitApplyInput, parseGitCommitInput, parseGitCommitOptions, parseGitPushInput, parsePatchFileInput, parseBackgroundProcessReadInput, parseBackgroundProcessWaitInput, parseProcessId, parseProcessSignal, parsePushCredentialResolver, parseRunCommandInput, parseWriteFileInput } from "./interfaces/code-session.js";
47
+ import { pollUntil, withLeaseHeartbeat } from "./operation-lease.js";
48
+ import { remoteSetupCustody } from "./remote-setup-custody.js";
49
+ import { resolveRemoteWorkspaceFileSystem, parseRemoteFileSystemIO } from "./remote-workspace-fs.js";
50
+ import { CODE_SESSION_METADATA_FILENAME, parseCodeSessionMetadata, serializeCodeSessionMetadata } from "./session-metadata.js";
51
+ /** The consumer's logger on the runtime that owns the session (C083a): never an ambient default-bound one. */
52
+ const consumerLogger = (runtime) => getLogger('agent.code-session.consumer', runtime);
53
+ /** Directory below the active Vibes profile that owns durable local sessions. */
54
+ const SESSION_DIRECTORY = 'code-sessions';
55
+ /** Pre-profile local root. Read only for explicit, stable-id reattachment. */
56
+ const LEGACY_LOCAL_SESSION_ROOT = '/tmp/vibes-code-session';
57
+ const CONSUMER_FILESYSTEM_ADAPTER_ID = 'filesystem-adapter-bun';
58
+ const SAFE_SESSION_ID = /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/;
59
+ const CodeSessionOperationLeaseSchema = z
60
+ .object({
61
+ holderId: z.string().uuid(),
62
+ expiresAt: z.number().finite().nonnegative()
63
+ })
64
+ .strict();
65
+ const FileContentsSchema = z.union([z.string(), z.instanceof(Uint8Array)]);
66
+ function utf8Text(contents) {
67
+ const admitted = FileContentsSchema.parse(contents);
68
+ const text = z.string().safeParse(admitted);
69
+ return text.success ? text.data : new TextDecoder().decode(z.instanceof(Uint8Array).parse(admitted));
70
+ }
71
+ function requestInvalid(field) {
72
+ return new CapabilityError('The code-session request is invalid.', 'INVALID_INPUT', {
73
+ reason: 'code-session-request-invalid',
74
+ retryable: false,
75
+ details: { field }
76
+ });
77
+ }
78
+ function metadataInvalid(cause) {
79
+ return new CapabilityError('Code-session control metadata is malformed.', 'INVALID_INPUT', {
80
+ reason: 'code-session-metadata-invalid',
81
+ retryable: false,
82
+ cause
83
+ });
84
+ }
85
+ /** Trust ladder ranks for the writable-FS escalation check. */
86
+ const TRUST_RANK = { '0': 0, '1': 1, '2a': 2, '2b': 3, '3': 4 };
87
+ function trustAllowsCodeSession(trustLevel) {
88
+ return TRUST_RANK[trustLevel] >= TRUST_RANK[CODE_SESSION_MIN_TRUST_LEVEL];
89
+ }
90
+ function isSafeSessionId(sessionId) {
91
+ return SAFE_SESSION_ID.test(sessionId);
92
+ }
93
+ function requireSafeSessionId(sessionId) {
94
+ if (!isSafeSessionId(sessionId)) {
95
+ throw requestInvalid('sessionId');
96
+ }
97
+ return sessionId;
98
+ }
99
+ function ownedWorkspacePath(fs, sessionRoot, sessionId) {
100
+ const safeId = requireSafeSessionId(sessionId);
101
+ const root = fs.normalize(fs.resolve(sessionRoot));
102
+ const candidate = fs.normalize(fs.resolve(root, safeId));
103
+ const rootWithSep = root.endsWith('/') ? root : `${root}/`;
104
+ if (candidate === root || !candidate.startsWith(rootWithSep)) {
105
+ throw requestInvalid('sessionId');
106
+ }
107
+ return candidate;
108
+ }
109
+ function assertOwnedWorkspacePath(fs, sessionRoot, sessionId, workspacePath) {
110
+ const expected = ownedWorkspacePath(fs, sessionRoot, sessionId);
111
+ if (fs.normalize(fs.resolve(workspacePath)) !== expected) {
112
+ throw new CapabilityError('Workspace cleanup is outside its owned session root.', 'CONFLICT', {
113
+ reason: 'code-session-cleanup-conflict',
114
+ retryable: false,
115
+ details: { sessionId }
116
+ });
117
+ }
118
+ }
119
+ const RemoteRuntimeExecutionSchema = z.custom((value) => z.object({ spawn: z.function() }).safeParse(value).success);
120
+ function parseRemoteRuntimeExecution(value, hostId) {
121
+ try {
122
+ const parsed = RemoteRuntimeExecutionSchema.safeParse(value);
123
+ if (!parsed.success) {
124
+ throw new CapabilityError('The remote host exposes no execution capability.', 'UNAVAILABLE', {
125
+ reason: 'code-session-host-unavailable', retryable: false, details: { hostId }
126
+ });
127
+ }
128
+ return parsed.data;
129
+ }
130
+ catch (caught) {
131
+ const failure = CaughtFailureSchema.parse(caught);
132
+ if (isCapabilityError(failure))
133
+ throw failure;
134
+ throw new CapabilityError('The remote execution contract could not be read.', 'UNAVAILABLE', {
135
+ reason: 'code-session-host-unavailable',
136
+ retryable: false,
137
+ details: { hostId },
138
+ cause: valueFreeFailure(failure)
139
+ });
140
+ }
141
+ }
142
+ /** Output chunks a background process keeps for `readBackgroundProcess` / `waitBackgroundProcess`. */
143
+ const MAX_BACKGROUND_CHUNKS = 512;
144
+ /** How long a wait that saw the exit gives the pipes to deliver their last output. */
145
+ const BACKGROUND_DRAIN_JOIN_MS = 1_000;
146
+ /** The output a background entry produced after `since`, split by stream. */
147
+ function backgroundOutputSince(entry, since, timedOut) {
148
+ const dropped = entry.produced - entry.output.length;
149
+ const from = Math.max(since ?? 0, dropped) - dropped;
150
+ const stdout = new TextDecoder();
151
+ const stderr = new TextDecoder();
152
+ let out = '';
153
+ let err = '';
154
+ for (const chunk of entry.output.slice(Math.min(from, entry.output.length))) {
155
+ if (chunk.stream === 'stdout')
156
+ out += stdout.decode(chunk.bytes, { stream: true });
157
+ else
158
+ err += stderr.decode(chunk.bytes, { stream: true });
159
+ }
160
+ out += stdout.decode();
161
+ err += stderr.decode();
162
+ return {
163
+ procId: entry.procId,
164
+ stdout: out,
165
+ stderr: err,
166
+ cursor: entry.produced,
167
+ running: entry.running,
168
+ exitCode: entry.running === false ? (entry.exitCode ?? null) : null,
169
+ timedOut
170
+ };
171
+ }
172
+ /** `lifecycle.wait` rejects a lapsed deadline as `CapabilityError` `TIMEOUT` `wait-timeout`. */
173
+ function isBackgroundWaitTimeout(failure) {
174
+ return isCapabilityError(failure) && failure.code === 'TIMEOUT' && failure.reason === 'wait-timeout';
175
+ }
176
+ /** Reject paths that escape the workspace root (path-traversal guard). */
177
+ function resolveInWorkspace(fs, workspacePath, relPath) {
178
+ const abs = fs.resolve(workspacePath, relPath);
179
+ const normalizedRoot = fs.normalize(workspacePath);
180
+ const rootWithSep = normalizedRoot.endsWith('/') ? normalizedRoot : `${normalizedRoot}/`;
181
+ if (abs !== normalizedRoot && !abs.startsWith(rootWithSep)) {
182
+ throw requestInvalid('path');
183
+ }
184
+ return abs;
185
+ }
186
+ /** Drain a (possibly null) ReadableStream into a UTF-8 string. */
187
+ async function readStream(stream) {
188
+ if (!stream)
189
+ return '';
190
+ return new Response(stream).text();
191
+ }
192
+ /**
193
+ * Invoke `onAllocated` without letting it take down the initialize. A caller's
194
+ * bookkeeping failure must not strand an allocated workspace.
195
+ */
196
+ async function notifyAllocated(input, sessionId, logger) {
197
+ if (!input.onAllocated)
198
+ return;
199
+ try {
200
+ await input.onAllocated(sessionId);
201
+ }
202
+ catch (caught) {
203
+ const failure = valueFreeFailure(CaughtFailureSchema.parse(caught));
204
+ logger.warn('onAllocated threw; continuing initialization', {
205
+ sessionId,
206
+ failure
207
+ });
208
+ return failure;
209
+ }
210
+ }
211
+ export class ConsumerCodeSession {
212
+ descriptor;
213
+ /** In-process session registry, keyed by sessionId — backs `attach`. */
214
+ sessions = new Map();
215
+ /** Live background procs per session. */
216
+ background = new Map();
217
+ /** Host-supplied resolver for a push credential keyed by repositoryId. */
218
+ pushCredentialResolver;
219
+ /** In-flight attach-on-demand attempts, keyed by sessionId — collapses concurrent callers onto one attach. */
220
+ attachInFlight = new Map();
221
+ /** Cached remote execution handles per non-local hostId (fleet wire). */
222
+ remotes = new Map();
223
+ remoteActiveHomes = new Map();
224
+ /** Cached remote workspace filesystem adapters per non-local hostId (fleet wire). */
225
+ remoteFilesystems = new Map();
226
+ /**
227
+ * Same-process create retries collapse before they reach the durable operation
228
+ * ledger. Claims are registered synchronously, keyed by the trimmed operation
229
+ * id, so call order is claim order regardless of when hashing finishes.
230
+ */
231
+ operationInFlight = new Map();
232
+ /** Serializes git add/commit per git repository root so concurrent sessions never collide on .git/index.lock. */
233
+ gitCommitQueues = new Map();
234
+ runtime;
235
+ activeHome;
236
+ constructor(descriptor, runtime) {
237
+ this.descriptor = descriptor;
238
+ this.runtime = runtime;
239
+ this.activeHome = getPathRoots(this.runtime).activeHome;
240
+ }
241
+ /** This session's logger, on the runtime that owns it. */
242
+ get log() {
243
+ return consumerLogger(this.runtime);
244
+ }
245
+ now() {
246
+ return this.runtime.lifecycle.now();
247
+ }
248
+ nowIso() {
249
+ return new Date().toISOString();
250
+ }
251
+ /** Local control records stay under this runtime's active profile. */
252
+ sessionRoot(fs) {
253
+ return fs.normalize(fs.resolve(fs.join(this.activeHome, SESSION_DIRECTORY)));
254
+ }
255
+ async targetSessionRoot(fs, hostTarget) {
256
+ if (hostTarget.kind === 'host' && hostTarget.hostId !== 'local') {
257
+ const remote = await this.remoteRuntime(hostTarget.hostId);
258
+ const roots = await getRemotePathRoots(remote);
259
+ const previous = this.remoteActiveHomes.get(hostTarget.hostId);
260
+ if (previous && previous !== roots.activeHome) {
261
+ throw new CapabilityError('Remote profile changed; explicit session recovery is required.', 'CONFLICT', {
262
+ reason: 'code-session-profile-conflict',
263
+ retryable: false,
264
+ details: { hostId: hostTarget.hostId }
265
+ });
266
+ }
267
+ this.remoteActiveHomes.set(hostTarget.hostId, roots.activeHome);
268
+ return fs.normalize(fs.resolve(fs.join(roots.activeHome, SESSION_DIRECTORY)));
269
+ }
270
+ return this.sessionRoot(fs);
271
+ }
272
+ /** The temp root of the host a session runs on, from that host's path roots. */
273
+ async targetTempRoot(fs, hostTarget) {
274
+ const roots = hostTarget.kind === 'host' && hostTarget.hostId !== 'local'
275
+ ? await getRemotePathRoots(await this.remoteRuntime(hostTarget.hostId))
276
+ : getPathRoots(this.runtime);
277
+ return fs.normalize(fs.resolve(roots.tempDir));
278
+ }
279
+ setPushCredentialResolver(resolver) {
280
+ this.pushCredentialResolver = parsePushCredentialResolver(resolver);
281
+ }
282
+ /** Workspace-local process state belongs to the control root, not source files.
283
+ * Keep a private home across reattachments without inheriting host credentials.
284
+ * TMPDIR lives under the host's temp root with a short name, so sockets bound
285
+ * under it fit the platform's socket path limit.
286
+ */
287
+ async processEnvironment(workspacePath, hostTarget, extra) {
288
+ const fs = await this.workspaceFilesystem(hostTarget);
289
+ const key = sha256Hex(workspacePath);
290
+ const root = fs.join(await this.targetSessionRoot(fs, hostTarget), '.environments', key);
291
+ const home = fs.join(root, 'home');
292
+ const temp = fs.join(await this.targetTempRoot(fs, hostTarget), codeSessionTempName(key));
293
+ await Promise.all([fs.mkdir(home, { recursive: true }), fs.mkdir(temp, { recursive: true })]);
294
+ const declared = {
295
+ HOME: home,
296
+ TMPDIR: temp,
297
+ VIBES_WORKSPACE_PATH: workspacePath,
298
+ GIT_CONFIG_COUNT: '1',
299
+ GIT_CONFIG_KEY_0: 'commit.gpgsign',
300
+ GIT_CONFIG_VALUE_0: 'false',
301
+ ...extra
302
+ };
303
+ if (hostTarget.kind === 'host' && hostTarget.hostId !== 'local')
304
+ return declared;
305
+ await this.seedGitAuthorIdentity(fs, home);
306
+ const env = composeChildEnv(this.runtime, { env: { PWD: workspacePath, ...declared } });
307
+ if (!readEnv({ platform: this.runtime.localEnvironment.getPlatform?.(), snapshot: env }, 'PATH')) {
308
+ throw new CapabilityError('The runtime that owns this child captured no PATH.', 'UNAVAILABLE', {
309
+ reason: 'host-path-unavailable', retryable: false
310
+ });
311
+ }
312
+ return env;
313
+ }
314
+ async seedGitAuthorIdentity(fs, home) {
315
+ const targetGitConfig = fs.join(home, '.gitconfig');
316
+ if (await fs.exists(targetGitConfig))
317
+ return;
318
+ const hostHome = this.runtime.getEnv('HOME');
319
+ if (!hostHome)
320
+ return;
321
+ const sourceGitConfig = fs.join(hostHome, '.gitconfig');
322
+ if (!(await fs.exists(sourceGitConfig)))
323
+ return;
324
+ const content = utf8Text(await fs.readFile(sourceGitConfig, 'utf8'));
325
+ let inUserSection = false;
326
+ let name;
327
+ let email;
328
+ for (const rawLine of content.split(/\r?\n/)) {
329
+ const line = rawLine.trim();
330
+ if (!line || line.startsWith('#') || line.startsWith(';'))
331
+ continue;
332
+ const sectionMatch = /^\[([^\]]+)\]$/.exec(line);
333
+ if (sectionMatch) {
334
+ inUserSection = sectionMatch[1].trim().toLowerCase() === 'user';
335
+ continue;
336
+ }
337
+ if (!inUserSection)
338
+ continue;
339
+ const kvMatch = /^([A-Za-z0-9-]+)\s*=\s*(.*)$/.exec(line);
340
+ if (!kvMatch)
341
+ continue;
342
+ const key = kvMatch[1].toLowerCase();
343
+ const value = kvMatch[2].trim().replace(/^"(.*)"$/, '$1');
344
+ if (key === 'name' && value)
345
+ name = value;
346
+ else if (key === 'email' && value)
347
+ email = value;
348
+ }
349
+ if (!name && !email)
350
+ return;
351
+ const lines = ['[user]'];
352
+ if (name)
353
+ lines.push(`\tname = ${name}`);
354
+ if (email)
355
+ lines.push(`\temail = ${email}`);
356
+ await fs.writeFile(targetGitConfig, `${lines.join('\n')}\n`, 'utf8');
357
+ }
358
+ /**
359
+ * Resolve the `runtime/execution` impl for a session's host target.
360
+ *
361
+ * THE JOIN (D-THE-JOIN): a `{ kind:'host', hostId }` target with a NON-local
362
+ * hostId reaches THAT host's execution impl over the fleet wire via
363
+ * `getVibesRemoteFrom(runtime, hostId)` (the proven path — fleet-daemon
364
+ * `reach-over-daemon.test.ts`). Local + default targets keep resolving the
365
+ * in-process impl exactly as before. There is NO silent local fallback: an
366
+ * unreachable host throws a precise, actionable error.
367
+ */
368
+ async execution() {
369
+ const impl = await this.runtime
370
+ .query('runtime/execution')
371
+ .resolve();
372
+ if (!impl) {
373
+ throw new CapabilityError('No execution capability is registered for this runtime scope.', 'UNAVAILABLE', {
374
+ reason: 'code-session-host-unavailable',
375
+ retryable: false
376
+ });
377
+ }
378
+ return impl;
379
+ }
380
+ /**
381
+ * Reach a remote host's `runtime/execution` over the fleet `runtime/rpc`
382
+ * wire. A `RemoteRuntime` pins its socket, so one connection per hostId is
383
+ * cached and reused across a session's commands; a failed resolve is dropped
384
+ * from the cache so a later call can retry once the host is paired.
385
+ */
386
+ remoteRuntime(hostId) {
387
+ let pending = this.remotes.get(hostId);
388
+ if (!pending) {
389
+ pending = getVibesRemoteFrom(this.runtime, hostId);
390
+ this.remotes.set(hostId, pending);
391
+ const owned = pending;
392
+ const evict = () => {
393
+ if (this.remotes.get(hostId) === owned) {
394
+ this.remotes.delete(hostId);
395
+ this.remoteFilesystems.delete(hostId);
396
+ }
397
+ };
398
+ void pending.then((remote) => remote.connection.closed.then(evict, (caught) => {
399
+ const failure = valueFreeFailure(CaughtFailureSchema.parse(caught));
400
+ this.log.warn('remote code-session connection closed with a failure', { hostId, failure });
401
+ evict();
402
+ return failure;
403
+ }), (caught) => {
404
+ const failure = valueFreeFailure(CaughtFailureSchema.parse(caught));
405
+ evict();
406
+ return failure;
407
+ });
408
+ }
409
+ return pending;
410
+ }
411
+ /**
412
+ * Spawn on the session's host. With `setup`, cancellation is checked after
413
+ * every pre-dispatch await: a cancelled setup returns `null` and dispatches
414
+ * nothing. A remote dispatch is owned by an acquisition retained in this
415
+ * runtime's custody (plugin `onDispose` drains it), so a late port is killed
416
+ * once and observed rather than abandoned.
417
+ */
418
+ async spawnOnHost(workspacePath, hostTarget, options, extra, setup) {
419
+ const remoteTarget = hostTarget.kind === 'host' && hostTarget.hostId !== 'local';
420
+ const custody = remoteSetupCustody(this.runtime);
421
+ if (remoteTarget)
422
+ custody.admit();
423
+ const env = await this.processEnvironment(workspacePath, hostTarget, extra);
424
+ if (setup?.cancelled())
425
+ return null;
426
+ if (remoteTarget) {
427
+ const remote = await this.remoteRuntime(hostTarget.hostId);
428
+ if (setup?.cancelled())
429
+ return null;
430
+ const execution = parseRemoteRuntimeExecution(await remote.query('runtime/execution'), hostTarget.hostId);
431
+ if (setup?.cancelled())
432
+ return null;
433
+ const request = { ...options, declaration: { env } };
434
+ custody.admit();
435
+ const acquisition = custody.retain(acquireWireProcess(this.runtime, () => execution.spawn(request)));
436
+ setup?.dispatched(acquisition);
437
+ return acquisition.ready;
438
+ }
439
+ const execution = await this.execution();
440
+ if (setup?.cancelled())
441
+ return null;
442
+ setup?.dispatched();
443
+ return execution.spawn({ ...options, env, envMode: 'replace' });
444
+ }
445
+ async spawnBackground(workspacePath, hostTarget, options, extra) {
446
+ const handle = await this.spawnOnHost(workspacePath, hostTarget, options, extra);
447
+ // Without a setup control nothing can cancel before dispatch.
448
+ if (!handle)
449
+ throw new CapabilityError('Background process setup was cancelled', 'UNAVAILABLE', { reason: 'code-session-setup-cancelled', retryable: false });
450
+ return handle;
451
+ }
452
+ /**
453
+ * Reach a remote host's workspace `filesystem/adapter` over the fleet
454
+ * `runtime/rpc` wire (UNIT-B3 / D-DAEMON-CLIENT), mirroring
455
+ * {@link remoteExecution}. One connection per hostId is cached and reused; a
456
+ * failed resolve is dropped so a later call can retry once the host is paired.
457
+ */
458
+ remoteFilesystem(hostId) {
459
+ let pending = this.remoteFilesystems.get(hostId);
460
+ if (!pending) {
461
+ pending = this.resolveRemoteFilesystem(hostId).then((adapter) => adapter, (caught) => {
462
+ this.remoteFilesystems.delete(hostId);
463
+ throw CapabilityError.from(CaughtFailureSchema.parse(caught));
464
+ });
465
+ this.remoteFilesystems.set(hostId, pending);
466
+ }
467
+ return pending;
468
+ }
469
+ async resolveRemoteFilesystem(hostId) {
470
+ let remote;
471
+ try {
472
+ remote = await this.remoteRuntime(hostId);
473
+ }
474
+ catch (caught) {
475
+ const failure = valueFreeFailure(CaughtFailureSchema.parse(caught));
476
+ this.log.warn('remote session host is unavailable', { hostId, failure });
477
+ throw new CapabilityError('The remote session host is unavailable.', 'UNAVAILABLE', {
478
+ reason: 'code-session-host-unavailable', retryable: true, details: { hostId }, cause: failure
479
+ });
480
+ }
481
+ const io = parseRemoteFileSystemIO(await remote.query('filesystem/adapter'), hostId);
482
+ return resolveRemoteWorkspaceFileSystem(io, hostId);
483
+ }
484
+ /**
485
+ * Resolve the LOCAL read/write filesystem adapter (the consumer's own disk);
486
+ * throw clearly if absent. This backs the session CONTROL ROOT (persisted
487
+ * metadata, borrowed bookkeeping, discovery) which always lives on THIS host,
488
+ * never a remote workspace. Workspace file ops use {@link workspaceFilesystem}.
489
+ */
490
+ async filesystem() {
491
+ // Code-session workspaces and their control metadata are host-managed and
492
+ // may intentionally live outside the currently bound user workspace.
493
+ const adapter = await resolveFileSystemAdapter(this.runtime, {
494
+ explicitId: CONSUMER_FILESYSTEM_ADAPTER_ID
495
+ });
496
+ if (!adapter) {
497
+ throw new CapabilityError('No workspace filesystem capability is registered for this runtime scope.', 'UNAVAILABLE', {
498
+ reason: 'code-session-host-unavailable',
499
+ retryable: false
500
+ });
501
+ }
502
+ return adapter;
503
+ }
504
+ /**
505
+ * Resolve the filesystem that backs a session's WORKSPACE. A `{ kind:'host',
506
+ * hostId }` target with a NON-local hostId reaches THAT host's workspace over
507
+ * the fleet wire (UNIT-B3, mirroring {@link execution}); local + default + cloud
508
+ * targets keep resolving the in-process adapter. There is NO silent local
509
+ * fallback: an unreachable host throws a precise, actionable error.
510
+ */
511
+ workspaceFilesystem(hostTarget = DEFAULT_SESSION_HOST_TARGET) {
512
+ if (hostTarget.kind === 'host' && hostTarget.hostId !== 'local') {
513
+ return this.remoteFilesystem(hostTarget.hostId);
514
+ }
515
+ return this.filesystem();
516
+ }
517
+ /**
518
+ * Resolve a session's metadata, attempting attach-on-demand exactly once if
519
+ * the id is unknown IN THIS PROCESS: a session initialized by a prior process
520
+ * on this host persisted its metadata under the profile-owned session root (see
521
+ * `attach()`), so a cold process can still serve it. Concurrent calls for the
522
+ * same unknown id collapse onto a single attach attempt via `attachInFlight`.
523
+ */
524
+ /**
525
+ * The same trust gate `initialize()` enforces, applied to every path that
526
+ * can hand out a workspace (attach / attach-on-demand). Without it, a
527
+ * below-threshold descriptor that could never CREATE a session could still
528
+ * run commands in one persisted by a higher-trust composition.
529
+ */
530
+ requireTrust() {
531
+ if (!trustAllowsCodeSession(this.descriptor.trustLevel)) {
532
+ throw new CapabilityError('The code-session trust level cannot edit a writable workspace.', 'PERMISSION_DENIED', {
533
+ reason: 'code-session-trust-denied',
534
+ retryable: false,
535
+ details: { trustLevel: this.descriptor.trustLevel, requiredTrustLevel: CODE_SESSION_MIN_TRUST_LEVEL }
536
+ });
537
+ }
538
+ }
539
+ async ensureSession(sessionId) {
540
+ this.requireTrust();
541
+ requireSafeSessionId(sessionId);
542
+ const known = this.sessions.get(sessionId);
543
+ if (known)
544
+ return this.requireReadySession(known);
545
+ let pending = this.attachInFlight.get(sessionId);
546
+ if (!pending) {
547
+ pending = this.loadPersistedSessionMeta(sessionId).finally(() => {
548
+ this.attachInFlight.delete(sessionId);
549
+ });
550
+ this.attachInFlight.set(sessionId, pending);
551
+ }
552
+ const meta = await pending;
553
+ if (!meta) {
554
+ throw new CapabilityError('The requested code session was not found.', 'NOT_FOUND', {
555
+ reason: 'code-session-workspace-not-found',
556
+ retryable: false,
557
+ details: { sessionId }
558
+ });
559
+ }
560
+ return this.requireReadySession(meta);
561
+ }
562
+ async requireReadySession(meta) {
563
+ if (meta.hostTarget.kind === 'host' && meta.hostTarget.hostId !== 'local') {
564
+ const fs = await this.workspaceFilesystem(meta.hostTarget);
565
+ const root = await this.targetSessionRoot(fs, meta.hostTarget);
566
+ if (meta.workspaceOwnership === 'owned' && meta.workspacePath !== ownedWorkspacePath(fs, root, meta.sessionId)) {
567
+ throw new CapabilityError('Remote session belongs to a different profile.', 'CONFLICT', {
568
+ reason: 'code-session-profile-conflict', retryable: false, details: { sessionId: meta.sessionId }
569
+ });
570
+ }
571
+ if (meta.workspaceOwnership === 'owned') {
572
+ const contents = await fs.readFile(this.metadataPath(fs, meta.workspacePath), 'utf8');
573
+ const persisted = parseCodeSessionMetadata(utf8Text(contents));
574
+ if (!persisted || persisted.sessionId !== meta.sessionId || persisted.workspacePath !== meta.workspacePath ||
575
+ persisted.operationId !== meta.operationId || persisted.operationFingerprint !== meta.operationFingerprint ||
576
+ persisted.hostTarget.kind !== 'host' || persisted.hostTarget.hostId !== meta.hostTarget.hostId) {
577
+ throw new CapabilityError('Remote session metadata changed; explicit recovery is required.', 'CONFLICT', {
578
+ reason: 'code-session-profile-conflict', retryable: false, details: { sessionId: meta.sessionId }
579
+ });
580
+ }
581
+ }
582
+ }
583
+ if (!meta.operationId)
584
+ return meta;
585
+ const fs = await this.filesystem();
586
+ const key = await codeSessionOperationKey(meta.operationId);
587
+ const record = await this.readOperationRecord(fs, key);
588
+ if (!record ||
589
+ record.stage !== 'ready' ||
590
+ record.sessionId !== meta.sessionId ||
591
+ record.fingerprint !== meta.operationFingerprint) {
592
+ throw new CapabilityError('The code-session operation is not ready.', 'CONFLICT', {
593
+ reason: 'code-session-operation-conflict', retryable: false, details: { sessionId: meta.sessionId }
594
+ });
595
+ }
596
+ return meta;
597
+ }
598
+ metadataPath(fs, workspacePath) {
599
+ return fs.join(workspacePath, CODE_SESSION_METADATA_FILENAME);
600
+ }
601
+ borrowedMetadataPath(fs, sessionId) {
602
+ return fs.join(this.sessionRoot(fs), '.metadata', `${requireSafeSessionId(sessionId)}.json`);
603
+ }
604
+ operationRoot(fs) {
605
+ return fs.join(this.sessionRoot(fs), '.operations');
606
+ }
607
+ operationDirectory(fs, key) {
608
+ return fs.join(this.operationRoot(fs), key);
609
+ }
610
+ operationRecordPath(fs, key) {
611
+ return fs.join(this.operationDirectory(fs, key), 'operation.json');
612
+ }
613
+ operationLeaseDirectory(fs, key) {
614
+ return fs.join(this.operationDirectory(fs, key), 'lease');
615
+ }
616
+ async readOperationLease(fs, key) {
617
+ const value = await this.readControlMetadata(fs, fs.join(this.operationLeaseDirectory(fs, key), 'lease.json'));
618
+ if (!value)
619
+ return null;
620
+ try {
621
+ return CodeSessionOperationLeaseSchema.parse(JSON.parse(utf8Text(value)));
622
+ }
623
+ catch (caught) {
624
+ throw metadataInvalid(valueFreeFailure(CaughtFailureSchema.parse(caught)));
625
+ }
626
+ }
627
+ async withOperationLease(operation, run, exclusive = false) {
628
+ const leaseDirectory = this.operationLeaseDirectory(operation.fs, operation.key);
629
+ const leasePath = operation.fs.join(leaseDirectory, 'lease.json');
630
+ const holderId = crypto.randomUUID();
631
+ const leaseDurationMs = 15_000;
632
+ // Another process holds the lease; its only signal is the lease directory
633
+ // going away, so this polls on the runtime's lifecycle (operation-lease.ts).
634
+ const acquired = await pollUntil(this.runtime.lifecycle, async () => {
635
+ try {
636
+ await operation.fs.mkdir(leaseDirectory);
637
+ return 'acquired';
638
+ }
639
+ catch (caught) {
640
+ const parsed = CaughtFailureSchema.parse(caught);
641
+ const failure = valueFreeFailure(parsed);
642
+ if (failure.code !== 'EEXIST')
643
+ throw CapabilityError.from(parsed);
644
+ const latest = await this.readOperationRecord(operation.fs, operation.key);
645
+ if (!exclusive && (latest?.stage === 'ready' ||
646
+ latest?.stage === 'attention-required' ||
647
+ latest?.stage === 'torn-down')) {
648
+ return latest;
649
+ }
650
+ const lease = await this.readOperationLease(operation.fs, operation.key);
651
+ if (lease && lease.expiresAt <= Date.now()) {
652
+ throw new CapabilityError('The code-session operation lease expired before reconciliation.', 'CONFLICT', {
653
+ reason: 'code-session-operation-conflict',
654
+ retryable: false,
655
+ details: { operationId: operation.record.operationId }
656
+ });
657
+ }
658
+ return null;
659
+ }
660
+ }, { intervalMs: 50, timeoutMs: 10 * 60_000, reason: 'code-session-operation-lease' });
661
+ if (acquired !== 'acquired') {
662
+ operation.record = acquired;
663
+ return run();
664
+ }
665
+ const renew = async () => {
666
+ await operation.fs.writeFile(leasePath, JSON.stringify({ holderId, expiresAt: Date.now() + leaseDurationMs }), 'utf8');
667
+ };
668
+ await renew();
669
+ try {
670
+ return await withLeaseHeartbeat(this.runtime.lifecycle, { periodMs: leaseDurationMs / 3, operationId: operation.record.operationId, renew, logger: this.log }, async () => {
671
+ const latest = await this.readOperationRecord(operation.fs, operation.key);
672
+ if (latest)
673
+ operation.record = latest;
674
+ return run();
675
+ });
676
+ }
677
+ finally {
678
+ const current = await this.readOperationLease(operation.fs, operation.key);
679
+ if (current?.holderId === holderId) {
680
+ await operation.fs.remove(leaseDirectory, { recursive: true });
681
+ }
682
+ }
683
+ }
684
+ async readOperationRecord(fs, key) {
685
+ const value = await this.readControlMetadata(fs, this.operationRecordPath(fs, key));
686
+ if (value === null)
687
+ return null;
688
+ try {
689
+ return PersistedCodeSessionOperationSchema.parse(JSON.parse(utf8Text(value)));
690
+ }
691
+ catch (caught) {
692
+ const failure = valueFreeFailure(CaughtFailureSchema.parse(caught));
693
+ this.log.warn('operation record is malformed', { failure });
694
+ throw metadataInvalid(failure);
695
+ }
696
+ }
697
+ async writeOperationRecord(operation, patch) {
698
+ const updated = { ...operation.record, ...patch, updatedAt: this.nowIso() };
699
+ const fs = operation.fs;
700
+ const path = this.operationRecordPath(fs, operation.key);
701
+ const temporary = fs.join(fs.dirname(path), `.operation-${crypto.randomUUID()}.json`);
702
+ if (!await createFileExclusiveChecked(fs, temporary, JSON.stringify(updated, null, 2), 'utf8', {
703
+ message: 'Operation publication requires exclusive file creation'
704
+ })) {
705
+ throw new CapabilityError('Operation publication path is already occupied', 'CONFLICT', { retryable: false });
706
+ }
707
+ // Same-directory rename publishes complete bytes; failure preserves the old record and temporary evidence.
708
+ await fs.rename(temporary, path);
709
+ operation.record = updated;
710
+ }
711
+ async prepareOperation(input) {
712
+ const operationId = input.operationId?.trim();
713
+ if (!operationId)
714
+ return null;
715
+ if (input.sessionId) {
716
+ throw requestInvalid('sessionId');
717
+ }
718
+ const [key, fingerprint, fs] = await Promise.all([
719
+ codeSessionOperationKey(operationId),
720
+ codeSessionOperationFingerprint(input, { authorityId: this.descriptor.id }),
721
+ this.filesystem()
722
+ ]);
723
+ const operationDirectory = this.operationDirectory(fs, key);
724
+ await fs.mkdir(operationDirectory, { recursive: true });
725
+ let record = await this.readOperationRecord(fs, key);
726
+ if (record) {
727
+ if (record.operationId !== operationId || record.fingerprint !== fingerprint) {
728
+ throw new CapabilityError('The code-session operation was claimed with a different configuration.', 'CONFLICT', {
729
+ reason: 'code-session-operation-conflict', retryable: false, details: { operationId }
730
+ });
731
+ }
732
+ return { key, record, fs };
733
+ }
734
+ const claimDirectory = fs.join(operationDirectory, 'claim');
735
+ let ownsClaim = false;
736
+ try {
737
+ await fs.mkdir(claimDirectory);
738
+ ownsClaim = true;
739
+ }
740
+ catch (caught) {
741
+ const parsed = CaughtFailureSchema.parse(caught);
742
+ const failure = valueFreeFailure(parsed);
743
+ if (failure.code !== 'EEXIST')
744
+ throw CapabilityError.from(parsed);
745
+ }
746
+ if (!ownsClaim) {
747
+ // The claimant is another process; its only signal is the record file.
748
+ const claimed = await pollUntil(this.runtime.lifecycle, () => this.readOperationRecord(fs, key), {
749
+ intervalMs: 25,
750
+ timeoutMs: 10_000,
751
+ reason: 'code-session-operation-claim-record'
752
+ });
753
+ if (claimed.operationId !== operationId || claimed.fingerprint !== fingerprint) {
754
+ throw new CapabilityError('The code-session operation was claimed with a different configuration.', 'CONFLICT', {
755
+ reason: 'code-session-operation-conflict', retryable: false, details: { operationId }
756
+ });
757
+ }
758
+ return { key, record: claimed, fs };
759
+ }
760
+ // Re-read after taking the exclusive claim: a completed claimant may have
761
+ // released immediately before our mkdir won.
762
+ record = await this.readOperationRecord(fs, key);
763
+ if (record) {
764
+ await fs.remove(claimDirectory, { recursive: true });
765
+ if (record.operationId !== operationId || record.fingerprint !== fingerprint) {
766
+ throw new CapabilityError('The code-session operation was claimed with a different configuration.', 'CONFLICT', {
767
+ reason: 'code-session-operation-conflict', retryable: false, details: { operationId }
768
+ });
769
+ }
770
+ return { key, record, fs };
771
+ }
772
+ const now = this.nowIso();
773
+ record = {
774
+ operationId,
775
+ fingerprint,
776
+ sessionId: `op-${key.slice(0, 32)}`,
777
+ stage: 'reserved',
778
+ generation: 1,
779
+ createdAt: now,
780
+ updatedAt: now
781
+ };
782
+ const fingerprintMarker = fs.join(operationDirectory, 'fingerprints', fingerprint.slice('sha256:'.length));
783
+ await fs.mkdir(fingerprintMarker, { recursive: true });
784
+ await this.writeOperationRecord({ fs, key, record }, {});
785
+ const durable = await this.readOperationRecord(fs, key);
786
+ if (!durable || durable.fingerprint !== fingerprint) {
787
+ throw new CapabilityError('The code-session operation claim was not durably recorded.', 'CONFLICT', {
788
+ reason: 'code-session-operation-conflict', retryable: false, details: { operationId }
789
+ });
790
+ }
791
+ await fs.remove(claimDirectory, { recursive: true });
792
+ return { key, record, fs };
793
+ }
794
+ async persistSessionMetadata(meta, ownedStoragePath) {
795
+ // Borrowed bookkeeping lives on the LOCAL control root (never inside the
796
+ // user's repo); an OWNED workspace carries its own metadata file, which for a
797
+ // remote host lives on the REMOTE workspace filesystem.
798
+ const fs = meta.workspaceOwnership === 'borrowed'
799
+ ? await this.filesystem()
800
+ : await this.workspaceFilesystem(meta.hostTarget);
801
+ const metadataPath = meta.workspaceOwnership === 'borrowed'
802
+ ? this.borrowedMetadataPath(fs, meta.sessionId)
803
+ : this.metadataPath(fs, ownedStoragePath ?? meta.workspacePath);
804
+ if (meta.workspaceOwnership === 'borrowed') {
805
+ await fs.mkdir(fs.dirname(metadataPath), { recursive: true });
806
+ }
807
+ await fs.writeFile(metadataPath, serializeCodeSessionMetadata({
808
+ sessionId: meta.sessionId,
809
+ operationId: meta.operationId,
810
+ operationFingerprint: meta.operationFingerprint,
811
+ workspacePath: meta.workspacePath,
812
+ workspaceOwnership: meta.workspaceOwnership,
813
+ createdAt: meta.createdAt,
814
+ hostTarget: meta.hostTarget,
815
+ repositoryId: meta.repositoryId,
816
+ cloneUrl: meta.cloneUrl,
817
+ ephemeral: meta.ephemeral,
818
+ checkoutRef: meta.checkoutRef,
819
+ exposedPorts: meta.exposedPorts,
820
+ previewUrl: meta.previewUrl,
821
+ pausedAt: meta.pausedAt
822
+ }), 'utf8');
823
+ }
824
+ /** Local discovery contains identity only; the remote workspace owns full metadata. */
825
+ async persistRemotePointer(meta) {
826
+ const fs = await this.filesystem();
827
+ const path = this.borrowedMetadataPath(fs, meta.sessionId);
828
+ await fs.mkdir(fs.dirname(path), { recursive: true });
829
+ await fs.writeFile(path, JSON.stringify({
830
+ sessionId: meta.sessionId, operationId: meta.operationId,
831
+ operationFingerprint: meta.operationFingerprint, hostTarget: meta.hostTarget,
832
+ workspacePath: meta.workspacePath, workspaceOwnership: 'owned'
833
+ }), 'utf8');
834
+ }
835
+ cleanupPath(fs, root, key, cleanup) {
836
+ const parent = fs.join(root, '.cleanup', key);
837
+ const relative = fs.relative(parent, cleanup.path);
838
+ if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\/workspace$/.test(relative) ||
839
+ !['intent', 'quarantined', 'removed'].includes(cleanup.phase) || fs.normalize(cleanup.path) !== fs.join(parent, relative)) {
840
+ throw new CapabilityError('Remote cleanup authorization does not match the current profile', 'CONFLICT', { retryable: false });
841
+ }
842
+ return cleanup.path;
843
+ }
844
+ async readControlMetadata(fs, path) {
845
+ try {
846
+ return await fs.readFile(path, 'utf8');
847
+ }
848
+ catch (caught) {
849
+ const failure = valueFreeFailure(CaughtFailureSchema.parse(caught));
850
+ if (failure.code === 'ENOENT')
851
+ return null;
852
+ throw new CapabilityError('Code-session control metadata could not be read.', 'UNAVAILABLE', {
853
+ reason: 'code-session-host-unavailable', retryable: false, cause: failure
854
+ });
855
+ }
856
+ }
857
+ async loadPersistedSessionMeta(sessionId, forCleanup = false, allowLegacy = true) {
858
+ const fs = await this.filesystem();
859
+ requireSafeSessionId(sessionId);
860
+ const persistedOwnedWorkspacePath = ownedWorkspacePath(fs, this.sessionRoot(fs), sessionId);
861
+ const borrowedPath = this.borrowedMetadataPath(fs, sessionId);
862
+ const ownedPath = this.metadataPath(fs, persistedOwnedWorkspacePath);
863
+ const contents = (await this.readControlMetadata(fs, borrowedPath)) ??
864
+ (await this.readControlMetadata(fs, ownedPath));
865
+ const text = contents === null ? null : utf8Text(contents);
866
+ let parsed = parseCodeSessionMetadata(text);
867
+ if (!parsed) {
868
+ if (text !== null)
869
+ throw metadataInvalid();
870
+ return allowLegacy ? this.reattachLegacyLocalSession(fs, sessionId) : null;
871
+ }
872
+ if (parsed.sessionId !== sessionId) {
873
+ throw new CapabilityError('Persisted session identity does not match the requested session.', 'CONFLICT', {
874
+ reason: 'code-session-profile-conflict', retryable: false, details: { sessionId }
875
+ });
876
+ }
877
+ const workspaceOwnership = parsed.workspaceOwnership ?? 'owned';
878
+ let cleanupPending = false;
879
+ let cleanupRecord = null;
880
+ if (forCleanup && parsed.operationId) {
881
+ const record = await this.readOperationRecord(fs, await codeSessionOperationKey(parsed.operationId));
882
+ cleanupRecord = record;
883
+ cleanupPending = !!record && (record.stage === 'cleanup-pending' || record.stage === 'torn-down') &&
884
+ record.sessionId === sessionId && record.fingerprint === parsed.operationFingerprint;
885
+ }
886
+ const targetFs = await this.workspaceFilesystem(parsed.hostTarget);
887
+ const isRemote = parsed.hostTarget.kind === 'host' && parsed.hostTarget.hostId !== 'local';
888
+ const workspacePath = workspaceOwnership === 'borrowed' ? parsed.workspacePath
889
+ : ownedWorkspacePath(targetFs, await this.targetSessionRoot(targetFs, parsed.hostTarget), sessionId);
890
+ if (!workspacePath)
891
+ return null;
892
+ if (isRemote && workspaceOwnership === 'owned') {
893
+ if (!parsed.operationId || !parsed.operationFingerprint || parsed.workspacePath !== workspacePath) {
894
+ throw new CapabilityError('Remote session roots or identity changed; explicit recovery is required.', 'CONFLICT', {
895
+ reason: 'code-session-profile-conflict', retryable: false, details: { sessionId }
896
+ });
897
+ }
898
+ let cleanupAuthorized = false;
899
+ if (cleanupPending && cleanupRecord?.cleanup) {
900
+ const root = await this.targetSessionRoot(targetFs, parsed.hostTarget);
901
+ const path = this.cleanupPath(targetFs, root, await codeSessionOperationKey(parsed.operationId), cleanupRecord.cleanup);
902
+ if (cleanupRecord.cleanup.phase === 'intent') {
903
+ const quarantined = await targetFs.exists(path);
904
+ if (quarantined && await targetFs.exists(workspacePath)) {
905
+ throw new CapabilityError('Remote cleanup rename is ambiguous; explicit recovery is required.', 'CONFLICT', {
906
+ reason: 'code-session-cleanup-conflict', retryable: false, details: { sessionId }
907
+ });
908
+ }
909
+ cleanupAuthorized = quarantined;
910
+ }
911
+ else
912
+ cleanupAuthorized = true;
913
+ }
914
+ if (!cleanupAuthorized && (!cleanupPending || await targetFs.exists(workspacePath))) {
915
+ const remoteContents = await targetFs.readFile(this.metadataPath(targetFs, workspacePath), 'utf8');
916
+ const remoteMetadata = parseCodeSessionMetadata(utf8Text(remoteContents));
917
+ if (!remoteMetadata || remoteMetadata.sessionId !== sessionId ||
918
+ remoteMetadata.operationId !== parsed.operationId || remoteMetadata.operationFingerprint !== parsed.operationFingerprint ||
919
+ remoteMetadata.workspacePath !== workspacePath || remoteMetadata.workspaceOwnership !== 'owned' ||
920
+ remoteMetadata.hostTarget.kind !== 'host' || parsed.hostTarget.kind !== 'host' || remoteMetadata.hostTarget.hostId !== parsed.hostTarget.hostId) {
921
+ throw new CapabilityError('Remote session publication does not match its control record.', 'CONFLICT', {
922
+ reason: 'code-session-profile-conflict', retryable: false, details: { sessionId }
923
+ });
924
+ }
925
+ parsed = remoteMetadata;
926
+ }
927
+ }
928
+ if (!cleanupPending && !(await targetFs.exists(workspacePath)))
929
+ return null;
930
+ const meta = {
931
+ sessionId: parsed.sessionId,
932
+ operationId: parsed.operationId,
933
+ operationFingerprint: parsed.operationFingerprint,
934
+ workspacePath,
935
+ workspaceOwnership,
936
+ createdAt: parsed.createdAt ?? this.nowIso(),
937
+ hostTarget: parsed.hostTarget,
938
+ repositoryId: parsed.repositoryId,
939
+ cloneUrl: parsed.cloneUrl,
940
+ ephemeral: parsed.ephemeral,
941
+ checkoutRef: parsed.checkoutRef,
942
+ exposedPorts: parsed.exposedPorts ?? [],
943
+ previewUrl: parsed.previewUrl,
944
+ pausedAt: parsed.pausedAt
945
+ };
946
+ if (isRemote && workspaceOwnership === 'owned' && !cleanupPending)
947
+ await this.requireReadySession(meta);
948
+ this.sessions.set(sessionId, meta);
949
+ this.background.set(sessionId, this.background.get(sessionId) ?? []);
950
+ return meta;
951
+ }
952
+ /**
953
+ * Reattach exactly one requested pre-profile local session.
954
+ *
955
+ * This intentionally never participates in discover(): profile isolation
956
+ * remains the default and no new profile absorbs the process-global legacy
957
+ * roster. A successful match is copied into this profile as BORROWED, so
958
+ * teardown cannot delete legacy workspace bytes that may still be referenced
959
+ * by an older process.
960
+ */
961
+ async reattachLegacyLocalSession(fs, sessionId) {
962
+ const legacyRoot = fs.normalize(fs.resolve(LEGACY_LOCAL_SESSION_ROOT));
963
+ if (legacyRoot === this.sessionRoot(fs))
964
+ return null;
965
+ const legacyWorkspacePath = ownedWorkspacePath(fs, legacyRoot, sessionId);
966
+ const legacyBorrowedPath = fs.join(legacyRoot, '.metadata', `${sessionId}.json`);
967
+ const legacyOwnedPath = this.metadataPath(fs, legacyWorkspacePath);
968
+ const contents = (await this.readControlMetadata(fs, legacyBorrowedPath)) ??
969
+ (await this.readControlMetadata(fs, legacyOwnedPath));
970
+ const text = contents === null ? null : utf8Text(contents);
971
+ const parsed = parseCodeSessionMetadata(text);
972
+ if (!parsed) {
973
+ if (text !== null)
974
+ throw metadataInvalid();
975
+ return null;
976
+ }
977
+ if (parsed.sessionId !== sessionId) {
978
+ throw new CapabilityError('Legacy session identity does not match the requested session.', 'CONFLICT', {
979
+ reason: 'code-session-profile-conflict', retryable: false, details: { sessionId }
980
+ });
981
+ }
982
+ if (parsed.hostTarget.kind !== 'host' || parsed.hostTarget.hostId !== 'local') {
983
+ return null;
984
+ }
985
+ const legacyOwnership = parsed.workspaceOwnership ?? 'owned';
986
+ const workspacePath = legacyOwnership === 'borrowed' ? parsed.workspacePath : legacyWorkspacePath;
987
+ if (!workspacePath || !(await fs.exists(workspacePath)))
988
+ return null;
989
+ const meta = {
990
+ sessionId,
991
+ operationId: parsed.operationId,
992
+ operationFingerprint: parsed.operationFingerprint,
993
+ workspacePath,
994
+ workspaceOwnership: 'borrowed',
995
+ createdAt: parsed.createdAt ?? this.nowIso(),
996
+ hostTarget: parsed.hostTarget,
997
+ repositoryId: parsed.repositoryId,
998
+ cloneUrl: parsed.cloneUrl,
999
+ ephemeral: parsed.ephemeral,
1000
+ checkoutRef: parsed.checkoutRef,
1001
+ exposedPorts: parsed.exposedPorts ?? [],
1002
+ previewUrl: parsed.previewUrl,
1003
+ pausedAt: parsed.pausedAt
1004
+ };
1005
+ await this.persistSessionMetadata(meta);
1006
+ this.sessions.set(sessionId, meta);
1007
+ this.background.set(sessionId, this.background.get(sessionId) ?? []);
1008
+ return meta;
1009
+ }
1010
+ /**
1011
+ * Run a one-shot command, capturing output. Used internally for git ops.
1012
+ *
1013
+ * Uses `runtime/execution`'s `spawn()` mode + self-drain rather than
1014
+ * `execute()` on purpose: this method owns a distinct timeout contract
1015
+ * (SIGTERM kill, forced `exitCode:null`, and an explicit
1016
+ * `[code-session] aborted after …` stderr suffix) that `execute()`'s
1017
+ * SIGKILL/137 + no-suffix semantics would change. Keeping spawn()+drain
1018
+ * preserves the exact consumer-observable behavior the characterization
1019
+ * tests pin.
1020
+ */
1021
+ async exec(workspacePath, command, opts = {}, hostTarget = DEFAULT_SESSION_HOST_TARGET) {
1022
+ const started = this.now();
1023
+ if (opts.signal?.aborted) {
1024
+ return {
1025
+ exitCode: null,
1026
+ stdout: '',
1027
+ stderr: '[code-session] stopped before spawn',
1028
+ timedOut: false,
1029
+ durationMs: this.now() - started,
1030
+ aborted: true
1031
+ };
1032
+ }
1033
+ let timedOut = false;
1034
+ const timeoutMs = Math.min(opts.timeoutMs ?? this.descriptor.timeoutMs, this.descriptor.timeoutMs);
1035
+ const [bin, ...args] = command;
1036
+ // One cancellation and one total deadline own the operation from here,
1037
+ // before any host work: before dispatch they stop it; during a remote
1038
+ // setup they cancel its acquisition; afterwards they kill the handle.
1039
+ let handle = null;
1040
+ let acquisition;
1041
+ let aborted = false;
1042
+ const cancel = (signal) => {
1043
+ if (handle)
1044
+ requestKill(signal);
1045
+ else
1046
+ acquisition?.cancel();
1047
+ };
1048
+ const disarmDeadline = this.runtime.lifecycle.timeout(() => {
1049
+ timedOut = true;
1050
+ cancel('SIGKILL');
1051
+ }, timeoutMs);
1052
+ // User abort (composer Stop): same cancellation as the timeout path,
1053
+ // distinct flag so callers can tell "stopped" from "timed out".
1054
+ const onAbort = () => {
1055
+ if (aborted)
1056
+ return;
1057
+ aborted = true;
1058
+ cancel(opts.abortKillSignal ?? 'SIGKILL');
1059
+ };
1060
+ opts.signal?.addEventListener('abort', onAbort, { once: true });
1061
+ let kill;
1062
+ let killSignal;
1063
+ let resolveKillFailure;
1064
+ const killFailure = new Promise((resolve) => { resolveKillFailure = resolve; });
1065
+ const requestKill = (signal) => {
1066
+ if (!handle || killSignal === 'SIGKILL' || killSignal === signal)
1067
+ return;
1068
+ const owned = handle;
1069
+ killSignal = signal;
1070
+ // A hard deadline can escalate an earlier graceful stop without waiting for its acknowledgement.
1071
+ const request = Promise.resolve().then(() => owned.kill(signal)).then(() => undefined, (caught) => {
1072
+ const failure = valueFreeFailure(CaughtFailureSchema.parse(caught));
1073
+ resolveKillFailure(new CapabilityError('Code-session process cancellation failed; cleanup is unconfirmed.', 'UNAVAILABLE', {
1074
+ reason: 'code-session-cleanup-unconfirmed', retryable: false, cause: failure
1075
+ }));
1076
+ return failure;
1077
+ });
1078
+ kill = kill ? Promise.all([kill, request]).then(([previous, current]) => previous ?? current) : request;
1079
+ };
1080
+ try {
1081
+ try {
1082
+ handle = await this.spawnOnHost(workspacePath, hostTarget, {
1083
+ command: bin,
1084
+ args,
1085
+ cwd: opts.cwd ?? workspacePath,
1086
+ stdout: 'pipe',
1087
+ stderr: 'pipe',
1088
+ // Timeout and abort kill the command's whole tree, not only its direct process.
1089
+ killTree: true
1090
+ }, opts.env, {
1091
+ cancelled: () => timedOut || aborted,
1092
+ dispatched: (remote) => { acquisition = remote; }
1093
+ });
1094
+ }
1095
+ catch (caught) {
1096
+ // A cancelled acquisition keeps observing its late port; the caller
1097
+ // learns only that cleanup is not yet confirmed.
1098
+ if (acquisition && (timedOut || aborted)) {
1099
+ throw new CapabilityError('Code-session process setup was cancelled; cleanup is unconfirmed.', 'UNAVAILABLE', {
1100
+ reason: 'code-session-cleanup-unconfirmed',
1101
+ retryable: false,
1102
+ cause: valueFreeFailure(CaughtFailureSchema.parse(caught))
1103
+ });
1104
+ }
1105
+ throw CapabilityError.from(CaughtFailureSchema.parse(caught));
1106
+ }
1107
+ if (!handle) {
1108
+ // Cancelled before dispatch: no process was requested.
1109
+ const cancelled = {
1110
+ exitCode: null,
1111
+ stdout: '',
1112
+ stderr: timedOut ? `[code-session] aborted after ${timeoutMs}ms` : '[code-session] stopped before spawn',
1113
+ timedOut,
1114
+ durationMs: this.now() - started
1115
+ };
1116
+ if (aborted && !timedOut)
1117
+ cancelled.aborted = true;
1118
+ return cancelled;
1119
+ }
1120
+ // Cancellation during a local spawn (which always settles) kills now.
1121
+ if (timedOut || aborted)
1122
+ requestKill(timedOut ? 'SIGKILL' : (opts.abortKillSignal ?? 'SIGKILL'));
1123
+ const outcome = await Promise.race([
1124
+ Promise.all([readStream(handle.stdout), readStream(handle.stderr), handle.exited]).then((value) => ({ kind: 'complete', value })),
1125
+ killFailure.then((error) => ({ kind: 'kill-failed', error }))
1126
+ ]);
1127
+ if (outcome.kind === 'kill-failed')
1128
+ throw outcome.error;
1129
+ const [stdout, stderr, exit] = outcome.value;
1130
+ if (kill)
1131
+ await kill;
1132
+ const result = {
1133
+ exitCode: timedOut || aborted ? null : exit.code,
1134
+ stdout,
1135
+ stderr: timedOut || aborted
1136
+ ? `${stderr}\n${timedOut ? `[code-session] aborted after ${timeoutMs}ms` : '[code-session] stopped by user'}`
1137
+ : stderr,
1138
+ timedOut,
1139
+ durationMs: this.now() - started
1140
+ };
1141
+ if (aborted)
1142
+ result.aborted = true;
1143
+ return result;
1144
+ }
1145
+ finally {
1146
+ disarmDeadline();
1147
+ opts.signal?.removeEventListener('abort', onAbort);
1148
+ if (kill)
1149
+ await kill;
1150
+ }
1151
+ }
1152
+ async initialize(input) {
1153
+ input = parseCodeSessionInitializeInput(input);
1154
+ if (input.sessionId) {
1155
+ if (input.operationId) {
1156
+ throw requestInvalid('operationId');
1157
+ }
1158
+ if (input.workspacePath) {
1159
+ this.requireTrust();
1160
+ const sessionId = requireSafeSessionId(input.sessionId);
1161
+ const hostTarget = input.hostTarget ?? this.descriptor.hostTarget ?? DEFAULT_SESSION_HOST_TARGET;
1162
+ if (hostTarget.kind !== 'host') {
1163
+ throw requestInvalid('hostTarget');
1164
+ }
1165
+ if (input.cloneUrl || input.repositoryId || input.ephemeral) {
1166
+ throw requestInvalid('workspacePath');
1167
+ }
1168
+ const fs = await this.workspaceFilesystem(hostTarget);
1169
+ const workspacePath = fs.resolve(input.workspacePath);
1170
+ if (!(await fs.exists(workspacePath)) || (await fs.stat(workspacePath)).kind !== 'directory') {
1171
+ throw new CapabilityError('The requested workspace does not exist.', 'NOT_FOUND', {
1172
+ reason: 'code-session-workspace-not-found', retryable: false, details: { sessionId }
1173
+ });
1174
+ }
1175
+ const meta = {
1176
+ sessionId,
1177
+ workspacePath,
1178
+ workspaceOwnership: 'borrowed',
1179
+ createdAt: this.nowIso(),
1180
+ hostTarget,
1181
+ checkoutRef: input.checkoutRef,
1182
+ exposedPorts: input.exposedPorts ?? [...this.descriptor.exposedPorts]
1183
+ };
1184
+ await this.persistSessionMetadata(meta);
1185
+ this.sessions.set(sessionId, meta);
1186
+ this.background.set(sessionId, []);
1187
+ await notifyAllocated(input, sessionId, this.log);
1188
+ return this.toHandle(meta);
1189
+ }
1190
+ return this.attach(input.sessionId);
1191
+ }
1192
+ return this.createOrGet({
1193
+ ...input,
1194
+ operationId: input.operationId?.trim() || `initialize:${crypto.randomUUID()}`
1195
+ });
1196
+ }
1197
+ async createOrGet(input) {
1198
+ input = parseCodeSessionCreateOrGetInput(input);
1199
+ this.requireTrust();
1200
+ input = {
1201
+ ...input,
1202
+ hostTarget: input.hostTarget ?? this.descriptor.hostTarget ?? DEFAULT_SESSION_HOST_TARGET,
1203
+ runtimeConfig: { ...this.descriptor.runtimeConfig, ...input.runtimeConfig },
1204
+ exposedPorts: input.exposedPorts ?? [...this.descriptor.exposedPorts]
1205
+ };
1206
+ const operationId = input.operationId;
1207
+ const admittedOperation = CodeSessionOperationIdSchema.safeParse(operationId);
1208
+ if (!admittedOperation.success)
1209
+ throw requestInvalid('operationId');
1210
+ const fingerprint = codeSessionOperationFingerprint(input, { authorityId: this.descriptor.id });
1211
+ // Claim before any await: the key's own refusals (empty, too long) still
1212
+ // surface through codeSessionOperationKey below, on the claimant's path.
1213
+ const claimKey = admittedOperation.data;
1214
+ const running = this.operationInFlight.get(claimKey);
1215
+ if (running) {
1216
+ // A later caller is compared against the first claimant, never the reverse.
1217
+ const [mine, theirs] = await Promise.all([fingerprint, running.fingerprint]);
1218
+ if (mine !== theirs) {
1219
+ throw new CapabilityError('The code-session operation was claimed with a different configuration.', 'CONFLICT', {
1220
+ reason: 'code-session-operation-conflict', retryable: false, details: { operationId: claimKey }
1221
+ });
1222
+ }
1223
+ return running.promise;
1224
+ }
1225
+ const pending = Promise.all([codeSessionOperationKey(operationId), fingerprint])
1226
+ .then(() => this.createOrGetOnce(input))
1227
+ .finally(() => {
1228
+ if (this.operationInFlight.get(claimKey)?.promise === pending) {
1229
+ this.operationInFlight.delete(claimKey);
1230
+ }
1231
+ });
1232
+ this.operationInFlight.set(claimKey, { fingerprint, promise: pending });
1233
+ return pending;
1234
+ }
1235
+ async createOrGetOnce(input) {
1236
+ const operation = await this.prepareOperation(input);
1237
+ if (!operation)
1238
+ throw requestInvalid('operationId');
1239
+ return this.withOperationLease(operation, async () => {
1240
+ const { record } = operation;
1241
+ if (record.stage === 'cleanup-pending' || record.stage === 'torn-down') {
1242
+ throw new CapabilityError('The code-session operation was already torn down.', 'CONFLICT', {
1243
+ reason: 'code-session-operation-conflict', retryable: false, details: { operationId: record.operationId }
1244
+ });
1245
+ }
1246
+ if (record.stage === 'attention-required') {
1247
+ throw new CapabilityError('The code-session operation requires explicit reconciliation.', 'CONFLICT', {
1248
+ reason: 'code-session-operation-conflict', retryable: false, details: { operationId: record.operationId }
1249
+ });
1250
+ }
1251
+ if (record.stage === 'setup-intent' || record.stage === 'setup-running') {
1252
+ await this.writeOperationRecord(operation, {
1253
+ stage: 'attention-required',
1254
+ failure: 'setup start was recorded without a durable success receipt; refusing to rerun it'
1255
+ });
1256
+ throw new CapabilityError('The code-session setup may have partially run.', 'CONFLICT', {
1257
+ reason: 'code-session-operation-conflict', retryable: false, details: { operationId: record.operationId }
1258
+ });
1259
+ }
1260
+ if (record.stage === 'ready') {
1261
+ await notifyAllocated(input, record.sessionId, this.log);
1262
+ return this.attach(record.sessionId);
1263
+ }
1264
+ const sessionId = requireSafeSessionId(record.sessionId);
1265
+ await notifyAllocated(input, sessionId, this.log);
1266
+ const hostTarget = input.hostTarget ?? DEFAULT_SESSION_HOST_TARGET;
1267
+ const fs = await this.workspaceFilesystem(hostTarget);
1268
+ const borrowedWorkspace = input.workspacePath?.trim();
1269
+ if (borrowedWorkspace && hostTarget.kind !== 'host') {
1270
+ throw requestInvalid('hostTarget');
1271
+ }
1272
+ if (borrowedWorkspace && (input.cloneUrl || input.repositoryId || input.ephemeral)) {
1273
+ throw requestInvalid('workspacePath');
1274
+ }
1275
+ const sessionRoot = await this.targetSessionRoot(fs, hostTarget);
1276
+ const workspacePath = borrowedWorkspace
1277
+ ? fs.resolve(borrowedWorkspace)
1278
+ : ownedWorkspacePath(fs, sessionRoot, sessionId);
1279
+ const workspaceOwnership = borrowedWorkspace
1280
+ ? 'borrowed'
1281
+ : 'owned';
1282
+ const stagingPath = borrowedWorkspace
1283
+ ? workspacePath
1284
+ : fs.join(sessionRoot, '.staging', operation.key, `generation-${record.generation}`);
1285
+ const existingReady = await this.loadPersistedSessionMeta(sessionId, false, false);
1286
+ if (existingReady?.operationId === record.operationId &&
1287
+ existingReady.operationFingerprint === record.fingerprint) {
1288
+ await this.writeOperationRecord(operation, { stage: 'ready', failure: undefined });
1289
+ return this.toHandle(existingReady);
1290
+ }
1291
+ await this.writeOperationRecord(operation, { stagingPath });
1292
+ if (workspaceOwnership === 'borrowed') {
1293
+ if (!(await fs.exists(workspacePath))) {
1294
+ throw new CapabilityError('The requested workspace does not exist.', 'NOT_FOUND', {
1295
+ reason: 'code-session-workspace-not-found', retryable: false, details: { sessionId }
1296
+ });
1297
+ }
1298
+ const stat = await fs.stat(workspacePath);
1299
+ if (stat.kind !== 'directory') {
1300
+ throw requestInvalid('workspacePath');
1301
+ }
1302
+ }
1303
+ else {
1304
+ await fs.mkdir(stagingPath, { recursive: true });
1305
+ }
1306
+ if (record.stage === 'reserved' || record.stage === 'source-preparing') {
1307
+ await this.writeOperationRecord(operation, { stage: 'source-preparing' });
1308
+ const gitDirectory = fs.join(stagingPath, '.git');
1309
+ const hasGit = await fs.exists(gitDirectory);
1310
+ if (input.cloneUrl) {
1311
+ if (hasGit) {
1312
+ const remote = await this.exec(stagingPath, ['git', 'remote', 'get-url', 'origin'], { cwd: stagingPath }, hostTarget);
1313
+ if (remote.exitCode !== 0 || remote.stdout.trim() !== input.cloneUrl) {
1314
+ await this.writeOperationRecord(operation, {
1315
+ stage: 'attention-required',
1316
+ failure: 'the preserved staging repository does not match the requested clone URL'
1317
+ });
1318
+ throw new CapabilityError('The preserved staging repository does not match this operation.', 'CONFLICT', {
1319
+ reason: 'code-session-operation-conflict', retryable: false, details: { operationId: record.operationId }
1320
+ });
1321
+ }
1322
+ }
1323
+ else {
1324
+ const entries = await fs.list(stagingPath);
1325
+ if (entries.length > 0) {
1326
+ await this.writeOperationRecord(operation, {
1327
+ stage: 'attention-required',
1328
+ failure: 'clone was interrupted and left an unverified staging workspace'
1329
+ });
1330
+ throw new CapabilityError('An interrupted clone left an unverified staging workspace.', 'CONFLICT', {
1331
+ reason: 'code-session-operation-conflict', retryable: false, details: { operationId: record.operationId }
1332
+ });
1333
+ }
1334
+ const cloneCred = codeSessionCredentialArgs(input.cloneCredential);
1335
+ const credEnv = Object.keys(cloneCred.env).length > 0 ? cloneCred.env : undefined;
1336
+ const cloneArgs = ['git', ...cloneCred.args, 'clone'];
1337
+ if (input.checkoutRef)
1338
+ cloneArgs.push('--branch', input.checkoutRef);
1339
+ cloneArgs.push(input.cloneUrl, stagingPath);
1340
+ const cloneOptions = { cwd: sessionRoot };
1341
+ if (credEnv)
1342
+ cloneOptions.env = credEnv;
1343
+ const result = await this.exec(sessionRoot, cloneArgs, cloneOptions, hostTarget);
1344
+ if (result.exitCode !== 0) {
1345
+ await this.writeOperationRecord(operation, {
1346
+ stage: 'attention-required',
1347
+ failure: 'clone command failed'
1348
+ });
1349
+ throw new CapabilityError('The code-session clone command failed.', 'UNAVAILABLE', {
1350
+ reason: 'code-session-host-unavailable', retryable: false, details: { exitCode: result.exitCode }
1351
+ });
1352
+ }
1353
+ }
1354
+ }
1355
+ else if ((input.repositoryId || input.ephemeral) && !hasGit) {
1356
+ const init = await this.exec(stagingPath, ['git', 'init'], {}, hostTarget);
1357
+ if (init.exitCode !== 0) {
1358
+ await this.writeOperationRecord(operation, {
1359
+ stage: 'attention-required',
1360
+ failure: 'git initialization command failed'
1361
+ });
1362
+ throw new CapabilityError('The code-session git initialization command failed.', 'UNAVAILABLE', {
1363
+ reason: 'code-session-host-unavailable', retryable: false, details: { exitCode: init.exitCode }
1364
+ });
1365
+ }
1366
+ }
1367
+ await this.writeOperationRecord(operation, { stage: 'source-ready', failure: undefined });
1368
+ }
1369
+ const setupScript = input.runtimeConfig?.setupScript?.trim();
1370
+ if (setupScript && operation.record.stage === 'source-ready') {
1371
+ await this.writeOperationRecord(operation, { stage: 'setup-intent' });
1372
+ await this.writeOperationRecord(operation, { stage: 'setup-running' });
1373
+ const result = await this.exec(stagingPath, ['sh', '-lc', setupScript], { cwd: stagingPath, env: input.initialEnv }, hostTarget);
1374
+ if (result.exitCode !== 0) {
1375
+ await this.writeOperationRecord(operation, {
1376
+ stage: 'attention-required',
1377
+ failure: 'setup command failed'
1378
+ });
1379
+ throw new CapabilityError('The code-session setup command failed.', 'UNAVAILABLE', {
1380
+ reason: 'code-session-host-unavailable', retryable: false, details: { exitCode: result.exitCode }
1381
+ });
1382
+ }
1383
+ await this.writeOperationRecord(operation, { stage: 'setup-succeeded', failure: undefined });
1384
+ }
1385
+ else if (!setupScript && operation.record.stage === 'source-ready') {
1386
+ await this.writeOperationRecord(operation, { stage: 'setup-succeeded', failure: undefined });
1387
+ }
1388
+ const meta = {
1389
+ sessionId,
1390
+ operationId: record.operationId,
1391
+ operationFingerprint: record.fingerprint,
1392
+ workspacePath,
1393
+ workspaceOwnership,
1394
+ createdAt: record.createdAt,
1395
+ hostTarget,
1396
+ repositoryId: input.repositoryId,
1397
+ cloneUrl: input.cloneUrl,
1398
+ checkoutRef: input.checkoutRef,
1399
+ exposedPorts: input.exposedPorts ?? [...this.descriptor.exposedPorts],
1400
+ ephemeral: input.ephemeral
1401
+ };
1402
+ await this.persistSessionMetadata(meta, workspaceOwnership === 'owned' ? stagingPath : undefined);
1403
+ if (workspaceOwnership === 'owned') {
1404
+ if (await fs.exists(workspacePath)) {
1405
+ await this.writeOperationRecord(operation, {
1406
+ stage: 'attention-required',
1407
+ failure: 'the final workspace path already exists and was not proven to belong to this operation'
1408
+ });
1409
+ throw new CapabilityError('The final workspace path is already occupied.', 'CONFLICT', {
1410
+ reason: 'code-session-operation-conflict', retryable: false, details: { operationId: record.operationId }
1411
+ });
1412
+ }
1413
+ await fs.rename(stagingPath, workspacePath);
1414
+ if (hostTarget.kind === 'host' && hostTarget.hostId !== 'local') {
1415
+ try {
1416
+ const published = await fs.readFile(this.metadataPath(fs, workspacePath), 'utf8');
1417
+ const verified = parseCodeSessionMetadata(utf8Text(published));
1418
+ if (!verified || verified.sessionId !== sessionId || verified.operationId !== meta.operationId || verified.operationFingerprint !== meta.operationFingerprint || verified.workspacePath !== workspacePath) {
1419
+ throw new CapabilityError('Remote session publication could not be verified.', 'CONFLICT', {
1420
+ reason: 'code-session-profile-conflict', retryable: false, details: { sessionId }
1421
+ });
1422
+ }
1423
+ await this.persistRemotePointer(meta);
1424
+ }
1425
+ catch (caught) {
1426
+ await this.writeOperationRecord(operation, { stage: 'attention-required', failure: 'Remote workspace published; local discovery publication failed. Preserve the remote workspace for explicit recovery.' });
1427
+ throw new CapabilityError('Remote session control publication failed; explicit recovery is required.', 'UNAVAILABLE', {
1428
+ reason: 'code-session-host-unavailable',
1429
+ retryable: false,
1430
+ cause: valueFreeFailure(CaughtFailureSchema.parse(caught))
1431
+ });
1432
+ }
1433
+ }
1434
+ }
1435
+ await this.writeOperationRecord(operation, { stage: 'ready', failure: undefined });
1436
+ this.sessions.set(sessionId, meta);
1437
+ this.background.set(sessionId, []);
1438
+ this.log.info?.('code session initialized', {
1439
+ sessionId,
1440
+ operationId: record.operationId,
1441
+ workspacePath,
1442
+ hostKind: hostTarget.kind,
1443
+ repositoryId: input.repositoryId
1444
+ });
1445
+ return this.toHandle(meta);
1446
+ });
1447
+ }
1448
+ async attach(sessionId) {
1449
+ sessionId = parseCodeSessionId(sessionId);
1450
+ this.requireTrust();
1451
+ requireSafeSessionId(sessionId);
1452
+ const meta = this.sessions.get(sessionId) ?? (await this.loadPersistedSessionMeta(sessionId));
1453
+ if (!meta) {
1454
+ throw new CapabilityError('The requested code session was not found.', 'NOT_FOUND', {
1455
+ reason: 'code-session-workspace-not-found', retryable: false, details: { sessionId }
1456
+ });
1457
+ }
1458
+ return this.toHandle(await this.requireReadySession(meta));
1459
+ }
1460
+ async discover() {
1461
+ this.requireTrust();
1462
+ const fs = await this.filesystem();
1463
+ const sessionRoot = this.sessionRoot(fs);
1464
+ if (!(await fs.exists(sessionRoot)))
1465
+ return [];
1466
+ const ids = new Set();
1467
+ for (const entry of await fs.list(sessionRoot)) {
1468
+ if (entry.kind === 'directory' && isSafeSessionId(entry.name))
1469
+ ids.add(entry.name);
1470
+ }
1471
+ const borrowedRoot = fs.join(sessionRoot, '.metadata');
1472
+ if (await fs.exists(borrowedRoot)) {
1473
+ for (const entry of await fs.list(borrowedRoot)) {
1474
+ if (entry.kind === 'file' && entry.name.endsWith('.json')) {
1475
+ const sessionId = entry.name.slice(0, -'.json'.length);
1476
+ if (isSafeSessionId(sessionId))
1477
+ ids.add(sessionId);
1478
+ }
1479
+ }
1480
+ }
1481
+ const handles = await Promise.all([...ids].map(async (sessionId) => {
1482
+ const meta = await this.loadPersistedSessionMeta(sessionId, false, false);
1483
+ return meta ? this.toHandle(meta) : null;
1484
+ }));
1485
+ return handles
1486
+ .filter((handle) => handle !== null)
1487
+ .sort((a, b) => (a.createdAt ?? '').localeCompare(b.createdAt ?? ''));
1488
+ }
1489
+ async runCommand(sessionId, input) {
1490
+ sessionId = parseCodeSessionId(sessionId);
1491
+ input = parseRunCommandInput(input);
1492
+ const meta = await this.ensureSession(sessionId);
1493
+ if (meta.pausedAt)
1494
+ throw sessionPaused(sessionId);
1495
+ const fs = await this.workspaceFilesystem(meta.hostTarget);
1496
+ const cwd = input.cwd
1497
+ ? resolveInWorkspace(fs, meta.workspacePath, input.cwd)
1498
+ : meta.workspacePath;
1499
+ if (input.signal?.aborted) {
1500
+ return {
1501
+ exitCode: null,
1502
+ stdout: '',
1503
+ stderr: '[code-session] stopped before spawn',
1504
+ timedOut: false,
1505
+ durationMs: 0,
1506
+ aborted: true
1507
+ };
1508
+ }
1509
+ if (input.background) {
1510
+ // Background mode needs a LONG-LIVED handle, so it uses spawn() (not
1511
+ // execute()): the ProcessHandle stays live in `this.background`,
1512
+ // `running` is tracked off `proc.exited`, and teardown() calls
1513
+ // `proc.kill()`. This is the path that proves the spawn() mode must
1514
+ // exist — execute()+session() alone has nowhere to land it.
1515
+ const procId = crypto.randomUUID();
1516
+ const [bin, ...args] = input.command;
1517
+ const proc = await this.spawnBackground(meta.workspacePath, meta.hostTarget, {
1518
+ command: bin,
1519
+ args,
1520
+ cwd,
1521
+ stdout: 'pipe',
1522
+ stderr: 'pipe',
1523
+ // Stop and teardown take the background command's whole tree.
1524
+ killTree: true
1525
+ }, input.env);
1526
+ const entry = {
1527
+ procId,
1528
+ proc,
1529
+ running: true,
1530
+ command: input.command,
1531
+ startedAt: this.nowIso(),
1532
+ output: [],
1533
+ produced: 0,
1534
+ drains: []
1535
+ };
1536
+ if (input.cwd)
1537
+ entry.cwd = input.cwd;
1538
+ // Piped output is always read: an undrained pipe blocks a chatty process once its buffer fills.
1539
+ this.drainBackground(sessionId, entry, 'stdout', proc.stdout);
1540
+ this.drainBackground(sessionId, entry, 'stderr', proc.stderr);
1541
+ void proc.exited.then(({ code }) => {
1542
+ entry.running = false;
1543
+ entry.exitCode = code;
1544
+ }, (caught) => {
1545
+ const failure = valueFreeFailure(CaughtFailureSchema.parse(caught));
1546
+ entry.running = null;
1547
+ this.log.warn('background process exit could not be observed', { sessionId, procId, failure });
1548
+ return failure;
1549
+ });
1550
+ this.background.get(sessionId)?.push(entry);
1551
+ this.log.info?.('background process spawned', { sessionId, procId, pid: proc.pid });
1552
+ return {
1553
+ exitCode: null,
1554
+ stdout: '',
1555
+ stderr: '',
1556
+ timedOut: false,
1557
+ durationMs: 0,
1558
+ procId,
1559
+ running: true
1560
+ };
1561
+ }
1562
+ return this.exec(meta.workspacePath, input.command, { cwd, env: input.env, timeoutMs: input.timeoutMs, signal: input.signal }, meta.hostTarget);
1563
+ }
1564
+ async listBackgroundProcesses(sessionId) {
1565
+ sessionId = parseCodeSessionId(sessionId);
1566
+ await this.ensureSession(sessionId);
1567
+ return (this.background.get(sessionId) ?? []).map((entry) => {
1568
+ const info = {
1569
+ procId: entry.procId,
1570
+ command: entry.command,
1571
+ running: entry.running,
1572
+ startedAt: entry.startedAt,
1573
+ pid: entry.proc.pid
1574
+ };
1575
+ if (entry.cwd)
1576
+ info.cwd = entry.cwd;
1577
+ if (entry.running === false)
1578
+ info.exitCode = entry.exitCode ?? null;
1579
+ return info;
1580
+ });
1581
+ }
1582
+ /**
1583
+ * Keep one background stream's chunks in the entry's ring until the stream
1584
+ * ends. Disposal joins owned work before plugins tear sessions down, so the
1585
+ * read stops when disposal begins instead of holding it for a live process.
1586
+ */
1587
+ drainBackground(sessionId, entry, stream, source) {
1588
+ if (source === null)
1589
+ return;
1590
+ const reader = source.getReader();
1591
+ const lifecycle = this.runtime.lifecycle;
1592
+ const drained = settle(() => lifecycle.own(async () => {
1593
+ const stop = () => void settle(() => reader.cancel());
1594
+ lifecycle.signal.addEventListener('abort', stop, { once: true });
1595
+ try {
1596
+ for (;;) {
1597
+ const next = await reader.read();
1598
+ if (next.done)
1599
+ return;
1600
+ entry.produced += 1;
1601
+ entry.output.push({ stream, bytes: next.value });
1602
+ if (entry.output.length > MAX_BACKGROUND_CHUNKS)
1603
+ entry.output.shift();
1604
+ }
1605
+ }
1606
+ finally {
1607
+ lifecycle.signal.removeEventListener('abort', stop);
1608
+ }
1609
+ })).then((read) => {
1610
+ if (read.ok)
1611
+ return;
1612
+ this.log.warn('background process output stopped being read', {
1613
+ sessionId,
1614
+ procId: entry.procId,
1615
+ failure: valueFreeFailure(read.cause)
1616
+ });
1617
+ });
1618
+ entry.drains.push(drained);
1619
+ }
1620
+ async backgroundEntry(sessionId, procId) {
1621
+ await this.ensureSession(sessionId);
1622
+ const entry = (this.background.get(sessionId) ?? []).find((candidate) => candidate.procId === procId);
1623
+ if (!entry) {
1624
+ throw new CapabilityError('The background process is not known in this session.', 'NOT_FOUND', {
1625
+ reason: 'code-session-process-not-found', retryable: false, details: { procId }
1626
+ });
1627
+ }
1628
+ return entry;
1629
+ }
1630
+ async readBackgroundProcess(sessionId, procId, input) {
1631
+ sessionId = parseCodeSessionId(sessionId);
1632
+ procId = parseProcessId(procId);
1633
+ const { since } = parseBackgroundProcessReadInput(input ?? {});
1634
+ return backgroundOutputSince(await this.backgroundEntry(sessionId, procId), since, false);
1635
+ }
1636
+ async waitBackgroundProcess(sessionId, procId, input) {
1637
+ sessionId = parseCodeSessionId(sessionId);
1638
+ procId = parseProcessId(procId);
1639
+ const { since, timeoutMs } = parseBackgroundProcessWaitInput(input);
1640
+ const entry = await this.backgroundEntry(sessionId, procId);
1641
+ const waited = await settle(() => this.runtime.lifecycle.wait(async () => entry.proc.exited, {
1642
+ timeoutMs,
1643
+ reason: 'code-session background wait'
1644
+ }));
1645
+ if (!waited.ok && !isBackgroundWaitTimeout(waited.cause))
1646
+ throw waited.cause;
1647
+ if (waited.ok) {
1648
+ entry.running = false;
1649
+ entry.exitCode = waited.value.code;
1650
+ // An ended process's last output is still in its pipes: answer once both are read. A
1651
+ // descendant can hold a pipe open past the exit, so the join is bounded.
1652
+ const joined = await settle(() => this.runtime.lifecycle.wait(async () => {
1653
+ await Promise.all(entry.drains);
1654
+ }, { timeoutMs: BACKGROUND_DRAIN_JOIN_MS, reason: 'code-session background output' }));
1655
+ if (!joined.ok && !isBackgroundWaitTimeout(joined.cause))
1656
+ throw joined.cause;
1657
+ }
1658
+ return backgroundOutputSince(entry, since, !waited.ok);
1659
+ }
1660
+ async stopBackgroundProcess(sessionId, procId, signal) {
1661
+ sessionId = parseCodeSessionId(sessionId);
1662
+ procId = parseProcessId(procId);
1663
+ signal = parseProcessSignal(signal);
1664
+ await this.ensureSession(sessionId);
1665
+ const entry = (this.background.get(sessionId) ?? []).find((candidate) => candidate.procId === procId);
1666
+ if (!entry)
1667
+ return false;
1668
+ if (entry.running === false)
1669
+ return true;
1670
+ if (entry.running === null) {
1671
+ throw new CapabilityError('Background process liveness is unknown.', 'UNAVAILABLE', {
1672
+ reason: 'code-session-cleanup-unconfirmed', retryable: false, details: { procId }
1673
+ });
1674
+ }
1675
+ try {
1676
+ await entry.proc.kill(signal ?? 'SIGTERM');
1677
+ }
1678
+ catch (caught) {
1679
+ entry.running = null;
1680
+ throw new CapabilityError('Background process signal submission failed.', 'UNAVAILABLE', {
1681
+ reason: 'code-session-cleanup-unconfirmed',
1682
+ retryable: false,
1683
+ details: { procId },
1684
+ cause: valueFreeFailure(CaughtFailureSchema.parse(caught))
1685
+ });
1686
+ }
1687
+ return true;
1688
+ }
1689
+ async pause(sessionId) {
1690
+ sessionId = parseCodeSessionId(sessionId);
1691
+ const meta = await this.ensureSession(sessionId);
1692
+ if (meta.pausedAt)
1693
+ return this.toHandle(meta);
1694
+ for (const entry of this.background.get(sessionId) ?? []) {
1695
+ if (entry.running !== false)
1696
+ await this.stopBackgroundProcess(sessionId, entry.procId);
1697
+ }
1698
+ meta.pausedAt = this.nowIso();
1699
+ await this.persistSessionMetadata(meta);
1700
+ return this.toHandle(meta);
1701
+ }
1702
+ async resume(sessionId) {
1703
+ sessionId = parseCodeSessionId(sessionId);
1704
+ const meta = await this.ensureSession(sessionId);
1705
+ if (!meta.pausedAt)
1706
+ return this.toHandle(meta);
1707
+ meta.pausedAt = undefined;
1708
+ await this.persistSessionMetadata(meta);
1709
+ return this.attach(sessionId);
1710
+ }
1711
+ async readFile(sessionId, path) {
1712
+ sessionId = parseCodeSessionId(sessionId);
1713
+ path = parseCodeSessionPath(path);
1714
+ const meta = await this.ensureSession(sessionId);
1715
+ const fs = await this.workspaceFilesystem(meta.hostTarget);
1716
+ const abs = resolveInWorkspace(fs, meta.workspacePath, path);
1717
+ const contents = await fs.readFile(abs, 'utf8');
1718
+ return utf8Text(contents);
1719
+ }
1720
+ async writeFile(sessionId, path, content) {
1721
+ sessionId = parseCodeSessionId(sessionId);
1722
+ ({ path, content } = parseWriteFileInput(path, content));
1723
+ const meta = await this.ensureSession(sessionId);
1724
+ const fs = await this.workspaceFilesystem(meta.hostTarget);
1725
+ const abs = resolveInWorkspace(fs, meta.workspacePath, path);
1726
+ await fs.mkdir(fs.dirname(abs), { recursive: true });
1727
+ await fs.writeFile(abs, content, 'utf8');
1728
+ }
1729
+ async patchFile(sessionId, input) {
1730
+ sessionId = parseCodeSessionId(sessionId);
1731
+ input = parsePatchFileInput(input);
1732
+ const meta = await this.ensureSession(sessionId);
1733
+ const fs = await this.workspaceFilesystem(meta.hostTarget);
1734
+ // Validate the target stays in-workspace before applying.
1735
+ resolveInWorkspace(fs, meta.workspacePath, input.path);
1736
+ // `git apply` is the safe, atomic diff applier; write the diff to a temp
1737
+ // file in the workspace then apply it relative to the workspace root.
1738
+ const patchPath = resolveInWorkspace(fs, meta.workspacePath, `.vibes-patch-${crypto.randomUUID()}.diff`);
1739
+ await fs.writeFile(patchPath, input.diff, 'utf8');
1740
+ try {
1741
+ const result = await this.exec(meta.workspacePath, ['git', 'apply', patchPath], {}, meta.hostTarget);
1742
+ if (result.exitCode !== 0) {
1743
+ throw new CapabilityError('The code-session patch command failed.', 'INVALID_INPUT', {
1744
+ reason: 'code-session-request-invalid', retryable: false, details: { exitCode: result.exitCode }
1745
+ });
1746
+ }
1747
+ }
1748
+ finally {
1749
+ await fs.remove(patchPath);
1750
+ }
1751
+ }
1752
+ async gitApply(sessionId, input) {
1753
+ sessionId = parseCodeSessionId(sessionId);
1754
+ input = parseGitApplyInput(input);
1755
+ const meta = await this.ensureSession(sessionId);
1756
+ const fs = await this.workspaceFilesystem(meta.hostTarget);
1757
+ // Write diff to a temp file in the workspace (git apply requires a file path).
1758
+ const patchPath = resolveInWorkspace(fs, meta.workspacePath, `.vibes-patch-${crypto.randomUUID()}.diff`);
1759
+ await fs.writeFile(patchPath, input.diff, 'utf8');
1760
+ try {
1761
+ const args = input.check
1762
+ ? ['git', 'apply', '--check', patchPath]
1763
+ : ['git', 'apply', patchPath];
1764
+ const result = await this.exec(meta.workspacePath, args, {}, meta.hostTarget);
1765
+ return {
1766
+ ok: result.exitCode === 0,
1767
+ stdout: result.stdout,
1768
+ stderr: result.stderr,
1769
+ exitCode: result.exitCode
1770
+ };
1771
+ }
1772
+ finally {
1773
+ await fs.remove(patchPath);
1774
+ }
1775
+ }
1776
+ async gitStatus(sessionId) {
1777
+ sessionId = parseCodeSessionId(sessionId);
1778
+ const meta = await this.ensureSession(sessionId);
1779
+ const [porcelain, branch] = await Promise.all([
1780
+ this.exec(meta.workspacePath, ['git', '--no-optional-locks', 'status', '--porcelain'], {}, meta.hostTarget),
1781
+ this.exec(meta.workspacePath, ['git', '--no-optional-locks', 'rev-parse', '--abbrev-ref', 'HEAD'], {}, meta.hostTarget)
1782
+ ]);
1783
+ if (porcelain.exitCode !== 0) {
1784
+ throw new CapabilityError('The code-session git status command failed.', 'UNAVAILABLE', {
1785
+ reason: 'code-session-host-unavailable', retryable: false, details: { exitCode: porcelain.exitCode }
1786
+ });
1787
+ }
1788
+ const staged = [];
1789
+ const unstaged = [];
1790
+ const untracked = [];
1791
+ for (const line of porcelain.stdout.split('\n')) {
1792
+ if (!line.trim())
1793
+ continue;
1794
+ const x = line[0];
1795
+ const y = line[1];
1796
+ const file = line.slice(3);
1797
+ if (x === '?' && y === '?')
1798
+ untracked.push(file);
1799
+ else {
1800
+ if (x && x !== ' ')
1801
+ staged.push(file);
1802
+ if (y && y !== ' ')
1803
+ unstaged.push(file);
1804
+ }
1805
+ }
1806
+ return { staged, unstaged, untracked, branch: branch.stdout.trim() || 'HEAD' };
1807
+ }
1808
+ async withGitRepoLock(repoRoot, fn, signal) {
1809
+ const previous = this.gitCommitQueues.get(repoRoot);
1810
+ const run = async () => {
1811
+ if (previous)
1812
+ await settle(() => previous);
1813
+ signal?.throwIfAborted();
1814
+ return fn();
1815
+ };
1816
+ const current = run();
1817
+ const tracked = settle(() => current);
1818
+ this.gitCommitQueues.set(repoRoot, tracked);
1819
+ try {
1820
+ return await current;
1821
+ }
1822
+ finally {
1823
+ if (this.gitCommitQueues.get(repoRoot) === tracked) {
1824
+ this.gitCommitQueues.delete(repoRoot);
1825
+ }
1826
+ }
1827
+ }
1828
+ async resolveGitRootForPaths(meta, paths, signal) {
1829
+ signal?.throwIfAborted();
1830
+ if (!paths || paths.length === 0) {
1831
+ return { gitRoot: meta.workspacePath, relPaths: [] };
1832
+ }
1833
+ const fs = await this.workspaceFilesystem(meta.hostTarget);
1834
+ signal?.throwIfAborted();
1835
+ const rootNorm = fs.normalize(meta.workspacePath);
1836
+ const resolved = await Promise.all(paths.map(async (rawPath) => {
1837
+ const abs = fs.normalize(resolveInWorkspace(fs, rootNorm, rawPath));
1838
+ let current = abs;
1839
+ while (current.startsWith(rootNorm) && current.length >= rootNorm.length) {
1840
+ signal?.throwIfAborted();
1841
+ const isRepo = await fs.exists(fs.join(current, '.git'));
1842
+ signal?.throwIfAborted();
1843
+ if (isRepo) {
1844
+ return { abs, gitRoot: current };
1845
+ }
1846
+ const parent = fs.normalize(fs.dirname(current));
1847
+ if (parent === current)
1848
+ break;
1849
+ current = parent;
1850
+ }
1851
+ return { abs, gitRoot: rootNorm };
1852
+ }));
1853
+ const firstRoot = resolved[0].gitRoot;
1854
+ const commonRoot = resolved.every((entry) => entry.gitRoot === firstRoot) ? firstRoot : rootNorm;
1855
+ const relPaths = resolved.map(({ abs }) => {
1856
+ if (abs === commonRoot)
1857
+ return '.';
1858
+ const prefix = commonRoot.endsWith('/') ? commonRoot : `${commonRoot}/`;
1859
+ return abs.startsWith(prefix) ? abs.slice(prefix.length) : abs;
1860
+ });
1861
+ return { gitRoot: commonRoot, relPaths };
1862
+ }
1863
+ async gitCommit(sessionId, input, options = {}) {
1864
+ const { signal } = parseGitCommitOptions(options);
1865
+ signal?.throwIfAborted();
1866
+ sessionId = parseCodeSessionId(sessionId);
1867
+ input = parseGitCommitInput(input);
1868
+ const meta = await this.ensureSession(sessionId);
1869
+ signal?.throwIfAborted();
1870
+ const { gitRoot, relPaths } = await this.resolveGitRootForPaths(meta, input.paths, signal);
1871
+ const hasExplicitPaths = relPaths.length > 0 && !relPaths.includes('.');
1872
+ return this.withGitRepoLock(gitRoot, async () => {
1873
+ const runStage = async (operation, command) => {
1874
+ signal?.throwIfAborted();
1875
+ const outcome = await settle(() => this.exec(gitRoot, command, { cwd: gitRoot, signal, abortKillSignal: 'SIGTERM' }, meta.hostTarget));
1876
+ if (!outcome.ok || outcome.value.exitCode !== 0) {
1877
+ throw new CapabilityError('The code-session Git command failed.', 'UNAVAILABLE', {
1878
+ reason: 'code-session-host-unavailable',
1879
+ retryable: false,
1880
+ details: { operation, exitCode: outcome.ok ? outcome.value.exitCode : null }
1881
+ });
1882
+ }
1883
+ return outcome.value;
1884
+ };
1885
+ let stagedByHelper = false;
1886
+ if (hasExplicitPaths) {
1887
+ await runStage('git-add', ['git', 'add', '-A', '--', ...relPaths]);
1888
+ stagedByHelper = true;
1889
+ }
1890
+ else {
1891
+ const stagedCheck = await runStage('git-staged-inspection', [
1892
+ 'git', '--no-optional-locks', 'diff', '--cached', '--name-only'
1893
+ ]);
1894
+ if (stagedCheck.stdout.trim().length === 0) {
1895
+ const addArgs = relPaths.length > 0 ? ['git', 'add', '-A', '--', ...relPaths] : ['git', 'add', '-A'];
1896
+ await runStage('git-add', addArgs);
1897
+ stagedByHelper = true;
1898
+ }
1899
+ }
1900
+ const commitArgs = ['git', '-c', 'commit.gpgsign=false'];
1901
+ if (input.author) {
1902
+ const authorMatch = /^([^<>]+?)\s*<([^<>]+)>$/.exec(input.author.trim());
1903
+ if (authorMatch) {
1904
+ commitArgs.push('-c', `user.name=${authorMatch[1].trim()}`, '-c', `user.email=${authorMatch[2].trim()}`);
1905
+ }
1906
+ }
1907
+ commitArgs.push('commit', '-m', input.message);
1908
+ if (input.author)
1909
+ commitArgs.push('--author', input.author);
1910
+ if (hasExplicitPaths) {
1911
+ commitArgs.push('--', ...relPaths);
1912
+ }
1913
+ try {
1914
+ await runStage('git-commit', commitArgs);
1915
+ }
1916
+ catch (failure) {
1917
+ if (stagedByHelper) {
1918
+ const resetArgs = hasExplicitPaths
1919
+ ? ['git', 'reset', '-q', 'HEAD', '--', ...relPaths]
1920
+ : ['git', 'reset', '-q', 'HEAD'];
1921
+ // Reset is cleanup of this attempt, independent of its cancelled foreground lifetime.
1922
+ const reset = await settle(() => this.exec(gitRoot, resetArgs, { cwd: gitRoot }, meta.hostTarget));
1923
+ if (!reset.ok || reset.value.exitCode !== 0) {
1924
+ throw new CapabilityError('Code-session Git cleanup is unconfirmed.', 'UNAVAILABLE', {
1925
+ reason: 'code-session-cleanup-unconfirmed', retryable: false,
1926
+ details: { operation: 'git-reset', exitCode: reset.ok ? reset.value.exitCode : null },
1927
+ cause: failure
1928
+ });
1929
+ }
1930
+ }
1931
+ throw failure;
1932
+ }
1933
+ const hash = await runStage('git-hash-lookup', [
1934
+ 'git', '--no-optional-locks', 'rev-parse', 'HEAD'
1935
+ ]);
1936
+ return { hash: hash.stdout.trim(), message: input.message };
1937
+ }, signal);
1938
+ }
1939
+ async gitPush(sessionId, input = {}) {
1940
+ sessionId = parseCodeSessionId(sessionId);
1941
+ input = parseGitPushInput(input);
1942
+ const meta = await this.ensureSession(sessionId);
1943
+ // Resolve the push credential: explicit > host resolver (by repositoryId) >
1944
+ // none (ambient workspace auth). The token never lands in argv / the URL /
1945
+ // `.git/config` — it is fed via a credential helper from the CHILD env only.
1946
+ let credential = input.credential;
1947
+ if (!credential && this.pushCredentialResolver && meta.repositoryId) {
1948
+ credential = (await this.pushCredentialResolver(meta.repositoryId)) ?? undefined;
1949
+ }
1950
+ const cred = codeSessionCredentialArgs(credential);
1951
+ const args = ['git', ...cred.args, 'push'];
1952
+ if (input.setUpstream)
1953
+ args.push('-u');
1954
+ if (input.remote)
1955
+ args.push(input.remote);
1956
+ if (input.branch)
1957
+ args.push(input.branch);
1958
+ const pushOptions = {};
1959
+ if (Object.keys(cred.env).length > 0)
1960
+ pushOptions.env = cred.env;
1961
+ const result = await this.exec(meta.workspacePath, args, pushOptions, meta.hostTarget);
1962
+ if (result.exitCode !== 0) {
1963
+ throw new CapabilityError('The code-session git push command failed.', 'UNAVAILABLE', {
1964
+ reason: 'code-session-host-unavailable', retryable: false, details: { exitCode: result.exitCode }
1965
+ });
1966
+ }
1967
+ }
1968
+ async exposePort(sessionId, input) {
1969
+ sessionId = parseCodeSessionId(sessionId);
1970
+ input = parseExposePortInput(input);
1971
+ const meta = await this.ensureSession(sessionId);
1972
+ if (!meta.exposedPorts.includes(input.port))
1973
+ meta.exposedPorts.push(input.port);
1974
+ // On a local Host the preview is reachable on localhost. A future remote
1975
+ // Host daemon would publish a tunneled URL here.
1976
+ const previewUrl = `http://localhost:${input.port}`;
1977
+ meta.previewUrl = previewUrl;
1978
+ await this.persistSessionMetadata(meta);
1979
+ return { port: input.port, previewUrl };
1980
+ }
1981
+ async teardown(sessionId) {
1982
+ sessionId = parseCodeSessionId(sessionId);
1983
+ requireSafeSessionId(sessionId);
1984
+ const known = this.sessions.get(sessionId);
1985
+ const meta = known?.workspaceOwnership === 'owned' && known.hostTarget.kind === 'host' && known.hostTarget.hostId !== 'local'
1986
+ ? await this.loadPersistedSessionMeta(sessionId, true)
1987
+ : known ?? await this.loadPersistedSessionMeta(sessionId, true);
1988
+ if (!meta)
1989
+ return;
1990
+ const controlFs = await this.filesystem();
1991
+ const key = meta.operationId ? await codeSessionOperationKey(meta.operationId) : undefined;
1992
+ const record = key ? await this.readOperationRecord(controlFs, key) : null;
1993
+ const operation = key && record ? { key, record, fs: controlFs } : undefined;
1994
+ const cleanup = async () => {
1995
+ const current = operation?.record ?? record;
1996
+ const resumingCleanup = current && (current.stage === 'cleanup-pending' || current.stage === 'torn-down') &&
1997
+ current.sessionId === sessionId && current.fingerprint === meta.operationFingerprint;
1998
+ if (!resumingCleanup)
1999
+ await this.requireReadySession(meta);
2000
+ for (const entry of this.background.get(sessionId) ?? []) {
2001
+ if (entry.running === false)
2002
+ continue;
2003
+ if (entry.running === null) {
2004
+ throw new CapabilityError('Background process liveness is unknown.', 'UNAVAILABLE', {
2005
+ reason: 'code-session-cleanup-unconfirmed', retryable: false, details: { procId: entry.procId }
2006
+ });
2007
+ }
2008
+ try {
2009
+ // The process's own exit is the owner event; the wait only bounds it.
2010
+ await this.runtime.lifecycle.wait(async () => {
2011
+ await entry.proc.kill('SIGTERM');
2012
+ await entry.proc.exited;
2013
+ }, { timeoutMs: 1_000, reason: 'code-session-background-exit' });
2014
+ }
2015
+ catch (caught) {
2016
+ entry.running = null;
2017
+ throw new CapabilityError('Background process cleanup is unconfirmed.', 'UNAVAILABLE', {
2018
+ reason: 'code-session-cleanup-unconfirmed',
2019
+ retryable: false,
2020
+ details: { procId: entry.procId },
2021
+ cause: valueFreeFailure(CaughtFailureSchema.parse(caught))
2022
+ });
2023
+ }
2024
+ }
2025
+ if (operation)
2026
+ await this.writeOperationRecord(operation, { stage: 'cleanup-pending', failure: undefined });
2027
+ if (meta.workspaceOwnership === 'owned') {
2028
+ const fs = await this.workspaceFilesystem(meta.hostTarget);
2029
+ assertOwnedWorkspacePath(fs, await this.targetSessionRoot(fs, meta.hostTarget), sessionId, meta.workspacePath);
2030
+ if (meta.hostTarget.kind === 'host' && meta.hostTarget.hostId !== 'local') {
2031
+ if (!operation)
2032
+ throw new CapabilityError('Remote owned cleanup requires its durable operation', 'CONFLICT', { retryable: false });
2033
+ const root = await this.targetSessionRoot(fs, meta.hostTarget);
2034
+ let cleanup = operation.record.cleanup;
2035
+ if (!cleanup) {
2036
+ const cleanupRoot = fs.join(root, '.cleanup', operation.key);
2037
+ await fs.mkdir(cleanupRoot, { recursive: true });
2038
+ const allocation = fs.join(cleanupRoot, crypto.randomUUID());
2039
+ // Exclusive parent allocation keeps rename's destination private and absent.
2040
+ await fs.mkdir(allocation);
2041
+ cleanup = { path: fs.join(allocation, 'workspace'), phase: 'intent' };
2042
+ await this.writeOperationRecord(operation, { cleanup });
2043
+ }
2044
+ const quarantine = this.cleanupPath(fs, root, operation.key, cleanup);
2045
+ if (cleanup.phase === 'intent') {
2046
+ const [originalExists, quarantineExists] = await Promise.all([fs.exists(meta.workspacePath), fs.exists(quarantine)]);
2047
+ if (originalExists && quarantineExists)
2048
+ throw new CapabilityError('Remote cleanup rename is ambiguous; explicit recovery is required', 'CONFLICT', { retryable: false });
2049
+ if (originalExists)
2050
+ await fs.rename(meta.workspacePath, quarantine);
2051
+ if (originalExists || quarantineExists) {
2052
+ const content = await fs.readFile(this.metadataPath(fs, quarantine), 'utf8');
2053
+ const moved = parseCodeSessionMetadata(utf8Text(content));
2054
+ if (!moved || moved.sessionId !== sessionId || moved.operationId !== meta.operationId ||
2055
+ moved.operationFingerprint !== meta.operationFingerprint || moved.workspacePath !== meta.workspacePath ||
2056
+ moved.workspaceOwnership !== 'owned' || moved.hostTarget.kind !== 'host' || moved.hostTarget.hostId !== meta.hostTarget.hostId) {
2057
+ throw new CapabilityError('Quarantined workspace identity changed; deletion is not authorized', 'CONFLICT', { retryable: false });
2058
+ }
2059
+ }
2060
+ cleanup = { ...cleanup, phase: quarantineExists || originalExists ? 'quarantined' : 'removed' };
2061
+ await this.writeOperationRecord(operation, { cleanup });
2062
+ }
2063
+ if (cleanup.phase === 'quarantined') {
2064
+ if (await fs.exists(quarantine))
2065
+ await fs.remove(quarantine, { recursive: true });
2066
+ cleanup = { ...cleanup, phase: 'removed' };
2067
+ await this.writeOperationRecord(operation, { cleanup });
2068
+ }
2069
+ }
2070
+ else
2071
+ await fs.remove(meta.workspacePath, { recursive: true });
2072
+ }
2073
+ else {
2074
+ const fs = await this.filesystem();
2075
+ try {
2076
+ await fs.remove(this.borrowedMetadataPath(fs, sessionId));
2077
+ }
2078
+ catch (caught) {
2079
+ const parsed = CaughtFailureSchema.parse(caught);
2080
+ const failure = valueFreeFailure(parsed);
2081
+ if (failure.code !== 'ENOENT')
2082
+ throw CapabilityError.from(parsed);
2083
+ }
2084
+ }
2085
+ if (operation)
2086
+ await this.writeOperationRecord(operation, { stage: 'torn-down', failure: undefined });
2087
+ if (meta.workspaceOwnership === 'owned' && meta.hostTarget.kind === 'host' && meta.hostTarget.hostId !== 'local') {
2088
+ const pointer = this.borrowedMetadataPath(controlFs, sessionId);
2089
+ if (await controlFs.exists(pointer))
2090
+ await controlFs.remove(pointer);
2091
+ }
2092
+ this.background.delete(sessionId);
2093
+ this.sessions.delete(sessionId);
2094
+ this.log.info?.('code session torn down', { sessionId });
2095
+ };
2096
+ return operation ? this.withOperationLease(operation, cleanup, true) : cleanup();
2097
+ }
2098
+ toHandle(meta) {
2099
+ return {
2100
+ sessionId: meta.sessionId,
2101
+ operationId: meta.operationId,
2102
+ workspacePath: meta.workspacePath,
2103
+ workspaceOwnership: meta.workspaceOwnership,
2104
+ createdAt: meta.createdAt,
2105
+ previewUrl: meta.previewUrl,
2106
+ hostTarget: meta.hostTarget,
2107
+ repositoryId: meta.repositoryId,
2108
+ ephemeral: meta.ephemeral
2109
+ };
2110
+ }
2111
+ }
2112
+ /** A paused session refuses new commands until resumed. */
2113
+ function sessionPaused(sessionId) {
2114
+ return new CapabilityError('The code session is paused; resume it to run commands.', 'CONFLICT', {
2115
+ reason: 'code-session-paused',
2116
+ retryable: false,
2117
+ details: { sessionId }
2118
+ });
2119
+ }
2120
+ class ConsumerCodeSessionImpl extends ConsumerCodeSession {
2121
+ constructor(descriptor, context) {
2122
+ super(AgentCodeSessionDescriptorSchema.parse(descriptor ?? consumerCodeSessionDescriptor), context.runtime);
2123
+ }
2124
+ }
2125
+ export default createRuntimeImplementation({
2126
+ kind: 'agent/code-session',
2127
+ id: consumerCodeSessionDescriptor.id,
2128
+ constraint: { hardware: ['consumer'] },
2129
+ implementation: ConsumerCodeSessionImpl
2130
+ });
2131
+ //# sourceMappingURL=agent-code-session.impl.consumer.js.map