@gobing-ai/ts-ai-runner 0.4.18 → 0.4.19

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -291,6 +291,57 @@ const gitBlock = getGitContext('/workspace/spur');
291
291
  // "Git context:\nbranch: feat/team-mode\ndirty: 3 files"
292
292
  ```
293
293
 
294
+ ## Session Affinity (run-scoped sessions)
295
+
296
+ `PromptOptions` carries two additive fields for pinning or isolating sessions:
297
+
298
+ - `sessionId?: string` — resume a specific prior session when the agent supports resume-by-id.
299
+ - `sessionDir?: string` — isolate session storage into a caller-owned directory (durable).
300
+
301
+ When either is set, the shim selects the **run-scoped session path** and **never** emits an unscoped
302
+ global continue/last-session flag. This prevents a pipeline hop from attaching to the host's
303
+ interactive session (ADR-047).
304
+
305
+ **Precedence (R5):**
306
+
307
+ 1. `sessionId` or `sessionDir` set → pin/isolate path; bare `continue` is ignored for global-last.
308
+ 2. else `continue === true` → legacy resume-last (only where the agent allows it).
309
+ 3. else → fresh open.
310
+
311
+ **Capability query (R2):** consult `getAgentSessionCapability(agent)` rather than inventing per-agent
312
+ argv. It reports `supportsResumeById` and `supportsSessionDir`.
313
+
314
+ ```ts
315
+ import { getAgentSessionCapability, getAgentShim } from '@gobing-ai/ts-ai-runner';
316
+
317
+ const cap = getAgentSessionCapability('omp'); // { supportsResumeById: true, supportsSessionDir: true }
318
+ const { args } = getAgentShim('omp').getPromptCommand({
319
+ input: '',
320
+ sessionDir: '.spur/run/r1/agent-sessions/omp',
321
+ sessionId: 'abc123',
322
+ });
323
+ // args: ['-p', '', '--session-dir', '.spur/run/r1/...', '-r', 'abc123', '--mode', 'text']
324
+ ```
325
+
326
+ **Shim / capability matrix:**
327
+
328
+ | Agent | `supportsResumeById` | `supportsSessionDir` | sessionDir flag | sessionId resume flag | Legacy continue |
329
+ | ----- | -------------------- | -------------------- | --------------- | --------------------- | --------------- |
330
+ | omp | ✓ | ✓ | `--session-dir <dir>` | `-r <id>` | `-c` |
331
+ | pi | ✓ | ✓ | `--session-dir <dir>` | `-r <id>` | `-c` |
332
+ | claude | ✓ | ✗ (ignored) | — | `--resume <id>` | `--continue` |
333
+ | codex | ✗ | ✗ (ignored) | — | degrade → fresh `exec` | `exec resume --last` |
334
+ | agy | ✓ | ✗ (ignored) | — | `--conversation <id>` | `--continue` |
335
+ | grok | ✓ | ✗ (ignored) | — | `--resume <id>` | `-c` |
336
+
337
+ **Degrade rule:** when `sessionDir`/`sessionId` is set and the agent lacks resume-by-id or a
338
+ session-store flag, the shim opens **fresh in isolation** (or plain fresh) — never bare global
339
+ continue/last. codex, which has no one-shot resume-by-id, always degrades to a fresh `exec`.
340
+
341
+ **Durable vs ephemeral (R4):** for omp/pi, setting `sessionDir` implies a durable session — the shim
342
+ omits `--no-session` so a session file is written and discoverable. The legacy fresh path (no session
343
+ fields, `continue` false) keeps `--no-session`.
344
+
294
345
  ## Observability
295
346
 
296
347
  `AiRunner` and `TeamOrchestrator` emit typed events when an `EventBus<AgentEvents>` is provided:
@@ -15,6 +15,19 @@ export interface PromptOptions {
15
15
  input?: string;
16
16
  /** Continue the previous session if the agent supports it. */
17
17
  continue?: boolean;
18
+ /**
19
+ * Pin a specific session to resume, when the agent supports resume-by-id
20
+ * (see {@link getAgentSessionCapability}). Setting `sessionId` or `sessionDir`
21
+ * selects the run-scoped session path and suppresses any unscoped global
22
+ * continue/last-session flag (ADR-047 precedence R5).
23
+ */
24
+ sessionId?: string;
25
+ /**
26
+ * Isolate session storage into this directory (where the agent supports it).
27
+ * Implies a durable session: agents with a session-store flag (omp/pi) must not
28
+ * emit `--no-session` when this is set, so the session file is discoverable.
29
+ */
30
+ sessionDir?: string;
18
31
  /** Model identifier passed through to the agent CLI. */
19
32
  model?: string;
20
33
  /** Output mode passed through to the agent CLI. */
@@ -64,6 +77,15 @@ export interface AgentShim {
64
77
  }
65
78
  /** All bundled agent shims keyed by canonical agent name. */
66
79
  export declare const AGENT_SHIMS: Readonly<Record<AgentName, AgentShim>>;
80
+ /** Session-affinity capability for one coding agent (ADR-047). */
81
+ export interface AgentSessionCapability {
82
+ /** Can resume a specific prior session by id (e.g. `-r <id>` / `--resume <id>`). */
83
+ readonly supportsResumeById: boolean;
84
+ /** Can isolate session storage into a caller-supplied directory. */
85
+ readonly supportsSessionDir: boolean;
86
+ }
87
+ /** Query a bundled agent's session-affinity capability by canonical name. */
88
+ export declare function getAgentSessionCapability(agent: AgentName): AgentSessionCapability;
67
89
  /** Tier-1 auto-selection priority. Deprecated ids are excluded. */
68
90
  export declare const TIER1_PRIORITY: readonly AgentName[];
69
91
  /** Display order for doctor and list commands. */
@@ -1 +1 @@
1
- {"version":3,"file":"shims.d.ts","sourceRoot":"","sources":["../../src/agents/shims.ts"],"names":[],"mappings":"AAEA,gEAAgE;AAChE,MAAM,MAAM,SAAS,GACf,QAAQ,GACR,OAAO,GACP,QAAQ,GACR,IAAI,GACJ,UAAU,GACV,iBAAiB,GACjB,UAAU,GACV,QAAQ,GACR,KAAK,GACL,MAAM,CAAC;AAEb,0CAA0C;AAC1C,MAAM,MAAM,UAAU,GAAG,MAAM,GAAG,MAAM,CAAC;AAEzC,mEAAmE;AACnE,MAAM,WAAW,WAAW;IACxB,4BAA4B;IAC5B,OAAO,EAAE,MAAM,CAAC;IAChB,2CAA2C;IAC3C,IAAI,EAAE,MAAM,EAAE,CAAC;CAClB;AAED,4CAA4C;AAC5C,MAAM,WAAW,aAAa;IAC1B,yDAAyD;IACzD,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,8DAA8D;IAC9D,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,wDAAwD;IACxD,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,mDAAmD;IACnD,IAAI,CAAC,EAAE,UAAU,CAAC;IAClB,2DAA2D;IAC3D,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,kCAAkC;IAClC,IAAI,CAAC,EAAE,MAAM,EAAE,CAAC;IAChB,kEAAkE;IAClE,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,iEAAiE;IACjE,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,qDAAqD;IACrD,KAAK,CAAC,EAAE,KAAK,CAAC;QAAE,EAAE,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAC;QAAC,OAAO,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;CACjE;AAED,2EAA2E;AAC3E,MAAM,WAAW,gBAAgB;IAC7B,4DAA4D;IAC5D,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,8CAA8C;IAC9C,QAAQ,CAAC,UAAU,CAAC,EAAE,SAAS,CAAC;CACnC;AAED,qDAAqD;AACrD,MAAM,WAAW,SAAS;IACtB,yCAAyC;IACzC,QAAQ,CAAC,IAAI,EAAE,SAAS,CAAC;IACzB,+BAA+B;IAC/B,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB,oEAAoE;IACpE,QAAQ,CAAC,IAAI,EAAE,CAAC,GAAG,CAAC,CAAC;IACrB,sFAAsF;IACtF,QAAQ,CAAC,OAAO,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;IACrC,wFAAwF;IACxF,QAAQ,CAAC,UAAU,CAAC,EAAE,gBAAgB,CAAC;IACvC,oCAAoC;IACpC,cAAc,IAAI,WAAW,CAAC;IAC9B,yCAAyC;IACzC,iBAAiB,IAAI,WAAW,CAAC;IACjC,yCAAyC;IACzC,gBAAgB,CAAC,OAAO,EAAE,aAAa,GAAG,WAAW,CAAC;IACtD,8DAA8D;IAC9D,cAAc,IAAI,WAAW,GAAG,IAAI,CAAC;CACxC;AAuLD,6DAA6D;AAC7D,eAAO,MAAM,WAAW,EAAE,QAAQ,CAAC,MAAM,CAAC,SAAS,EAAE,SAAS,CAAC,CAW9D,CAAC;AAEF,mEAAmE;AACnE,eAAO,MAAM,cAAc,EAAE,SAAS,SAAS,EAS9C,CAAC;AAEF,kDAAkD;AAClD,eAAO,MAAM,aAAa,EAAE,SAAS,SAAS,EAW7C,CAAC;AAEF,6CAA6C;AAC7C,eAAO,MAAM,YAAY,EAAE,WAAW,CAAC,SAAS,CAAyB,CAAC;AAU1E;;;;;;;;;GASG;AACH,wBAAgB,gBAAgB,CAAC,KAAK,EAAE,MAAM,GAAG,SAAS,GAAG,SAAS,CAQrE;AAED;mEACmE;AACnE,wBAAgB,WAAW,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAElD;AAED,6EAA6E;AAC7E,wBAAgB,YAAY,CAAC,KAAK,EAAE,SAAS,GAAG,SAAS,CAMxD"}
1
+ {"version":3,"file":"shims.d.ts","sourceRoot":"","sources":["../../src/agents/shims.ts"],"names":[],"mappings":"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"}
@@ -7,8 +7,16 @@ const claudeShim = {
7
7
  getVersionCommand: () => ({ command: 'claude', args: ['--version'] }),
8
8
  getPromptCommand: (options) => {
9
9
  const args = ['-p', options.input ?? ''];
10
- if (options.continue === true)
10
+ const hasSession = options.sessionId !== undefined || options.sessionDir !== undefined;
11
+ if (hasSession) {
12
+ // Session/pin path — never emit --continue. Claude has no session-dir
13
+ // flag; sessionDir is ignored (best-effort isolate).
14
+ if (options.sessionId !== undefined)
15
+ args.push('--resume', options.sessionId);
16
+ }
17
+ else if (options.continue === true) {
11
18
  args.push('--continue');
19
+ }
12
20
  if (options.model !== undefined)
13
21
  args.push('--model', options.model);
14
22
  args.push('--output-format', options.mode ?? 'text');
@@ -23,10 +31,13 @@ const codexShim = {
23
31
  getHelpCommand: () => ({ command: 'codex', args: ['--help'] }),
24
32
  getVersionCommand: () => ({ command: 'codex', args: ['--version'] }),
25
33
  getPromptCommand: (options) => {
26
- if (options.continue === true && options.input !== undefined) {
34
+ const hasSession = options.sessionId !== undefined || options.sessionDir !== undefined;
35
+ // Session/pin path degrades to a fresh `exec` — codex has no reliable
36
+ // one-shot resume-by-id, so never `exec resume --last` (no global last).
37
+ if (options.continue === true && !hasSession && options.input !== undefined) {
27
38
  throw new Error('Codex resume mode does not accept a new prompt');
28
39
  }
29
- const args = options.continue === true ? ['exec', 'resume', '--last'] : ['exec', options.input ?? ''];
40
+ const args = options.continue === true && !hasSession ? ['exec', 'resume', '--last'] : ['exec', options.input ?? ''];
30
41
  if (options.model !== undefined)
31
42
  args.push('-m', options.model);
32
43
  if ((options.mode ?? 'text') === 'json')
@@ -61,11 +72,17 @@ const piShim = {
61
72
  getVersionCommand: () => ({ command: 'pi', args: ['--version'] }),
62
73
  getPromptCommand: (options) => {
63
74
  const args = [];
64
- if (options.continue !== true)
75
+ const hasSession = options.sessionId !== undefined || options.sessionDir !== undefined;
76
+ // Session/pin path: durable — never ephemeral --no-session, never global -c.
77
+ if (!hasSession && options.continue !== true)
65
78
  args.push('--no-session');
66
79
  args.push('-p', options.input ?? '');
67
- if (options.continue === true)
80
+ if (!hasSession && options.continue === true)
68
81
  args.push('-c');
82
+ if (options.sessionDir !== undefined)
83
+ args.push('--session-dir', options.sessionDir);
84
+ if (options.sessionId !== undefined)
85
+ args.push('-r', options.sessionId);
69
86
  if (options.model !== undefined)
70
87
  args.push('--model', options.model);
71
88
  args.push('--mode', options.mode ?? 'text');
@@ -105,8 +122,16 @@ const antigravityCliShim = {
105
122
  getVersionCommand: () => ({ command: 'agy', args: ['--version'] }),
106
123
  getPromptCommand: (options) => {
107
124
  const args = ['-p', options.input ?? ''];
108
- if (options.continue === true)
125
+ const hasSession = options.sessionId !== undefined || options.sessionDir !== undefined;
126
+ if (hasSession) {
127
+ // Session/pin path — never emit --continue. agy has no session-dir flag;
128
+ // sessionDir is ignored (best-effort isolate).
129
+ if (options.sessionId !== undefined)
130
+ args.push('--conversation', options.sessionId);
131
+ }
132
+ else if (options.continue === true) {
109
133
  args.push('--continue');
134
+ }
110
135
  if (options.model !== undefined)
111
136
  args.push('--model', options.model);
112
137
  return { command: 'agy', args };
@@ -154,11 +179,17 @@ const ompShim = {
154
179
  getVersionCommand: () => ({ command: 'omp', args: ['--version'] }),
155
180
  getPromptCommand: (options) => {
156
181
  const args = [];
157
- if (options.continue !== true)
182
+ const hasSession = options.sessionId !== undefined || options.sessionDir !== undefined;
183
+ // Session/pin path: durable — never ephemeral --no-session, never global -c.
184
+ if (!hasSession && options.continue !== true)
158
185
  args.push('--no-session');
159
186
  args.push('-p', options.input ?? '');
160
- if (options.continue === true)
187
+ if (!hasSession && options.continue === true)
161
188
  args.push('-c');
189
+ if (options.sessionDir !== undefined)
190
+ args.push('--session-dir', options.sessionDir);
191
+ if (options.sessionId !== undefined)
192
+ args.push('-r', options.sessionId);
162
193
  if (options.model !== undefined)
163
194
  args.push('--model', options.model);
164
195
  args.push('--mode', options.mode ?? 'text');
@@ -180,8 +211,16 @@ const grokShim = {
180
211
  getVersionCommand: () => ({ command: 'grok', args: ['--version'] }),
181
212
  getPromptCommand: (options) => {
182
213
  const args = ['-p', options.input ?? ''];
183
- if (options.continue === true)
214
+ const hasSession = options.sessionId !== undefined || options.sessionDir !== undefined;
215
+ if (hasSession) {
216
+ // Session/pin path — never emit -c. grok has no session-dir flag;
217
+ // sessionDir is ignored (best-effort isolate).
218
+ if (options.sessionId !== undefined)
219
+ args.push('--resume', options.sessionId);
220
+ }
221
+ else if (options.continue === true) {
184
222
  args.push('-c');
223
+ }
185
224
  if (options.model !== undefined)
186
225
  args.push('-m', options.model);
187
226
  // Grok has no `text` format; map ai-runner OutputMode `text` → `plain`.
@@ -204,6 +243,34 @@ export const AGENT_SHIMS = {
204
243
  omp: ompShim,
205
244
  grok: grokShim,
206
245
  };
246
+ /**
247
+ * Session-affinity capability metadata per agent. Callers must consult this
248
+ * instead of inventing per-agent argv: when `sessionDir`/`sessionId` are set,
249
+ * `supportsResumeById` decides resume-by-id vs fresh-degrade, and
250
+ * `supportsSessionDir` decides whether `sessionDir` is honored (ADR-047 R2).
251
+ *
252
+ * Agents not in the six-agent affinity matrix default to no resume-by-id and no
253
+ * session-dir — they get the isolated-fresh / no-resume degrade.
254
+ */
255
+ const AGENT_SESSION_CAPABILITY = {
256
+ omp: { supportsResumeById: true, supportsSessionDir: true },
257
+ pi: { supportsResumeById: true, supportsSessionDir: true },
258
+ claude: { supportsResumeById: true, supportsSessionDir: false },
259
+ // codex resume is interactive-only (`exec resume` picker); no one-shot
260
+ // resume-by-id and no session-dir — degrades to fresh exec.
261
+ codex: { supportsResumeById: false, supportsSessionDir: false },
262
+ 'antigravity-cli': { supportsResumeById: true, supportsSessionDir: false },
263
+ grok: { supportsResumeById: true, supportsSessionDir: false },
264
+ // Non-matrix agents: conservative default (isolated-fresh / no-resume).
265
+ gemini: { supportsResumeById: false, supportsSessionDir: false },
266
+ opencode: { supportsResumeById: false, supportsSessionDir: false },
267
+ openclaw: { supportsResumeById: false, supportsSessionDir: false },
268
+ hermes: { supportsResumeById: false, supportsSessionDir: false },
269
+ };
270
+ /** Query a bundled agent's session-affinity capability by canonical name. */
271
+ export function getAgentSessionCapability(agent) {
272
+ return AGENT_SESSION_CAPABILITY[agent];
273
+ }
207
274
  /** Tier-1 auto-selection priority. Deprecated ids are excluded. */
208
275
  export const TIER1_PRIORITY = [
209
276
  'pi',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gobing-ai/ts-ai-runner",
3
- "version": "0.4.18",
3
+ "version": "0.4.19",
4
4
  "description": "@gobing-ai/ts-ai-runner — Coding-agent shims, detection, doctor checks, and prompt execution.",
5
5
  "keywords": [
6
6
  "typescript",
@@ -47,12 +47,12 @@
47
47
  "release": "echo 'Manual publish is disabled. Releases go through GitHub Actions via Trusted Publishing — push a tag: git tag @gobing-ai/ts-ai-runner-v<version> && git push --tags' && exit 1"
48
48
  },
49
49
  "dependencies": {
50
- "@gobing-ai/ts-infra": "^0.4.18",
51
- "@gobing-ai/ts-runtime": "^0.4.18"
50
+ "@gobing-ai/ts-infra": "^0.4.19",
51
+ "@gobing-ai/ts-runtime": "^0.4.19"
52
52
  },
53
53
  "devDependencies": {
54
54
  "@types/bun": "1.3.14",
55
- "@gobing-ai/ts-db": "^0.4.18"
55
+ "@gobing-ai/ts-db": "^0.4.19"
56
56
  },
57
57
  "publishConfig": {
58
58
  "access": "public"
@@ -30,6 +30,19 @@ export interface PromptOptions {
30
30
  input?: string;
31
31
  /** Continue the previous session if the agent supports it. */
32
32
  continue?: boolean;
33
+ /**
34
+ * Pin a specific session to resume, when the agent supports resume-by-id
35
+ * (see {@link getAgentSessionCapability}). Setting `sessionId` or `sessionDir`
36
+ * selects the run-scoped session path and suppresses any unscoped global
37
+ * continue/last-session flag (ADR-047 precedence R5).
38
+ */
39
+ sessionId?: string;
40
+ /**
41
+ * Isolate session storage into this directory (where the agent supports it).
42
+ * Implies a durable session: agents with a session-store flag (omp/pi) must not
43
+ * emit `--no-session` when this is set, so the session file is discoverable.
44
+ */
45
+ sessionDir?: string;
33
46
  /** Model identifier passed through to the agent CLI. */
34
47
  model?: string;
35
48
  /** Output mode passed through to the agent CLI. */
@@ -84,7 +97,14 @@ const claudeShim: AgentShim = {
84
97
  getVersionCommand: () => ({ command: 'claude', args: ['--version'] }),
85
98
  getPromptCommand: (options) => {
86
99
  const args = ['-p', options.input ?? ''];
87
- if (options.continue === true) args.push('--continue');
100
+ const hasSession = options.sessionId !== undefined || options.sessionDir !== undefined;
101
+ if (hasSession) {
102
+ // Session/pin path — never emit --continue. Claude has no session-dir
103
+ // flag; sessionDir is ignored (best-effort isolate).
104
+ if (options.sessionId !== undefined) args.push('--resume', options.sessionId);
105
+ } else if (options.continue === true) {
106
+ args.push('--continue');
107
+ }
88
108
  if (options.model !== undefined) args.push('--model', options.model);
89
109
  args.push('--output-format', options.mode ?? 'text');
90
110
  return { command: 'claude', args };
@@ -99,10 +119,14 @@ const codexShim: AgentShim = {
99
119
  getHelpCommand: () => ({ command: 'codex', args: ['--help'] }),
100
120
  getVersionCommand: () => ({ command: 'codex', args: ['--version'] }),
101
121
  getPromptCommand: (options) => {
102
- if (options.continue === true && options.input !== undefined) {
122
+ const hasSession = options.sessionId !== undefined || options.sessionDir !== undefined;
123
+ // Session/pin path degrades to a fresh `exec` — codex has no reliable
124
+ // one-shot resume-by-id, so never `exec resume --last` (no global last).
125
+ if (options.continue === true && !hasSession && options.input !== undefined) {
103
126
  throw new Error('Codex resume mode does not accept a new prompt');
104
127
  }
105
- const args = options.continue === true ? ['exec', 'resume', '--last'] : ['exec', options.input ?? ''];
128
+ const args =
129
+ options.continue === true && !hasSession ? ['exec', 'resume', '--last'] : ['exec', options.input ?? ''];
106
130
  if (options.model !== undefined) args.push('-m', options.model);
107
131
  if ((options.mode ?? 'text') === 'json') args.push('--json');
108
132
  return { command: 'codex', args };
@@ -135,9 +159,13 @@ const piShim: AgentShim = {
135
159
  getVersionCommand: () => ({ command: 'pi', args: ['--version'] }),
136
160
  getPromptCommand: (options) => {
137
161
  const args: string[] = [];
138
- if (options.continue !== true) args.push('--no-session');
162
+ const hasSession = options.sessionId !== undefined || options.sessionDir !== undefined;
163
+ // Session/pin path: durable — never ephemeral --no-session, never global -c.
164
+ if (!hasSession && options.continue !== true) args.push('--no-session');
139
165
  args.push('-p', options.input ?? '');
140
- if (options.continue === true) args.push('-c');
166
+ if (!hasSession && options.continue === true) args.push('-c');
167
+ if (options.sessionDir !== undefined) args.push('--session-dir', options.sessionDir);
168
+ if (options.sessionId !== undefined) args.push('-r', options.sessionId);
141
169
  if (options.model !== undefined) args.push('--model', options.model);
142
170
  args.push('--mode', options.mode ?? 'text');
143
171
  return { command: 'pi', args };
@@ -175,7 +203,14 @@ const antigravityCliShim: AgentShim = {
175
203
  getVersionCommand: () => ({ command: 'agy', args: ['--version'] }),
176
204
  getPromptCommand: (options) => {
177
205
  const args = ['-p', options.input ?? ''];
178
- if (options.continue === true) args.push('--continue');
206
+ const hasSession = options.sessionId !== undefined || options.sessionDir !== undefined;
207
+ if (hasSession) {
208
+ // Session/pin path — never emit --continue. agy has no session-dir flag;
209
+ // sessionDir is ignored (best-effort isolate).
210
+ if (options.sessionId !== undefined) args.push('--conversation', options.sessionId);
211
+ } else if (options.continue === true) {
212
+ args.push('--continue');
213
+ }
179
214
  if (options.model !== undefined) args.push('--model', options.model);
180
215
  return { command: 'agy', args };
181
216
  },
@@ -223,9 +258,13 @@ const ompShim: AgentShim = {
223
258
  getVersionCommand: () => ({ command: 'omp', args: ['--version'] }),
224
259
  getPromptCommand: (options) => {
225
260
  const args: string[] = [];
226
- if (options.continue !== true) args.push('--no-session');
261
+ const hasSession = options.sessionId !== undefined || options.sessionDir !== undefined;
262
+ // Session/pin path: durable — never ephemeral --no-session, never global -c.
263
+ if (!hasSession && options.continue !== true) args.push('--no-session');
227
264
  args.push('-p', options.input ?? '');
228
- if (options.continue === true) args.push('-c');
265
+ if (!hasSession && options.continue === true) args.push('-c');
266
+ if (options.sessionDir !== undefined) args.push('--session-dir', options.sessionDir);
267
+ if (options.sessionId !== undefined) args.push('-r', options.sessionId);
229
268
  if (options.model !== undefined) args.push('--model', options.model);
230
269
  args.push('--mode', options.mode ?? 'text');
231
270
  return { command: 'omp', args };
@@ -247,7 +286,14 @@ const grokShim: AgentShim = {
247
286
  getVersionCommand: () => ({ command: 'grok', args: ['--version'] }),
248
287
  getPromptCommand: (options) => {
249
288
  const args = ['-p', options.input ?? ''];
250
- if (options.continue === true) args.push('-c');
289
+ const hasSession = options.sessionId !== undefined || options.sessionDir !== undefined;
290
+ if (hasSession) {
291
+ // Session/pin path — never emit -c. grok has no session-dir flag;
292
+ // sessionDir is ignored (best-effort isolate).
293
+ if (options.sessionId !== undefined) args.push('--resume', options.sessionId);
294
+ } else if (options.continue === true) {
295
+ args.push('-c');
296
+ }
251
297
  if (options.model !== undefined) args.push('-m', options.model);
252
298
  // Grok has no `text` format; map ai-runner OutputMode `text` → `plain`.
253
299
  const format = (options.mode ?? 'text') === 'json' ? 'json' : 'plain';
@@ -271,6 +317,44 @@ export const AGENT_SHIMS: Readonly<Record<AgentName, AgentShim>> = {
271
317
  grok: grokShim,
272
318
  };
273
319
 
320
+ /** Session-affinity capability for one coding agent (ADR-047). */
321
+ export interface AgentSessionCapability {
322
+ /** Can resume a specific prior session by id (e.g. `-r <id>` / `--resume <id>`). */
323
+ readonly supportsResumeById: boolean;
324
+ /** Can isolate session storage into a caller-supplied directory. */
325
+ readonly supportsSessionDir: boolean;
326
+ }
327
+
328
+ /**
329
+ * Session-affinity capability metadata per agent. Callers must consult this
330
+ * instead of inventing per-agent argv: when `sessionDir`/`sessionId` are set,
331
+ * `supportsResumeById` decides resume-by-id vs fresh-degrade, and
332
+ * `supportsSessionDir` decides whether `sessionDir` is honored (ADR-047 R2).
333
+ *
334
+ * Agents not in the six-agent affinity matrix default to no resume-by-id and no
335
+ * session-dir — they get the isolated-fresh / no-resume degrade.
336
+ */
337
+ const AGENT_SESSION_CAPABILITY: Readonly<Record<AgentName, AgentSessionCapability>> = {
338
+ omp: { supportsResumeById: true, supportsSessionDir: true },
339
+ pi: { supportsResumeById: true, supportsSessionDir: true },
340
+ claude: { supportsResumeById: true, supportsSessionDir: false },
341
+ // codex resume is interactive-only (`exec resume` picker); no one-shot
342
+ // resume-by-id and no session-dir — degrades to fresh exec.
343
+ codex: { supportsResumeById: false, supportsSessionDir: false },
344
+ 'antigravity-cli': { supportsResumeById: true, supportsSessionDir: false },
345
+ grok: { supportsResumeById: true, supportsSessionDir: false },
346
+ // Non-matrix agents: conservative default (isolated-fresh / no-resume).
347
+ gemini: { supportsResumeById: false, supportsSessionDir: false },
348
+ opencode: { supportsResumeById: false, supportsSessionDir: false },
349
+ openclaw: { supportsResumeById: false, supportsSessionDir: false },
350
+ hermes: { supportsResumeById: false, supportsSessionDir: false },
351
+ };
352
+
353
+ /** Query a bundled agent's session-affinity capability by canonical name. */
354
+ export function getAgentSessionCapability(agent: AgentName): AgentSessionCapability {
355
+ return AGENT_SESSION_CAPABILITY[agent];
356
+ }
357
+
274
358
  /** Tier-1 auto-selection priority. Deprecated ids are excluded. */
275
359
  export const TIER1_PRIORITY: readonly AgentName[] = [
276
360
  'pi',