awb-agent-manager 1.6.199 → 1.6.201

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.
@@ -1,12 +1,37 @@
1
- import { access, constants as fsConstants } from 'node:fs/promises';
1
+ import { access, constants as fsConstants, lstat, mkdir, symlink } from 'node:fs/promises';
2
2
  import { randomUUID } from 'node:crypto';
3
- import { delimiter, dirname, join } from 'node:path';
4
- import { createAdapter } from './cli-adapters/index.js';
3
+ import { delimiter, join } from 'node:path';
4
+ import { AgentSessionStore, resolveClaudeHome, resolveCodexHome } from './agent-session-store.js';
5
+ import { AGENT_MANAGER_HOME } from './constants.js';
6
+ import { normalizeCredentialFields } from './credential-fields.js';
5
7
  import { log } from './logging.js';
6
8
  import { terminateDetachedProcessTree } from './process-tree.js';
7
- import { patchAgentSession, postAgentSessionEvents, } from './rest.js';
9
+ import { fetchSessionCredential, postAgentSessionEvents, postAgentSessionRpcResponse, } from './rest.js';
10
+ import { createRuntimeCliAdapter } from './runtime/runtime-registry.js';
8
11
  import { AcpClient } from './runtime/acp/acp-client.js';
9
12
  import { resolveHermesAcpCommand } from './runtime/hermes/hermes-command.js';
