@the-open-engine/zeroshot 6.23.0 → 6.25.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/cli/index.js +37 -2
  2. package/docker/zeroshot-cluster/Dockerfile +7 -0
  3. package/lib/agent-cli-provider/adapters/omp.d.ts.map +1 -1
  4. package/lib/agent-cli-provider/adapters/omp.js +37 -11
  5. package/lib/agent-cli-provider/adapters/omp.js.map +1 -1
  6. package/lib/agent-cli-provider/omp-release.d.ts +3 -0
  7. package/lib/agent-cli-provider/omp-release.d.ts.map +1 -1
  8. package/lib/agent-cli-provider/omp-release.js +20 -1
  9. package/lib/agent-cli-provider/omp-release.js.map +1 -1
  10. package/lib/agent-cli-provider/omp-rpc-driver.d.ts.map +1 -1
  11. package/lib/agent-cli-provider/omp-rpc-driver.js +27 -2
  12. package/lib/agent-cli-provider/omp-rpc-driver.js.map +1 -1
  13. package/lib/agent-cli-provider/omp-rpc-session.js +3 -3
  14. package/lib/agent-cli-provider/omp-rpc-session.js.map +1 -1
  15. package/lib/agent-cli-provider/provider-registry.d.ts +20 -8
  16. package/lib/agent-cli-provider/provider-registry.d.ts.map +1 -1
  17. package/lib/agent-cli-provider/provider-registry.js +56 -9
  18. package/lib/agent-cli-provider/provider-registry.js.map +1 -1
  19. package/lib/agent-cli-provider/types.d.ts +2 -0
  20. package/lib/agent-cli-provider/types.d.ts.map +1 -1
  21. package/lib/agent-cli-provider/types.js.map +1 -1
  22. package/lib/docker-config.js +122 -5
  23. package/package.json +2 -2
  24. package/src/agent/agent-lifecycle.js +78 -3
  25. package/src/agent/agent-task-executor.js +72 -3
  26. package/src/agent/provider-session.js +112 -2
  27. package/src/agent-cli-provider/adapters/omp.ts +41 -11
  28. package/src/agent-cli-provider/omp-release.ts +26 -0
  29. package/src/agent-cli-provider/omp-rpc-driver.ts +31 -3
  30. package/src/agent-cli-provider/omp-rpc-session.ts +3 -3
  31. package/src/agent-cli-provider/provider-registry.ts +102 -20
  32. package/src/agent-cli-provider/types.ts +4 -0
  33. package/src/isolation-manager.js +535 -89
  34. package/src/omp-blob-root.js +110 -0
  35. package/src/omp-config-overlay.js +9 -1
  36. package/src/omp-execution-fingerprint.js +62 -0
  37. package/src/omp-session-limits.js +17 -0
  38. package/src/omp-session-partition.js +297 -0
  39. package/src/omp-session-verifier.js +576 -0
  40. package/src/orchestrator.js +11 -1
  41. package/src/preflight.js +15 -2
  42. package/task-lib/commands/clean.js +23 -0
  43. package/task-lib/commands/resume.js +42 -0
  44. package/task-lib/commands/run.js +65 -0
  45. package/task-lib/omp-session-cleanup.js +160 -0
  46. package/task-lib/omp-session-ownership-schema.js +262 -0
  47. package/task-lib/omp-session-ownership.js +332 -0
  48. package/task-lib/omp-storage-root.js +35 -0
  49. package/task-lib/rpc-watcher.js +332 -2
  50. package/task-lib/runner.js +195 -4
  51. package/task-lib/store.js +42 -7