13
+ export const SESSION_CLI_CREDENTIAL_PREFIX = {
14
+ claude: 'claude_',
15
+ codex: 'codex_',
16
+ };
17
+ const SESSION_REQUIRED_CREDENTIAL_FIELDS = {
18
+ claude_subscription: ['credentials_json'],
19
+ claude_api_key: ['api_key'],
20
+ claude_oauth_token: ['oauth_token'],
21
+ codex_subscription: ['auth_json'],
22
+ codex_api_key: ['api_key'],
23
+ };
24
+ export function redactSecrets(text) {
25
+ return String(text ?? '')
26
+ .replace(/Bearer\s+[^\s"'`]+/gi, 'Bearer <redacted>')
27
+ .replace(/sk-ant-[A-Za-z0-9_-]{8,}/g, 'sk-ant-<redacted>')
28
+ .replace(/sk-[A-Za-z0-9_-]{16,}/g, 'sk-<redacted>')
29
+ .replace(/(api[_-]?key|oauth[_-]?token|access[_-]?token|refresh[_-]?token)(["']?\s*[:=]\s*["']?)[^\s"',}]+/gi, '$1$2<redacted>');
30
+ }
31
+ const SESSION_STORE_SUBDIR = {
32
+ claude: 'projects',
33
+ codex: 'sessions',
34
+ };
10
35
  const DEFAULT_IDLE_MINUTES = 30;
11
36
  const DEFAULT_PERMISSION_TIMEOUT_MS = 15 * 60_000;
12
37
  const DEFAULT_REQUEST_TIMEOUT_MS = 60_000;
@@ -15,6 +40,7 @@ const DEFAULT_FLUSH_INTERVAL_MS = 150;
15
40
  const MAX_TOOL_TEXT_CHARS = 16_000;
16
41
  const MAX_PAYLOAD_CHARS = 200_000;
17
42
  const ALLOW_KINDS = new Set(['allow_once', 'allow_always', 'allow_session']);
43
+ export const ACP_SESSION_CLIS = ['claude', 'codex', 'hermes'];
18
44
  function truncateText(value, max) {
19
45
  return value.length > max ? `${value.slice(0, max)}\n…[truncated ${value.length - max} chars]` : value;
20
46
  }
@@ -42,7 +68,7 @@ function boundedPayload(payload) {
42
68
  return bounded;
43
69
  return { truncated: true, preview: serialized.slice(0, 4_000) };
44
70
  }
45
- async function findOnPath(name) {
71
+ export async function findOnPath(name) {
46
72
  const candidates = process.platform === 'win32' ? [`${name}.cmd`, `${name}.exe`, name] : [name];
47
73
  for (const dir of (process.env.PATH || '').split(delimiter)) {
48
74
  if (!dir)
@@ -63,45 +89,48 @@ function parseCommandLine(line) {
63
89
  const parts = line.trim().split(/\s+/).filter(Boolean);
64
90
  return { command: parts[0] || '', args: parts.slice(1) };
65
91
  }
66
- export async function resolveAcpCommandForRuntime(runtime, ctx) {
67
- const extra = (ctx.runtime_config?.extra ?? {});
68
- if (typeof extra.acp_command === 'string' && extra.acp_command.trim()) {
69
- const args = Array.isArray(extra.acp_args) ? extra.acp_args.map((a) => String(a)) : [];
70
- return { command: extra.acp_command.trim(), args };
71
- }
72
- const envOverride = process.env[`AWB_ACP_COMMAND_${runtime.toUpperCase()}`]?.trim();
92
+ export async function resolveAcpCommandForCli(cli) {
93
+ const envOverride = process.env[`AWB_ACP_COMMAND_${cli.toUpperCase()}`]?.trim();
73
94
  if (envOverride)
74
95
  return parseCommandLine(envOverride);
75
- switch (runtime) {
76
- case 'claude':
77
- case 'deepseek': {
96
+ switch (cli) {
97
+ case 'claude': {
78
98
  const found = await findOnPath('claude-agent-acp');
79
- return found
80
- ? { command: found, args: [] }
81
- : { command: 'npx', args: ['--yes', '@agentclientprotocol/claude-agent-acp'] };
99
+ return found ? { command: found, args: [] } : { command: 'npx', args: ['--yes', '@agentclientprotocol/claude-agent-acp'] };
82
100
  }
83
101
  case 'codex': {
84
102
  const found = await findOnPath('codex-acp');
85
- return found
86
- ? { command: found, args: [] }
87
- : { command: 'npx', args: ['--yes', '@zed-industries/codex-acp'] };
103
+ return found ? { command: found, args: [] } : { command: 'npx', args: ['--yes', '@zed-industries/codex-acp'] };
88
104
  }
89
105
  case 'hermes': {
90
106
  const resolved = await resolveHermesAcpCommand();
91
107
  return { command: resolved.command, args: [...resolved.argsPrefix] };
92
108
  }
93
109
  default:
94
- throw new Error(`No ACP adapter is known for runtime "${runtime}". Set runtime_config.extra.acp_command on the agent.`);
110
+ throw new Error(`No ACP adapter is known for CLI "${cli}".`);
95
111
  }
96
112
  }
113
+ export async function detectAcpSessionClis(env = process.env) {
114
+ const out = [];
115
+ if (env.AWB_ACP_COMMAND_CLAUDE || await findOnPath('claude-agent-acp') || await findOnPath('claude'))
116
+ out.push('claude');
117
+ if (env.AWB_ACP_COMMAND_CODEX || await findOnPath('codex-acp') || await findOnPath('codex'))
118
+ out.push('codex');
119
+ if (env.AWB_ACP_COMMAND_HERMES || env.HERMES_ACP_COMMAND || await findOnPath('hermes-acp') || await findOnPath('hermes'))
120
+ out.push('hermes');
121
+ return out;
122
+ }
97
123
  export class AgentSessionRunner {
98
124
  #config;
99
125
  #options;
126
+ #store;
100
127
  #live = new Map();
101
128
  #opening = new Map();
102
- constructor(config, options = {}) {
129
+ constructor(config, options) {
103
130
  this.#config = config;
131
+ this.#store = options.store ?? new AgentSessionStore();
104
132
  this.#options = {
133
+ getManagerId: options.getManagerId,
105
134
  idleMinutes: options.idleMinutes ?? DEFAULT_IDLE_MINUTES,
106
135
  permissionTimeoutMs: options.permissionTimeoutMs ?? DEFAULT_PERMISSION_TIMEOUT_MS,
107
136
  requestTimeoutMs: options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS,
@@ -110,13 +139,18 @@ export class AgentSessionRunner {
110
139
  commandResolver: options.commandResolver,
111
140
  baseEnv: options.baseEnv,
112
141
  clientVersion: options.clientVersion,
142
+ mcpServers: options.mcpServers,
143
+ sessionHomesDir: options.sessionHomesDir ?? join(AGENT_MANAGER_HOME, 'session-homes'),
144
+ credentialFetcher: options.credentialFetcher,
113
145
  };
114
146
  }
147
+ get store() {
148
+ return this.#store;
149
+ }
115
150
  _snapshot() {
116
151
  return Array.from(this.#live.values()).map((live) => ({
152
+ cli: live.cli,
117
153
  session_id: live.sessionId,
118
- agent_id: live.agentId,
119
- runtime: live.runtime,
120
154
  busy: live.turn !== null || live.pendingPermissions.size > 0,
121
155
  pid: live.client.process.pid ?? null,
122
156
  }));
@@ -124,194 +158,247 @@ export class AgentSessionRunner {
124
158
  countInFlight() {
125
159
  return this._snapshot().filter((s) => s.busy).length;
126
160
  }
127
- async handle(request, ctx) {
128
- const sid = request.session_id;
129
- if (!sid || !request.agent_id)
130
- return;
131
- const tag = `[agent-session ${sid.slice(0, 8)}]`;
132
- if (!ctx) {
133
- if (request.op === 'open' || request.op === 'prompt' || request.op === 'set_mode') {
134
- log(`${tag} ${request.op}: agent ${request.agent_id.slice(0, 8)} has no bootstrapped context on this manager`);
135
- await patchAgentSession(this.#config, sid, request.agent_id, {
136
- status: 'error',
137
- last_error: 'This agent is registered on the Runtime Host but its runtime context is not bootstrapped (missing api key / working_dir). Spawn or restart the agent from the AI Agents page, then prompt again.',
138
- reason: 'agent_context_missing',
139
- });
140
- }
161
+ #ref(cli, sessionId) {
162
+ return { manager_id: this.#options.getManagerId(), cli, session_id: sessionId };
163
+ }
164
+ #key(cli, sessionId) {
165
+ return `${cli}:${sessionId}`;
166
+ }
167
+ async handle(request) {
168
+ const cli = String(request.cli || '').toLowerCase();
169
+ const sessionId = request.session_id || '';
170
+ const tag = `[agent-session ${cli}${sessionId ? ` ${sessionId.slice(0, 8)}` : ''}]`;
171
+ if (request.request_id) {
172
+ await this.#handleRpc(request, cli, sessionId, tag);
141
173
  return;
142
174
  }
143
- const rest = { ...this.#config, apiKey: ctx.api_key, retryApiKey: this.#config.apiKey };
144
- const liveBefore = this.#live.get(sid);
175
+ const liveBefore = sessionId ? this.#live.get(this.#key(cli, sessionId)) : undefined;
145
176
  try {
146
177
  switch (request.op) {
147
- case 'open':
148
- await this.#ensureLive(request, ctx, rest);
149
- return;
150
178
  case 'prompt': {
151
- const live = await this.#ensureLive(request, ctx, rest);
179
+ if (!sessionId)
180
+ return;
181
+ const live = await this.#ensureLive(cli, sessionId, request.cwd || '', request.title || '', request);
152
182
  await this.#runPrompt(live, request.turn_id || randomUUID(), request.text || '');
153
183
  return;
154
184
  }
155
185
  case 'permission':
156
- this.#resolvePermission(sid, request.request_id || '', request.option_id ?? null);
186
+ this.#resolvePermission(cli, sessionId, request.permission_request_id || '', request.option_id ?? null);
157
187
  return;
158
188
  case 'cancel': {
159
- const live = this.#live.get(sid);
160
- if (!live)
161
- return;
162
- await live.client.cancel(live.nativeSessionId).catch(() => undefined);
189
+ const live = this.#live.get(this.#key(cli, sessionId));
190
+ if (live)
191
+ await live.client.cancel(live.sessionId).catch(() => undefined);
163
192
  return;
164
193
  }
165
194
  case 'set_mode': {
166
- const live = await this.#ensureLive(request, ctx, rest);
195
+ const live = this.#live.get(this.#key(cli, sessionId));
167
196
  const modeId = request.mode_id || '';
168
- if (!modeId)
197
+ if (!live || !modeId)
169
198
  return;
170
- await live.client.request('session/set_mode', { sessionId: live.nativeSessionId, modeId }, { timeoutMs: this.#options.requestTimeoutMs });
199
+ await live.client.request('session/set_mode', { sessionId: live.sessionId, modeId }, { timeoutMs: this.#options.requestTimeoutMs });
171
200
  this.#enqueue(live, [{ type: 'system', payload: { text: `Mode set to ${modeId}.` } }], { current_mode: modeId, reason: 'mode' });
172
201
  return;
173
202
  }
174
203
  case 'close':
175
- await this.#closeLive(sid, 'closed');
204
+ await this.#closeLive(cli, sessionId, 'closed');
176
205
  return;
177
206
  default:
178
207
  log(`${tag} unknown op ${String(request.op)}`);
179
208
  }
180
- const settled = this.#live.get(sid) ?? liveBefore;
209
+ const settled = this.#live.get(this.#key(cli, sessionId)) ?? liveBefore;
181
210
  if (settled)
182
211
  await settled.postChain;
183
212
  }
184
213
  catch (err) {
185
- const message = err?.message ?? String(err);
214
+ const message = redactSecrets(err?.message ?? String(err));
186
215
  log(`${tag} ${request.op} failed: ${message}`);
187
- const live = this.#live.get(sid);
216
+ const live = this.#live.get(this.#key(cli, sessionId));
217
+ const failure = { type: 'error', payload: { message, code: err?.code ?? undefined }, turn_id: request.turn_id };
218
+ const state = { status: live?.turn ? 'busy' : 'error', last_error: message, reason: `${request.op}_failed` };
188
219
  if (live) {
189
- this.#enqueue(live, [{ type: 'error', payload: { message, code: err?.code ?? undefined }, turn_id: request.turn_id }], {
190
- status: live.turn ? 'busy' : 'error',
191
- last_error: message,
192
- reason: `${request.op}_failed`,
193
- });
220
+ this.#enqueue(live, [failure], state);
221
+ await live.postChain;
194
222
  }
195
- else {
196
- await postAgentSessionEvents(rest, sid, ctx.agent_id, [{ type: 'error', payload: { message, code: err?.code ?? undefined }, turn_id: request.turn_id }], {
197
- status: 'error',
198
- last_error: message,
199
- reason: `${request.op}_failed`,
200
- });
223
+ else if (sessionId) {
224
+ await postAgentSessionEvents(this.#config, this.#ref(cli, sessionId), [{ ...failure, seq: 0, id: `${sessionId}:err:${Date.now()}`, created_at: new Date().toISOString() }], state);
201
225
  }
202
226
  }
203
227
  }
228
+ async #handleRpc(request, cli, sessionId, tag) {
229
+ const requestId = request.request_id;
230
+ const managerId = this.#options.getManagerId();
231
+ try {
232
+ switch (request.op) {
233
+ case 'list': {
234
+ const sessions = await this.#store.listSessions(cli);
235
+ const withLive = sessions.map((s) => ({ ...s, live_status: this.#live.get(this.#key(cli, s.session_id)) ? this.#statusOf(this.#live.get(this.#key(cli, s.session_id))) : undefined }));
236
+ await postAgentSessionRpcResponse(this.#config, managerId, requestId, { ok: true, result: { sessions: withLive } });
237
+ return;
238
+ }
239
+ case 'history': {
240
+ if (!sessionId)
241
+ throw Object.assign(new Error('session_id is required'), { code: 'not_found' });
242
+ const history = await this.#store.readHistory(cli, sessionId);
243
+ const live = this.#live.get(this.#key(cli, sessionId));
244
+ if (!history.session && !live) {
245
+ await postAgentSessionRpcResponse(this.#config, managerId, requestId, { ok: false, error: 'Session not found on this Runtime Host.', code: 'not_found' });
246
+ return;
247
+ }
248
+ await postAgentSessionRpcResponse(this.#config, managerId, requestId, {
249
+ ok: true,
250
+ result: {
251
+ session: history.session ?? (live ? this.#summaryOf(live) : null),
252
+ events: history.events,
253
+ truncated: history.truncated,
254
+ live: live ? this.#stateOf(live) : null,
255
+ },
256
+ });
257
+ return;
258
+ }
259
+ case 'open': {
260
+ const live = await this.#ensureLive(cli, sessionId, request.cwd || '', request.title || '', request);
261
+ await postAgentSessionRpcResponse(this.#config, managerId, requestId, { ok: true, result: this.#stateOf(live) });
262
+ return;
263
+ }
264
+ default:
265
+ await postAgentSessionRpcResponse(this.#config, managerId, requestId, { ok: false, error: `Unknown RPC op ${String(request.op)}`, code: 'bad_request' });
266
+ }
267
+ }
268
+ catch (err) {
269
+ const message = redactSecrets(err?.message ?? String(err));
270
+ log(`${tag} ${request.op} rpc failed: ${message}`);
271
+ await postAgentSessionRpcResponse(this.#config, managerId, requestId, { ok: false, error: message, code: err?.code ?? 'manager_error' });
272
+ }
273
+ }
204
274
  async stopAll(reason = 'manager_shutdown') {
205
- const ids = Array.from(this.#live.keys());
206
- await Promise.all(ids.map((id) => this.#closeLive(id, 'suspended', reason).catch(() => undefined)));
207
- }
208
- async #ensureLive(request, ctx, rest) {
209
- const sid = request.session_id;
210
- const existing = this.#live.get(sid);
211
- if (existing && !existing.exited && !existing.closing)
212
- return existing;
213
- const inFlight = this.#opening.get(sid);
214
- if (inFlight)
215
- return inFlight;
216
- const opening = this.#open(request, ctx, rest).finally(() => this.#opening.delete(sid));
217
- this.#opening.set(sid, opening);
275
+ const keys = Array.from(this.#live.values()).map((l) => [l.cli, l.sessionId]);
276
+ await Promise.all(keys.map(([cli, id]) => this.#closeLive(cli, id, 'idle', reason).catch(() => undefined)));
277
+ }
278
+ async #ensureLive(cli, sessionId, cwd, title, request) {
279
+ if (sessionId) {
280
+ const existing = this.#live.get(this.#key(cli, sessionId));
281
+ if (existing && !existing.exited && !existing.closing) {
282
+ const wanted = request.credential_id || '';
283
+ if (existing.credentialId === wanted || existing.turn)
284
+ return existing;
285
+ log(`[agent-session ${cli} ${sessionId.slice(0, 8)}] credential binding changed (${existing.credentialId || 'operator-login'} → ${wanted || 'operator-login'}); reopening`);
286
+ await this.#closeLive(cli, sessionId, 'idle', 'credential_changed');
287
+ }
288
+ const inFlight = this.#opening.get(this.#key(cli, sessionId));
289
+ if (inFlight)
290
+ return inFlight;
291
+ }
292
+ const opening = this.#open(cli, sessionId, cwd, title, request);
293
+ if (sessionId) {
294
+ this.#opening.set(this.#key(cli, sessionId), opening);
295
+ opening.finally(() => this.#opening.delete(this.#key(cli, sessionId))).catch(() => undefined);
296
+ }
218
297
  return opening;
219
298
  }
220
- async #open(request, ctx, rest) {
221
- const sid = request.session_id;
222
- const tag = `[agent-session ${sid.slice(0, 8)}]`;
223
- const runtime = (request.runtime || ctx.cli || 'claude').toLowerCase();
224
- const cwd = (request.cwd || ctx.cwd || '').trim();
299
+ async #open(cli, requestedSessionId, requestedCwd, title, request) {
300
+ const tag = `[agent-session ${cli}${requestedSessionId ? ` ${requestedSessionId.slice(0, 8)}` : ' new'}]`;
301
+ let cwd = requestedCwd.trim();
302
+ if (requestedSessionId && !cwd) {
303
+ const history = await this.#store.readHistory(cli, requestedSessionId).catch(() => null);
304
+ cwd = history?.session?.cwd || '';
305
+ }
225
306
  if (!cwd)
226
- throw new Error('No working directory: set cwd on the session or working_dir on the agent.');
307
+ throw new Error('No working directory: pass cwd for a new session.');
227
308
  try {
228
309
  await access(cwd, fsConstants.R_OK);
229
310
  }
230
311
  catch {
231
312
  throw new Error(`Working directory does not exist on this Runtime Host: ${cwd}`);
232
313
  }
233
- const resolver = this.#options.commandResolver ?? resolveAcpCommandForRuntime;
234
- const { command, args } = await resolver(runtime, ctx);
235
- const env = this.#buildEnv(runtime, ctx, sid);
236
- log(`${tag} spawning ACP adapter runtime=${runtime} cmd=${command} ${args.join(' ')} cwd=${cwd}`);
314
+ const resolver = this.#options.commandResolver ?? resolveAcpCommandForCli;
315
+ const { command, args } = await resolver(cli);
316
+ const auth = await this.#prepareAuth(cli, cwd, request);
317
+ log(`${tag} spawning ACP adapter cmd=${command} ${args.join(' ')} cwd=${cwd} auth=${auth.label}`);
318
+ let live = null;
237
319
  const client = await AcpClient.spawn({
238
320
  command,
239
321
  args,
240
322
  cwd,
241
- env,
323
+ env: this.#buildEnv(cli, requestedSessionId || 'new', auth),
242
324
  requestTimeoutMs: this.#options.requestTimeoutMs,
243
- onEvent: (event) => this.#onEvent(sid, event),
244
- onPermissionRequest: (permission) => this.#onPermission(sid, permission),
245
- onStderr: (line) => log(`${tag} stderr: ${line}`),
325
+ onEvent: (event) => { if (live)
326
+ this.#onEvent(live, event); },
327
+ onPermissionRequest: (permission) => (live ? this.#onPermission(live, permission) : Promise.resolve({ outcome: 'cancelled' })),
328
+ onStderr: (line) => log(`${tag} stderr: ${redactSecrets(line)}`),
246
329
  spawnOptions: { detached: process.platform !== 'win32' },
247
330
  });
248
- const live = {
249
- sessionId: sid,
250
- agentId: ctx.agent_id,
251
- workspaceId: request.workspace_id,
252
- runtime,
253
- cwd,
254
- client,
255
- nativeSessionId: '',
256
- loadSupported: false,
257
- permissionPolicy: request.permission_policy || 'ask',
258
- rest,
259
- pendingPermissions: new Map(),
260
- turn: null,
261
- textBuffer: '',
262
- reasoningBuffer: '',
263
- flushTimer: null,
264
- idleTimer: null,
265
- postChain: Promise.resolve(),
266
- closing: false,
267
- exited: false,
268
- };
269
- this.#live.set(sid, live);
270
- client.process.once('exit', (code, signal) => this.#onProcessExit(sid, code, signal));
271
331
  try {
272
332
  const initialized = await client.initialize({
273
333
  clientInfo: { name: 'awb-agent-session', version: this.#options.clientVersion || '1' },
274
334
  clientCapabilities: { fs: { readTextFile: false, writeTextFile: false }, terminal: false },
275
335
  });
276
336
  const caps = (initialized?.agentCapabilities ?? {});
277
- live.loadSupported = caps.loadSession === true;
278
- const mcpServers = this.#mcpServers(ctx, sid);
337
+ const loadSupported = caps.loadSession === true;
338
+ const sessionIdForMcp = requestedSessionId || 'new';
339
+ const mcpServers = this.#options.mcpServers ? this.#options.mcpServers(sessionIdForMcp) : this.#defaultMcpServers(sessionIdForMcp);
340
+ live = {
341
+ cli,
342
+ sessionId: requestedSessionId,
343
+ cwd,
344
+ title,
345
+ client,
346
+ loadSupported,
347
+ loading: false,
348
+ pendingPermissions: new Map(),
349
+ turn: null,
350
+ textBuffer: '',
351
+ reasoningBuffer: '',
352
+ flushTimer: null,
353
+ idleTimer: null,
354
+ postChain: Promise.resolve(),
355
+ seq: 0,
356
+ nonce: randomUUID().slice(0, 8),
357
+ credentialId: request.credential_id || '',
358
+ closing: false,
359
+ exited: false,
360
+ };
361
+ let modes;
279
362
  let resumed = false;
280
- let modes = undefined;
281
- if (request.native_session_id && live.loadSupported) {
363
+ if (requestedSessionId) {
364
+ if (!loadSupported) {
365
+ throw Object.assign(new Error(`The ${cli} ACP adapter cannot resume existing sessions (no loadSession capability).`), { code: 'resume_unsupported' });
366
+ }
367
+ live.loading = true;
282
368
  try {
283
- const loaded = await client.loadSession({ sessionId: request.native_session_id, cwd, mcpServers });
284
- live.nativeSessionId = request.native_session_id;
369
+ const loaded = await client.loadSession({ sessionId: requestedSessionId, cwd, mcpServers });
285
370
  modes = loaded?.modes;
286
371
  resumed = true;
287
372
  }
288
- catch (err) {
289
- log(`${tag} session/load failed (${err?.message ?? err}); opening a fresh session`);
373
+ finally {
374
+ live.loading = false;
290
375
  }
291
376
  }
292
- if (!resumed) {
377
+ else {
293
378
  const created = await client.newSession({ cwd, mcpServers });
294
- live.nativeSessionId = created.sessionId;
379
+ live.sessionId = created.sessionId;
295
380
  modes = created.modes;
381
+ await this.#store.recordAwbSession({ cli, session_id: live.sessionId, cwd, title }).catch(() => undefined);
296
382
  }
383
+ if (!live.sessionId)
384
+ throw new Error('ACP adapter returned no session id.');
385
+ const key = this.#key(cli, live.sessionId);
386
+ this.#live.set(key, live);
387
+ client.process.once('exit', (code, signal) => this.#onProcessExit(key, code, signal));
297
388
  const modeInfo = this.#parseModes(modes);
298
389
  this.#enqueue(live, [{
299
390
  type: 'system',
300
391
  payload: {
301
- text: resumed
302
- ? `Session resumed (${runtime}, ${cwd}).`
303
- : `Session opened (${runtime}, ${cwd}).`,
304
- runtime,
305
- cwd,
306
- resumed,
307
- command: `${command} ${args.join(' ')}`.trim(),
392
+ text: resumed ? `Session resumed (${cli}, ${cwd}).` : `Session opened (${cli}, ${cwd}).`,
393
+ cli, cwd, resumed, command: `${command} ${args.join(' ')}`.trim(),
308
394
  },
309
395
  }], {
310
396
  status: 'ready',
311
- native_session_id: live.nativeSessionId,
312
- resume_supported: live.loadSupported,
397
+ cwd,
398
+ ...(title ? { title } : {}),
313
399
  current_mode: modeInfo.current,
314
400
  available_modes: modeInfo.available,
401
+ resume_supported: loadSupported,
315
402
  last_error: null,
316
403
  reason: resumed ? 'resumed' : 'opened',
317
404
  });
@@ -319,43 +406,86 @@ export class AgentSessionRunner {
319
406
  return live;
320
407
  }
321
408
  catch (err) {
322
- this.#live.delete(sid);
323
- await this.#killClient(live).catch(() => undefined);
409
+ const child = client.process;
410
+ client.close();
411
+ if (child?.pid)
412
+ await terminateDetachedProcessTree(child.pid, 250, { child }).catch(() => undefined);
324
413
  throw err;
325
414
  }
326
415
  }
327
- #buildEnv(runtime, ctx, sessionId) {
416
+ #buildEnv(cli, sessionId, auth) {
328
417
  const env = { ...(this.#options.baseEnv ?? process.env) };
329
- if (runtime === 'hermes') {
330
- if (ctx.cli_home_dir)
331
- env.HERMES_HOME = join(dirname(ctx.cli_home_dir), 'hermes');
332
- }
333
- else if (ctx.cli_home_dir) {
334
- try {
335
- const adapter = createAdapter(runtime === 'deepseek' ? 'deepseek' : runtime);
336
- const configDirEnv = adapter.configDirEnv();
337
- if (configDirEnv)
338
- env[configDirEnv] = ctx.cli_home_dir;
339
- }
340
- catch {
341
- }
342
- }
343
- Object.assign(env, ctx.extra_env ?? {});
344
- env.AWB_AGENT_ID = ctx.agent_id;
345
- env.AWB_SESSION_ID = sessionId;
346
- env.AWB_API_KEY = ctx.api_key;
418
+ for (const key of auth.stripEnvKeys)
419
+ delete env[key];
420
+ Object.assign(env, auth.env);
347
421
  env.AWB_URL = this.#config.url;
422
+ env.AWB_MANAGER_ID = this.#options.getManagerId();
423
+ env.AWB_SESSION_CLI = cli;
424
+ env.AWB_SESSION_ID = sessionId;
348
425
  return env;
349
426
  }
350
- #mcpServers(ctx, sessionId) {
427
+ async #prepareAuth(cli, cwd, request) {
428
+ const none = { label: 'operator-login', env: {}, stripEnvKeys: [], cliHome: null };
429
+ const credentialId = request.credential_id || '';
430
+ if (!credentialId)
431
+ return none;
432
+ const prefix = SESSION_CLI_CREDENTIAL_PREFIX[cli];
433
+ if (!prefix)
434
+ throw Object.assign(new Error(`${cli} sessions cannot use an AWB credential.`), { code: 'credential_unsupported' });
435
+ const fetcher = this.#options.credentialFetcher
436
+ ?? ((id, ws) => fetchSessionCredential(this.#config, this.#options.getManagerId(), id, ws));
437
+ const fetched = await fetcher(credentialId, request.workspace_id || '');
438
+ if (!fetched)
439
+ throw Object.assign(new Error('The credential assigned in CLI settings could not be fetched from AWB.'), { code: 'credential_unavailable' });
440
+ if (!fetched.provider.startsWith(prefix)) {
441
+ throw Object.assign(new Error(`CLI settings credential provider ${fetched.provider} does not match ${cli}.`), { code: 'credential_provider_mismatch' });
442
+ }
443
+ const { fields, repaired } = normalizeCredentialFields(fetched.fields);
444
+ if (repaired.length)
445
+ log(`[agent-session ${cli}] credential ${fetched.credential_id.slice(0, 8)} whitespace repaired in: ${repaired.join(', ')}`);
446
+ const missing = (SESSION_REQUIRED_CREDENTIAL_FIELDS[fetched.provider] ?? []).filter((key) => !fields[key]);
447
+ if (missing.length) {
448
+ throw Object.assign(new Error(`CLI settings credential ${fetched.provider} is missing ${missing.join(', ')} — re-save it in Settings → Credentials.`), { code: 'credential_incomplete' });
449
+ }
450
+ const credential = { ...fetched, fields };
451
+ const adapter = createRuntimeCliAdapter(cli);
452
+ const cliHome = join(this.#options.sessionHomesDir, cli, credential.credential_id);
453
+ await mkdir(cliHome, { recursive: true, mode: 0o700 });
454
+ await this.#linkSessionStore(cli, cliHome);
455
+ const prep = await adapter.prepareCliHome(cliHome, credential, null);
456
+ const env = { ...(prep.extraEnv ?? {}) };
457
+ const configDirEnv = adapter.configDirEnv();
458
+ if (configDirEnv)
459
+ env[configDirEnv] = cliHome;
460
+ const stripEnvKeys = adapter.authEnvKeys().filter((key) => !(key in env));
461
+ await adapter.ensureWorkspaceTrust(cliHome, cwd).catch((err) => log(`[agent-session ${cli}] trust seed failed: ${err?.message ?? err}`));
462
+ return { label: `credential:${credential.provider}`, env, stripEnvKeys, cliHome };
463
+ }
464
+ async #linkSessionStore(cli, cliHome) {
465
+ const subdir = SESSION_STORE_SUBDIR[cli];
466
+ if (!subdir)
467
+ return;
468
+ const operatorHome = cli === 'claude' ? resolveClaudeHome(this.#options.baseEnv ?? process.env) : resolveCodexHome(this.#options.baseEnv ?? process.env);
469
+ const target = join(operatorHome, subdir);
470
+ const linkPath = join(cliHome, subdir);
471
+ await mkdir(target, { recursive: true });
472
+ try {
473
+ const existing = await lstat(linkPath);
474
+ if (existing.isSymbolicLink() || existing.isDirectory())
475
+ return;
476
+ }
477
+ catch {
478
+ }
479
+ await symlink(target, linkPath, process.platform === 'win32' ? 'junction' : 'dir');
480
+ }
481
+ #defaultMcpServers(sessionId) {
351
482
  return [{
352
483
  type: 'http',
353
484
  name: 'awb',
354
485
  url: `${this.#config.url.replace(/\/$/, '')}/mcp`,
355
486
  headers: [
356
- { name: 'Authorization', value: `Bearer ${ctx.api_key}` },
487
+ { name: 'Authorization', value: `Bearer ${this.#config.apiKey}` },
357
488
  { name: 'X-AWB-Client-Type', value: 'agent-session' },
358
- { name: 'X-AWB-Agent-Id', value: ctx.agent_id },
359
489
  { name: 'X-AWB-Session-Id', value: sessionId },
360
490
  ],
361
491
  }];
@@ -376,6 +506,28 @@ export class AgentSessionRunner {
376
506
  }));
377
507
  return { current, available };
378
508
  }
509
+ #statusOf(live) {
510
+ if (live.exited || live.closing)
511
+ return 'idle';
512
+ if (live.pendingPermissions.size > 0)
513
+ return 'awaiting_permission';
514
+ if (live.turn)
515
+ return 'busy';
516
+ return 'ready';
517
+ }
518
+ #summaryOf(live) {
519
+ const now = new Date().toISOString();
520
+ return { cli: live.cli, session_id: live.sessionId, cwd: live.cwd, title: live.title, created_at: now, updated_at: now, source: 'awb' };
521
+ }
522
+ #stateOf(live) {
523
+ return {
524
+ session_id: live.sessionId,
525
+ cwd: live.cwd,
526
+ title: live.title,
527
+ status: this.#statusOf(live),
528
+ resume_supported: live.loadSupported,
529
+ };
530
+ }
379
531
  async #runPrompt(live, turnId, text) {
380
532
  if (live.turn) {
381
533
  this.#enqueue(live, [{ type: 'error', payload: { message: 'A turn is already in progress.', code: 'turn_in_progress' }, turn_id: turnId }]);
@@ -383,9 +535,13 @@ export class AgentSessionRunner {
383
535
  }
384
536
  live.turn = { turnId, startedAt: Date.now() };
385
537
  this.#clearIdle(live);
386
- this.#enqueue(live, [{ type: 'turn', payload: { phase: 'started' }, turn_id: turnId }], { status: 'busy', reason: 'turn_started' });
538
+ if (!live.title) {
539
+ live.title = text.trim().replace(/\s+/g, ' ').slice(0, 80);
540
+ await this.#store.touchAwbSession(live.cli, live.sessionId, { title: live.title }).catch(() => undefined);
541
+ }
542
+ this.#enqueue(live, [{ type: 'turn', payload: { phase: 'started' }, turn_id: turnId }], { status: 'busy', title: live.title, reason: 'turn_started' });
387
543
  try {
388
- const response = await live.client.prompt({ sessionId: live.nativeSessionId, prompt: [{ type: 'text', text }] }, { timeoutMs: this.#options.promptTimeoutMs });
544
+ const response = await live.client.prompt({ sessionId: live.sessionId, prompt: [{ type: 'text', text }] }, { timeoutMs: this.#options.promptTimeoutMs });
389
545
  this.#flushBuffers(live, turnId);
390
546
  const events = [];
391
547
  if (response?.usage) {
@@ -403,23 +559,24 @@ export class AgentSessionRunner {
403
559
  }
404
560
  events.push({ type: 'turn', payload: { phase: 'finished', stop_reason: response?.stopReason || 'end_turn' }, turn_id: turnId });
405
561
  this.#enqueue(live, events, { status: 'ready', last_error: null, reason: 'turn_finished' });
562
+ await this.#store.touchAwbSession(live.cli, live.sessionId).catch(() => undefined);
406
563
  }
407
564
  catch (err) {
408
565
  this.#flushBuffers(live, turnId);
409
- const message = err?.message ?? String(err);
566
+ const message = redactSecrets(err?.message ?? String(err));
410
567
  this.#enqueue(live, [
411
568
  { type: 'error', payload: { message, code: err?.code ?? undefined }, turn_id: turnId },
412
569
  { type: 'turn', payload: { phase: 'finished', stop_reason: 'error' }, turn_id: turnId },
413
- ], { status: live.exited ? 'suspended' : 'error', last_error: message, reason: 'turn_failed' });
570
+ ], { status: live.exited ? 'idle' : 'error', last_error: message, reason: 'turn_failed' });
414
571
  }
415
572
  finally {
416
573
  live.turn = null;
417
574
  this.#touch(live);
575
+ await live.postChain;
418
576
  }
419
577
  }
420
- #onEvent(sessionId, event) {
421
- const live = this.#live.get(sessionId);
422
- if (!live)
578
+ #onEvent(live, event) {
579
+ if (live.loading)
423
580
  return;
424
581
  const turnId = live.turn?.turnId;
425
582
  switch (event.type) {
@@ -443,7 +600,7 @@ export class AgentSessionRunner {
443
600
  return;
444
601
  }
445
602
  case 'tool_updated':
446
- case 'tool_completed': {
603
+ case 'tool_completed':
447
604
  this.#flushBuffers(live, turnId);
448
605
  this.#enqueue(live, [{
449
606
  type: 'tool_update',
@@ -451,22 +608,14 @@ export class AgentSessionRunner {
451
608
  turn_id: turnId,
452
609
  }]);
453
610
  return;
454
- }
455
611
  case 'child_finished':
456
612
  this.#flushBuffers(live, turnId);
457
- this.#enqueue(live, [{
458
- type: 'tool_update',
459
- payload: { tool_call_id: event.childRunId, status: event.status, output: boundedValue(event.output) },
460
- turn_id: turnId,
461
- }]);
613
+ this.#enqueue(live, [{ type: 'tool_update', payload: { tool_call_id: event.childRunId, status: event.status, output: boundedValue(event.output) }, turn_id: turnId }]);
462
614
  return;
463
615
  case 'usage':
464
616
  this.#enqueue(live, [{
465
617
  type: 'usage',
466
- payload: {
467
- input_tokens: event.inputTokens, output_tokens: event.outputTokens, total_tokens: event.totalTokens,
468
- cached_read_tokens: event.cachedReadTokens, thought_tokens: event.thoughtTokens,
469
- },
618
+ payload: { input_tokens: event.inputTokens, output_tokens: event.outputTokens, total_tokens: event.totalTokens, cached_read_tokens: event.cachedReadTokens, thought_tokens: event.thoughtTokens },
470
619
  turn_id: turnId,
471
620
  }]);
472
621
  return;
@@ -479,43 +628,32 @@ export class AgentSessionRunner {
479
628
  this.#enqueue(live, [], { current_mode: modeId, reason: 'mode' });
480
629
  return;
481
630
  }
482
- log(`[agent-session ${sessionId.slice(0, 8)}] diagnostic ${event.method}${kind ? ` (${kind})` : ''}`);
631
+ log(`[agent-session ${live.cli} ${live.sessionId.slice(0, 8)}] diagnostic ${event.method}${kind ? ` (${kind})` : ''}`);
483
632
  return;
484
633
  }
485
634
  default:
486
635
  return;
487
636
  }
488
637
  }
489
- async #onPermission(sessionId, permission) {
490
- const live = this.#live.get(sessionId);
491
- if (!live)
638
+ async #onPermission(live, permission) {
639
+ if (live.loading)
492
640
  return { outcome: 'cancelled' };
493
641
  const turnId = live.turn?.turnId;
494
642
  this.#flushBuffers(live, turnId);
495
643
  const requestId = randomUUID();
496
644
  const options = (permission.options ?? []).map((o) => ({ option_id: o.optionId, name: o.name, kind: o.kind }));
497
- const requestEvent = {
498
- type: 'permission_request',
499
- payload: {
500
- request_id: requestId,
501
- tool_call_id: permission.toolCall?.toolCallId ?? '',
502
- title: permission.toolCall?.title ?? '',
503
- kind: permission.toolCall?.kind ?? '',
504
- options,
505
- raw_input: boundedValue(permission.toolCall?.rawInput ?? permission.toolCall?.raw_input),
506
- },
507
- turn_id: turnId,
508
- };
509
- if (live.permissionPolicy === 'auto_allow') {
510
- const pick = (permission.options ?? []).find((o) => ALLOW_KINDS.has(o.kind)) ?? permission.options?.[0];
511
- this.#enqueue(live, [requestEvent, {
512
- type: 'permission_decision',
513
- payload: { request_id: requestId, outcome: pick ? 'selected' : 'cancelled', option_id: pick?.optionId ?? null, decided_by: 'policy' },
514
- turn_id: turnId,
515
- }]);
516
- return pick ? { outcome: 'selected', optionId: pick.optionId } : { outcome: 'cancelled' };
517
- }
518
- this.#enqueue(live, [requestEvent], { status: 'awaiting_permission', reason: 'permission' });
645
+ this.#enqueue(live, [{
646
+ type: 'permission_request',
647
+ payload: {
648
+ request_id: requestId,
649
+ tool_call_id: permission.toolCall?.toolCallId ?? '',
650
+ title: permission.toolCall?.title ?? '',
651
+ kind: permission.toolCall?.kind ?? '',
652
+ options,
653
+ raw_input: boundedValue(permission.toolCall?.rawInput ?? permission.toolCall?.raw_input),
654
+ },
655
+ turn_id: turnId,
656
+ }], { status: 'awaiting_permission', reason: 'permission' });
519
657
  return new Promise((resolve) => {
520
658
  const timer = setTimeout(() => {
521
659
  live.pendingPermissions.delete(requestId);
@@ -530,17 +668,23 @@ export class AgentSessionRunner {
530
668
  live.pendingPermissions.set(requestId, { resolve, timer });
531
669
  });
532
670
  }
533
- #resolvePermission(sessionId, requestId, optionId) {
534
- const live = this.#live.get(sessionId);
671
+ #resolvePermission(cli, sessionId, requestId, optionId) {
672
+ const live = this.#live.get(this.#key(cli, sessionId));
535
673
  if (!live)
536
674
  return;
537
675
  const pending = live.pendingPermissions.get(requestId);
538
676
  if (!pending) {
539
- log(`[agent-session ${sessionId.slice(0, 8)}] permission ${requestId.slice(0, 8)} not pending (late or duplicate decision)`);
677
+ log(`[agent-session ${cli} ${sessionId.slice(0, 8)}] permission ${requestId.slice(0, 8)} not pending (late or duplicate decision)`);
540
678
  return;
541
679
  }
542
680
  clearTimeout(pending.timer);
543
681
  live.pendingPermissions.delete(requestId);
682
+ const allowed = !!optionId && (ALLOW_KINDS.size > 0);
683
+ this.#enqueue(live, [{
684
+ type: 'permission_decision',
685
+ payload: { request_id: requestId, outcome: optionId ? 'selected' : 'cancelled', option_id: optionId, decided_by: 'user' },
686
+ turn_id: live.turn?.turnId,
687
+ }], { status: 'busy', reason: allowed ? 'permission_allowed' : 'permission_denied' });
544
688
  pending.resolve(optionId ? { outcome: 'selected', optionId } : { outcome: 'cancelled' });
545
689
  }
546
690
  #scheduleFlush(live) {
@@ -568,10 +712,17 @@ export class AgentSessionRunner {
568
712
  if (events.length)
569
713
  this.#enqueue(live, events);
570
714
  }
571
- #enqueue(live, events, patch) {
572
- const bounded = events.map((e) => ({ ...e, payload: boundedPayload(e.payload) }));
715
+ #enqueue(live, events, state) {
716
+ const now = new Date().toISOString();
717
+ const stamped = events.map((e) => {
718
+ live.seq += 1;
719
+ return { ...e, payload: boundedPayload(e.payload), seq: live.seq, id: `${live.sessionId}:live:${live.nonce}:${live.seq}`, created_at: now };
720
+ });
721
+ const ref = this.#ref(live.cli, live.sessionId);
573
722
  live.postChain = live.postChain
574
- .then(() => postAgentSessionEvents(live.rest, live.sessionId, live.agentId, bounded, patch ?? null))
723
+ .then(() => (stamped.length || state
724
+ ? postAgentSessionEvents(this.#config, ref, stamped, state ?? null)
725
+ : Promise.resolve({ ok: true, status: 204 })))
575
726
  .then(() => undefined, () => undefined);
576
727
  }
577
728
  #touch(live) {
@@ -586,7 +737,7 @@ export class AgentSessionRunner {
586
737
  this.#touch(live);
587
738
  return;
588
739
  }
589
- void this.#closeLive(live.sessionId, 'suspended', 'idle');
740
+ void this.#closeLive(live.cli, live.sessionId, 'idle', 'idle');
590
741
  }, ms);
591
742
  live.idleTimer.unref?.();
592
743
  }
@@ -596,8 +747,8 @@ export class AgentSessionRunner {
596
747
  live.idleTimer = null;
597
748
  }
598
749
  }
599
- #onProcessExit(sessionId, code, signal) {
600
- const live = this.#live.get(sessionId);
750
+ #onProcessExit(key, code, signal) {
751
+ const live = this.#live.get(key);
601
752
  if (!live)
602
753
  return;
603
754
  live.exited = true;
@@ -609,19 +760,17 @@ export class AgentSessionRunner {
609
760
  this.#clearIdle(live);
610
761
  if (live.closing)
611
762
  return;
612
- this.#live.delete(sessionId);
763
+ this.#live.delete(key);
613
764
  const detail = `Agent process exited (${signal ? `signal ${signal}` : `code ${code ?? 'unknown'}`}).`;
614
- log(`[agent-session ${sessionId.slice(0, 8)}] ${detail}`);
765
+ log(`[agent-session ${live.cli} ${live.sessionId.slice(0, 8)}] ${detail}`);
615
766
  this.#flushBuffers(live, live.turn?.turnId);
616
- this.#enqueue(live, [{ type: 'system', payload: { text: `${detail} The next prompt reopens the session.` } }], live.turn ? undefined : { status: 'suspended', reason: 'process_exit' });
767
+ this.#enqueue(live, [{ type: 'system', payload: { text: `${detail} The next prompt reopens the session.` } }], live.turn ? undefined : { status: 'idle', reason: 'process_exit' });
617
768
  }
618
- async #closeLive(sessionId, finalStatus, reason = finalStatus) {
619
- const live = this.#live.get(sessionId);
620
- if (!live) {
621
- if (finalStatus === 'closed')
622
- return;
769
+ async #closeLive(cli, sessionId, finalStatus, reason = finalStatus) {
770
+ const key = this.#key(cli, sessionId);
771
+ const live = this.#live.get(key);
772
+ if (!live)
623
773
  return;
624
- }
625
774
  live.closing = true;
626
775
  this.#clearIdle(live);
627
776
  for (const [id, pending] of live.pendingPermissions) {
@@ -630,29 +779,25 @@ export class AgentSessionRunner {
630
779
  live.pendingPermissions.delete(id);
631
780
  }
632
781
  if (live.turn)
633
- await live.client.cancel(live.nativeSessionId).catch(() => undefined);
782
+ await live.client.cancel(live.sessionId).catch(() => undefined);
634
783
  this.#flushBuffers(live, live.turn?.turnId);
635
- if (!live.exited && live.nativeSessionId) {
636
- await live.client.closeSession(live.nativeSessionId).catch(() => undefined);
637
- }
638
- this.#live.delete(sessionId);
639
- await this.#killClient(live);
640
- const text = finalStatus === 'closed'
641
- ? 'Agent process stopped.'
642
- : reason === 'idle'
643
- ? `Idle for ${this.#options.idleMinutes} min — agent process stopped. The next prompt reopens the session.`
644
- : `Agent process stopped (${reason}). The next prompt reopens the session.`;
645
- this.#enqueue(live, [{ type: 'system', payload: { text } }], finalStatus === 'closed'
646
- ? { reason: 'process_stopped' }
647
- : { status: 'suspended', reason });
648
- await live.postChain;
649
- }
650
- async #killClient(live) {
784
+ if (!live.exited)
785
+ await live.client.closeSession(live.sessionId).catch(() => undefined);
786
+ this.#live.delete(key);
651
787
  const child = live.client.process;
652
788
  const pid = child?.pid;
653
789
  live.client.close();
654
790
  if (pid && child)
655
791
  await terminateDetachedProcessTree(pid, 250, { child }).catch(() => undefined);
792
+ const text = finalStatus === 'closed'
793
+ ? 'Agent process stopped.'
794
+ : reason === 'idle'
795
+ ? `Idle for ${this.#options.idleMinutes} min — agent process stopped. The next prompt reopens the session.`
796
+ : reason === 'credential_changed'
797
+ ? 'CLI settings changed — reopening the session with the new credential.'
798
+ : `Agent process stopped (${reason}). The next prompt reopens the session.`;
799
+ this.#enqueue(live, [{ type: 'system', payload: { text } }], { status: finalStatus, reason });
800
+ await live.postChain;
656
801
  }
657
802
  }
658
803
  //# sourceMappingURL=agent-session-runner.js.map