@@ -17,6 +17,7 @@ const { getNestedExecutionRegistry, TaskExecutionHandle } = require('./task-exec
17
17
  const os = require('os');
18
18
  const { parseProviderChunk, getProvider } = require('../providers');
19
19
  const { getTask, getTaskBySpawnOwnershipToken } = require('../../task-lib/store.js');
20
+ const { OMP_SESSIONLESS_ENV } = require('../../task-lib/omp-storage-root.js');
20
21
  const { loadSettings } = require('../../lib/settings.js');
21
22
  const { getDefaultProviderId } = require('../../lib/provider-names');
22
23
  const { resolveClaudeAuth } = require('../../lib/settings/claude-auth.js');
@@ -46,6 +47,7 @@ const {
46
47
  } = require('../task-spawn-cleanup-ownership');
47
48
  const {
48
49
  providerSessionFromCompletedTask,
50
+ resolveAgentProviderSession,
49
51
  resolveAgentResumeSessionId,
50
52
  validateCompletedResumeIdentity,
51
53
  } = require('./provider-session');
@@ -722,14 +724,48 @@ function buildTaskRunArgs({
722
724
  args.push(mcpArg);
723
725
  }
724
726
 
725
- const resumeSessionId = resolveAgentResumeSessionId(agent, providerName);
726
- if (resumeSessionId) {
727
- args.push('--resume', resumeSessionId);
727
+ if (providerName === 'omp') {
728
+ const ompResumeArg = buildOmpResumeArg(agent, providerName);
729
+ if (ompResumeArg) args.push('--omp-resume', ompResumeArg);
730
+ } else {
731
+ const resumeSessionId = resolveAgentResumeSessionId(agent, providerName);
732
+ if (resumeSessionId) {
733
+ args.push('--resume', resumeSessionId);
734
+ }
728
735
  }
729
736
 
730
737
  return args;
731
738
  }
732
739
 
740
+ /**
741
+ * OMP never accepts a bare `--resume <sessionId>` (see failClosedUnsupportedSessionControl in
742
+ * adapters/omp.ts): the child `zeroshot task run` process needs the *complete* committed tuple so
743
+ * it can re-check it against the prior owner's persisted row and re-verify identity/manifest/
744
+ * fingerprint drift before ever spawning OMP.
745
+ *
746
+ * `priorOwnerTaskId` is what turns this from an assertion into a cross-check: the child resolves
747
+ * that row and requires every field below to match it exactly, so a stale or tampered argv
748
+ * descriptor is a conflict that fails closed instead of a claim that is taken at face value.
749
+ * Nothing here is secret — it is the same non-secret shape as task.ompSessionOwnership.session,
750
+ * and it deliberately carries no storage-root or partition path.
751
+ */
752
+ function buildOmpResumeArg(agent, providerName) {
753
+ const session = resolveAgentProviderSession(agent, providerName);
754
+ const ompSession = session?.ompSession;
755
+ if (!session || !ompSession) return null;
756
+ return JSON.stringify({
757
+ priorOwnerTaskId: session.taskId,
758
+ partitionId: ompSession.partitionId,
759
+ sessionFileName: ompSession.sessionFileName,
760
+ expectedSessionId: session.sessionId,
761
+ expectedSessionFileIdentity: ompSession.sessionFileIdentity,
762
+ expectedArtifactManifestDigest: ompSession.artifactManifestDigest,
763
+ expectedExecutionFingerprint: ompSession.executionFingerprint,
764
+ expectedSelectedProvider: ompSession.selectedProvider,
765
+ expectedSelectedModel: ompSession.selectedModel,
766
+ });
767
+ }
768
+
733
769
  /**
734
770
  * Build the `--mcp-config` args for a task-run invocation, or [] when they don't apply.
735
771
  *
@@ -805,6 +841,7 @@ function buildSpawnEnv(agent, providerName, modelSpec, options = {}) {
805
841
 
806
842
  if (clusterId) {
807
843
  spawnEnv.ZEROSHOT_CLUSTER_ID = clusterId;
844
+ spawnEnv.ZEROSHOT_AGENT_ID = agent.id;
808
845
  const cmdproofRoot = path.join(os.homedir(), '.zeroshot', 'cmdproof', clusterId);
809
846
  if (!spawnEnv.CMDPROOF_CACHE_DIR) {
810
847
  spawnEnv.CMDPROOF_CACHE_DIR = path.join(cmdproofRoot, 'cache');
@@ -812,6 +849,13 @@ function buildSpawnEnv(agent, providerName, modelSpec, options = {}) {
812
849
  if (!spawnEnv.CMDPROOF_KEY_DIR) {
813
850
  spawnEnv.CMDPROOF_KEY_DIR = path.join(cmdproofRoot, 'keys');
814
851
  }
852
+ // OMP session partitions live under the owning cluster's storageDir (never TASKS_DIR — see
853
+ // task-lib/omp-storage-root.js), forwarded to the spawned `zeroshot task run` child since it
854
+ // has no other way to learn which orchestrator instance owns this cluster.
855
+ if (providerName === 'omp') {
856
+ spawnEnv.ZEROSHOT_OMP_STORAGE_ROOT =
857
+ agent.cluster?.storageDir || path.join(os.homedir(), '.zeroshot');
858
+ }
815
859
  }
816
860
 
817
861
  const commandProofs = Array.isArray(agent.config?.commandProofs)
@@ -1456,6 +1500,25 @@ async function buildCompletionResult({
1456
1500
  };
1457
1501
  }
1458
1502
 
1503
+ /**
1504
+ * Rebuild the provider-session snapshot from a *freshly re-read* task row.
1505
+ *
1506
+ * The snapshot inside a completion result is necessarily built while the OMP ownership row is
1507
+ * still `provisional` — the detached watcher records verified materialization evidence but defers
1508
+ * the commit decision to the agent's post-hook success boundary — so for provider `omp` it is
1509
+ * stale by construction and always null. agent-lifecycle.js calls this *after* a checked commit
1510
+ * and before publishing TASK_COMPLETED, which is the only point at which a durable, resumable
1511
+ * `ompSession` exists to snapshot.
1512
+ */
1513
+ function rebuildProviderSessionAfterCommit({ agent, providerName, taskId, logicalSuccess = true }) {
1514
+ return providerSessionFromCompletedTask({
1515
+ agent,
1516
+ providerName,
1517
+ taskInfo: getTask(taskId),
1518
+ logicalSuccess,
1519
+ });
1520
+ }
1521
+
1459
1522
  function finalizeLogFollow(agent, state) {
1460
1523
  if (state.pollInterval) {
1461
1524
  clearInterval(state.pollInterval);
@@ -1886,6 +1949,11 @@ async function spawnClaudeTaskIsolatedExecution(agent, context, options = {}) {
1886
1949
  // only authoritative bridge back to the detached task row in the container.
1887
1950
  const isolatedEnv = {
1888
1951
  ...(providerName === 'claude' ? buildClaudeEnv(modelSpec, { includeAuth: false }) : {}),
1952
+ // Docker is fresh-only for OMP (issue #866): the container filesystem is ephemeral, so a
1953
+ // session partition allocated inside it could never be resumed and its ownership row would be
1954
+ // unreclaimable once the container is removed. This marker makes the in-container
1955
+ // `zeroshot task run` skip partition allocation entirely and launch `--no-session`.
1956
+ ...(providerName === 'omp' ? { [OMP_SESSIONLESS_ENV]: '1' } : {}),
1889
1957
  [TASK_SPAWN_OWNERSHIP_TOKEN_ENV]: ownershipToken,
1890
1958
  };
1891
1959
 
@@ -3003,5 +3071,6 @@ module.exports = {
3003
3071
  parseResultOutput,
3004
3072
  buildCompletionResult,
3005
3073
  buildTaskRunArgs,
3074
+ rebuildProviderSessionAfterCommit,
3006
3075
  killTask,
3007
3076
  };
@@ -3,6 +3,7 @@ const path = require('path');
3
3
 
4
4
  const { normalizeProviderName, providerSupportsCapability } = require('../../lib/provider-names');
5
5
  const { tryCanonicalMessageSequence } = require('../ledger-sequence');
6
+ const { validateOwnedByTask } = require('../../task-lib/omp-session-ownership-schema.js');
6
7
 
7
8
  const DURABLE_SESSION_BOUNDARY_EVENTS = new Set([
8
9
  'TASK_STARTED',
@@ -44,6 +45,77 @@ function promptIdentity(value) {
44
45
  return `sha256:${crypto.createHash('sha256').update(String(value)).digest('hex')}`;
45
46
  }
46
47
 
48
+ const DECIMAL_STRING = /^(0|[1-9][0-9]*)$/;
49
+ const SHA256_DIGEST = /^sha256:[a-f0-9]{64}$/;
50
+ const SESSION_FILE_NAME = /^[^/\\]+\.jsonl$/;
51
+ const { PARTITION_ID_PATTERN } = require('../omp-session-partition');
52
+
53
+ // Exactly the field set issue #866 fixes for this snapshot — no more, no less. The record is
54
+ // closed in both directions so a stale snapshot written by a different Zeroshot version, or one
55
+ // carrying smuggled extra state, is rejected rather than partially trusted.
56
+ const OMP_SESSION_KEYS = new Set([
57
+ 'schemaVersion',
58
+ 'partitionId',
59
+ 'sessionFileName',
60
+ 'sessionFileIdentity',
61
+ 'artifactManifestDigest',
62
+ 'executionFingerprint',
63
+ 'selectedProvider',
64
+ 'selectedModel',
65
+ ]);
66
+ const IDENTITY_KEYS = new Set(['device', 'inode']);
67
+
68
+ function normalizeIdentity(value) {
69
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
70
+ if (!Object.keys(value).every((key) => IDENTITY_KEYS.has(key))) return null;
71
+ if (!DECIMAL_STRING.test(String(value.device)) || !DECIMAL_STRING.test(String(value.inode))) {
72
+ return null;
73
+ }
74
+ return { device: String(value.device), inode: String(value.inode) };
75
+ }
76
+
77
+ /**
78
+ * The optional providerSession.ompSession field (issue #866): required in addition to the
79
+ * generic tuple above for provider 'omp', absent for every other provider. Every digest here is
80
+ * sha256:<64-lower-hex>; every device/inode is a canonical unsigned decimal string. Never carries
81
+ * storage-root or partition paths — those stay in task.ompSessionOwnership, not the agent
82
+ * snapshot; the partition is re-derived from the owner row at resume time.
83
+ */
84
+ function normalizeOmpSession(value) {
85
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
86
+ return null;
87
+ }
88
+ if (!Object.keys(value).every((key) => OMP_SESSION_KEYS.has(key))) return null;
89
+ const sessionFileIdentity = normalizeIdentity(value.sessionFileIdentity);
90
+ if (
91
+ value.schemaVersion !== 1 ||
92
+ !normalizeNonEmptyString(value.partitionId) ||
93
+ !PARTITION_ID_PATTERN.test(value.partitionId) ||
94
+ !normalizeNonEmptyString(value.sessionFileName) ||
95
+ !SESSION_FILE_NAME.test(value.sessionFileName) ||
96
+ value.sessionFileName === '.jsonl' ||
97
+ !sessionFileIdentity ||
98
+ typeof value.artifactManifestDigest !== 'string' ||
99
+ !SHA256_DIGEST.test(value.artifactManifestDigest) ||
100
+ typeof value.executionFingerprint !== 'string' ||
101
+ !SHA256_DIGEST.test(value.executionFingerprint) ||
102
+ !normalizeNonEmptyString(value.selectedProvider) ||
103
+ !normalizeNonEmptyString(value.selectedModel)
104
+ ) {
105
+ return null;
106
+ }
107
+ return {
108
+ schemaVersion: 1,
109
+ partitionId: value.partitionId,
110
+ sessionFileName: value.sessionFileName,
111
+ sessionFileIdentity,
112
+ artifactManifestDigest: value.artifactManifestDigest,
113
+ executionFingerprint: value.executionFingerprint,
114
+ selectedProvider: value.selectedProvider,
115
+ selectedModel: value.selectedModel,
116
+ };
117
+ }
118
+
47
119
  function supportsSessionResume(providerName) {
48
120
  try {
49
121
  return providerSupportsCapability(providerName, 'sessionResume');
@@ -68,6 +140,8 @@ function normalizeProviderSession(value) {
68
140
  const contextSequence = normalizeCursor(value.contextSequence);
69
141
  const guidanceSequence = normalizeNullableCursor(value.guidanceSequence);
70
142
  const normalizedPromptIdentity = normalizePromptIdentity(value.promptIdentity);
143
+ const hasOmpSession = Object.hasOwn(value, 'ompSession');
144
+ const normalizedOmpSession = hasOmpSession ? normalizeOmpSession(value.ompSession) : null;
71
145
 
72
146
  if (
73
147
  !provider ||
@@ -84,7 +158,8 @@ function normalizeProviderSession(value) {
84
158
  (value.guidanceSequence !== null && guidanceSequence === null) ||
85
159
  !Object.hasOwn(value, 'promptIdentity') ||
86
160
  normalizedPromptIdentity === undefined ||
87
- !supportsSessionResume(provider)
161
+ !supportsSessionResume(provider) ||
162
+ (provider === 'omp' ? normalizedOmpSession === null : hasOmpSession)
88
163
  ) {
89
164
  return null;
90
165
  }
@@ -100,6 +175,7 @@ function normalizeProviderSession(value) {
100
175
  contextSequence,
101
176
  guidanceSequence,
102
177
  promptIdentity: normalizedPromptIdentity,
178
+ ...(provider === 'omp' ? { ompSession: normalizedOmpSession } : {}),
103
179
  };
104
180
  }
105
181
 
@@ -169,6 +245,29 @@ function validateCompletedResumeIdentity(taskInfo) {
169
245
  : 'Provider continuation did not confirm the requested session identity';
170
246
  }
171
247
 
248
+ /** taskInfo.ompSessionOwnership is the authoritative, watcher-committed continuation evidence for
249
+ * provider 'omp'; a provisional/cleanup-required/missing record means this task never durably
250
+ * proved a resumable session, regardless of what the generic sessionId/resumeIdentityVerified
251
+ * columns say (rpc-watcher.js never populates those — they're the stdout-parsing watchers' path).
252
+ * The record is re-fenced to this exact task row: an ownership object naming a different owner is
253
+ * not this task's continuation evidence, however well-formed it is. */
254
+ function ompSessionFromCompletedTask(taskInfo) {
255
+ const ownership = validateOwnedByTask(taskInfo?.ompSessionOwnership ?? null, taskInfo?.id);
256
+ if (!ownership || ownership.state !== 'committed' || !ownership.session) {
257
+ return null;
258
+ }
259
+ return {
260
+ schemaVersion: 1,
261
+ partitionId: ownership.partitionId,
262
+ sessionFileName: ownership.session.fileName,
263
+ sessionFileIdentity: ownership.session.fileIdentity,
264
+ artifactManifestDigest: ownership.session.artifactManifestDigest,
265
+ executionFingerprint: ownership.session.executionFingerprint,
266
+ selectedProvider: ownership.session.selectedProvider,
267
+ selectedModel: ownership.session.selectedModel,
268
+ };
269
+ }
270
+
172
271
  function providerSessionFromCompletedTask({
173
272
  agent,
174
273
  providerName,
@@ -192,7 +291,16 @@ function providerSessionFromCompletedTask({
192
291
  return null;
193
292
  }
194
293
 
195
- const sessionId = normalizeNonEmptyString(taskInfo.sessionId);
294
+ const isOmp = provider === 'omp';
295
+ const ompOwnership = isOmp
296
+ ? validateOwnedByTask(taskInfo.ompSessionOwnership ?? null, taskInfo.id)
297
+ : null;
298
+ const ompSession = isOmp ? ompSessionFromCompletedTask(taskInfo) : null;
299
+ // rpc-watcher.js never populates the generic sessionId column; the OMP-observed session ID
300
+ // committed alongside ompSession is the one authoritative identity for this provider.
301
+ const sessionId = isOmp
302
+ ? normalizeNonEmptyString(ompOwnership?.state === 'committed' ? ompOwnership.session?.sessionId : null)
303
+ : normalizeNonEmptyString(taskInfo.sessionId);
196
304
  const taskId = normalizeNonEmptyString(taskInfo.id);
197
305
  const generation = agent?.iteration;
198
306
  const agentId = normalizeNonEmptyString(agent?.id);
@@ -207,6 +315,7 @@ function providerSessionFromCompletedTask({
207
315
  contextSequence: agent?.currentContextSequence,
208
316
  guidanceSequence: agent?.currentGuidanceSequence ?? null,
209
317
  promptIdentity: agent?.currentPromptIdentity ?? null,
318
+ ...(isOmp ? { ompSession } : {}),
210
319
  });
211
320
  }
212
321
 
@@ -264,6 +373,7 @@ function restoreAgentProviderSession({ agent, savedState, messageBus, clusterId
264
373
  }
265
374
 
266
375
  module.exports = {
376
+ agentCanReuseSession,
267
377
  agentWorkspaceProvenance,
268
378
  normalizeProviderSession,
269
379
  promptIdentity,
@@ -4,6 +4,7 @@ import { appendJsonSchemaPrompt } from '../schema';
4
4
  import { isRecord, unknownToMessage } from '../json';
5
5
  import { OMP_REMEDIATION, OMP_SUPPORTED_VERSION } from '../omp-release';
6
6
  import { parseNormalizedOmpRpcEventLine } from '../omp-rpc-events';
7
+ import type { OmpSessionLaunch } from '../omp-rpc-session';
7
8
  import {
8
9
  InvalidProviderModelError,
9
10
  type BuildProviderCommandOptions,
@@ -180,8 +181,13 @@ function detectCliFeatures(helpText?: string | null, versionText?: string | null
180
181
  };
181
182
  }
182
183
 
184
+ function resolveOmpSessionLaunch(options: BuildProviderCommandOptions): OmpSessionLaunch {
185
+ return options.ompSession ?? { kind: 'none' };
186
+ }
187
+
183
188
  function assertRequiredOmpFeatures(options: BuildProviderCommandOptions): void {
184
189
  const features = optionFeatures(options);
190
+ const session = resolveOmpSessionLaunch(options);
185
191
  const required: ReadonlyArray<readonly [boolean | undefined, string]> = [
186
192
  [features.versionMatches, `exact OMP version ${OMP_SUPPORTED_VERSION}`],
187
193
  [features.supportsRpcMode, '"rpc" mode'],
@@ -190,6 +196,10 @@ function assertRequiredOmpFeatures(options: BuildProviderCommandOptions): void {
190
196
  [features.supportsApprovalMode, '--approval-mode'],
191
197
  [features.supportsNoTitle, '--no-title'],
192
198
  [features.supportsNoSession, '--no-session'],
199
+ ...(session.kind === 'none'
200
+ ? []
201
+ : ([[features.supportsSessionDir, '--session-dir']] as const)),
202
+ ...(session.kind === 'resume' ? ([[features.supportsResume, '--resume']] as const) : []),
193
203
  ];
194
204
  const missing = required.filter(([supported]) => supported === false).map(([, label]) => label);
195
205
  if (missing.length === 0) return;
@@ -203,16 +213,35 @@ function assertRequiredOmpFeatures(options: BuildProviderCommandOptions): void {
203
213
  }
204
214
 
205
215
  function failClosedUnsupportedSessionControl(options: BuildProviderCommandOptions): void {
206
- const hasResumeSessionId = options.resumeSessionId !== undefined;
207
- if (!hasResumeSessionId && !options.continueSession) return;
208
- const field = hasResumeSessionId ? 'options.resumeSessionId' : 'options.continueSession';
209
- throw contractError({
210
- code: 'invalid-field',
211
- field,
212
- exitCode: 2,
213
- message:
214
- 'OMP RPC lane runs sessionless (--no-session) in this slice; resume/continue session control is capability-gated off (sessionResume: false).',
215
- });
216
+ if (options.continueSession) {
217
+ throw contractError({
218
+ code: 'invalid-field',
219
+ field: 'options.continueSession',
220
+ exitCode: 2,
221
+ message: 'OMP RPC lane never supports --continue; continuation is always an explicit verified --resume partition.',
222
+ });
223
+ }
224
+ const hasVerifiedResume = resolveOmpSessionLaunch(options).kind === 'resume';
225
+ if (options.resumeSessionId !== undefined && !hasVerifiedResume) {
226
+ throw contractError({
227
+ code: 'invalid-field',
228
+ field: 'options.resumeSessionId',
229
+ exitCode: 2,
230
+ message:
231
+ 'OMP RPC lane requires a verified session partition (options.ompSession.kind === "resume") to resume; a bare session ID cannot be trusted.',
232
+ });
233
+ }
234
+ }
235
+
236
+ function sessionArgs(session: OmpSessionLaunch): readonly string[] {
237
+ switch (session.kind) {
238
+ case 'none':
239
+ return ['--no-session'];
240
+ case 'fresh':
241
+ return ['--session-dir', session.partition.path];
242
+ case 'resume':
243
+ return ['--session-dir', session.partition.path, '--resume', session.file.path];
244
+ }
216
245
  }
217
246
 
218
247
  function rejectMcpConfig(options: BuildProviderCommandOptions): void {
@@ -257,8 +286,9 @@ function buildCommand(_context: string, options: BuildProviderCommandOptions = {
257
286
  const modelSelector = resolveModelSelector(options);
258
287
  const warnings = collectWarnings(options);
259
288
  const overlay = createOmpConfigOverlay();
289
+ const session = resolveOmpSessionLaunch(options);
260
290
 
261
- const args: string[] = ['--mode', 'rpc', '--no-session', '--model', modelSelector];
291
+ const args: string[] = ['--mode', 'rpc', ...sessionArgs(session), '--model', modelSelector];
262
292
  if (options.modelSpec?.reasoningEffort) {
263
293
  args.push('--thinking', options.modelSpec.reasoningEffort);
264
294
  }
@@ -84,3 +84,29 @@ export function findOmpReleaseAsset(platform: string): OmpReleaseAsset | undefin
84
84
  export function ompReleaseAssetDownloadUrl(asset: OmpReleaseAsset): string {
85
85
  return `${OMP_RELEASE_DOWNLOAD_BASE_URL}/${asset.name}`;
86
86
  }
87
+
88
+ // Docker isolation runs the cluster image as linux/amd64 only (see AGENTS.md OMP Docker
89
+ // section); the base image's AWS/Terraform/kubectl/Helm/Infracost/TFLint/tfsec layers are
90
+ // hard-coded x86-64 assets, so this cannot yet claim native arm64.
91
+ export const OMP_DOCKER_PLATFORM = 'linux/amd64' as const;
92
+ export const OMP_DOCKER_RELEASE_PLATFORM = 'linux-x64' as const satisfies OmpReleasePlatform;
93
+
94
+ const OMP_DOCKER_RELEASE_ASSET = findOmpReleaseAsset(OMP_DOCKER_RELEASE_PLATFORM);
95
+ if (!OMP_DOCKER_RELEASE_ASSET) {
96
+ throw new Error(
97
+ `No OMP release asset found for Docker platform "${OMP_DOCKER_RELEASE_PLATFORM}"`
98
+ );
99
+ }
100
+
101
+ const OMP_DOCKER_RELEASE_URL = ompReleaseAssetDownloadUrl(OMP_DOCKER_RELEASE_ASSET);
102
+ const OMP_DOCKER_RELEASE_SHA256 = OMP_DOCKER_RELEASE_ASSET.sha256;
103
+
104
+ // Digest-verified install for the Docker image variant only. Downloads the pinned release asset,
105
+ // verifies its SHA-256 before install, and asserts `omp --version` matches exactly — never
106
+ // `latest`, never trusting the tag URL alone.
107
+ export const OMP_DOCKER_INSTALL_COMMAND: string =
108
+ `set -eu; curl -fsSL --retry 3 -o /tmp/omp "${OMP_DOCKER_RELEASE_URL}"; ` +
109
+ `printf '%s /tmp/omp\\n' '${OMP_DOCKER_RELEASE_SHA256}' | sha256sum -c -; ` +
110
+ `install -m 0755 /tmp/omp /usr/local/bin/omp; rm -f /tmp/omp; ` +
111
+ `v="$(omp --version 2>&1 | head -n1 | tr -dc '0-9.')"; ` +
112
+ `[ "$v" = "${OMP_SUPPORTED_VERSION}" ] || { echo "omp --version mismatch: $v" >&2; exit 1; }`;
@@ -464,6 +464,20 @@ export function runOmpRpcTask(
464
464
  // (agent_end / a delayed prompt_result), not by this function returning.
465
465
  }
466
466
 
467
+ // Present on both the get_state response's `data` and a `session_info_update` event frame per
468
+ // docs/rpc.md; either may carry only a subset, so callers merge this onto prior evidence
469
+ // rather than replacing it wholesale.
470
+ function sessionFieldsFromRecord(
471
+ record: Record<string, unknown>
472
+ ): Partial<Pick<OmpRpcSessionEvidence, 'sessionId' | 'sessionFile'>> {
473
+ const sessionId = getString(record, 'sessionId');
474
+ const sessionFile = getString(record, 'sessionFile');
475
+ return {
476
+ ...(sessionId !== null ? { sessionId } : {}),
477
+ ...(sessionFile !== null ? { sessionFile } : {}),
478
+ };
479
+ }
480
+
467
481
  function sessionEvidenceFromState(
468
482
  stateResponse: Record<string, unknown> | null
469
483
  ): Omit<OmpRpcSessionEvidence, 'phase'> {
@@ -471,15 +485,27 @@ export function runOmpRpcTask(
471
485
  const data = getRecord(stateResponse, 'data');
472
486
  const model = data ? getRecord(data, 'model') : null;
473
487
  return {
474
- sessionId: null,
475
- sessionFile: null,
488
+ ...UNKNOWN_SESSION_EVIDENCE,
489
+ ...(data ? sessionFieldsFromRecord(data) : {}),
476
490
  selectedProvider: (model ? getString(model, 'provider') : null) ?? '',
477
491
  selectedModel: (model ? getString(model, 'id') : null) ?? '',
478
492
  thinkingLevel: (data ? getString(data, 'thinkingLevel') : null) ?? '',
479
493
  };
480
494
  }
481
495
 
482
- function dispatchFrame(frame: OmpRpcInboundFrame): void {
496
+ // session_info_update is a builtin slash-command side channel (docs/rpc.md) that can carry a
497
+ // later-observed sessionId/sessionFile than the initial get_state snapshot. Returning the
498
+ // hooks.onSession() promise (rather than fire-and-forget) lets a persistence failure surface
499
+ // through the same enqueue()/state.chain .catch() -> failPermanently() path as every other
500
+ // dispatch failure, instead of being silently swallowed.
501
+ function handleSessionInfoUpdate(frame: OmpRpcInboundFrame): Promise<void> | void {
502
+ const updates = sessionFieldsFromRecord(frame);
503
+ if (Object.keys(updates).length === 0) return;
504
+ state.sessionEvidence = { ...state.sessionEvidence, ...updates };
505
+ return hooks.onSession({ ...state.sessionEvidence, phase: 'ready' });
506
+ }
507
+
508
+ function dispatchFrame(frame: OmpRpcInboundFrame): void | Promise<void> {
483
509
  if (state.terminal) return; // Frames after the terminal frame are dropped.
484
510
  if (!state.readyReceived) {
485
511
  dispatchReadyFrame(frame);
@@ -513,6 +539,8 @@ export function runOmpRpcTask(
513
539
  case 'agent_end':
514
540
  handleAgentEnd();
515
541
  return;
542
+ case 'session_info_update':
543
+ return handleSessionInfoUpdate(frame);
516
544
  default:
517
545
  if (state.promptSent) emitNormalized(frame);
518
546
  }
@@ -1,6 +1,6 @@
1
- // Session-launch types for the OMP RPC v2 driver. This slice launches sessionless
2
- // (`--no-session`) only; `fresh`/`resume` are typed for Subissue 4's session-flag activation but
3
- // are unreachable from any caller in this slice.
1
+ // Session-launch types for the OMP RPC v2 driver. `none` keeps the Docker-only sessionless launch
2
+ // (`--no-session`); `fresh`/`resume` carry a verified partition (and, for resume, a verified
3
+ // session file) allocated and checked by the JS task-lib layer — never a raw, unverified path.
4
4
 
5
5
  export interface VerifiedOmpPartition {
6
6
  readonly path: string;