devchain-cli 0.12.0 → 0.12.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/dist/drizzle/0060_huge_proemial_gods.sql +12 -0
  2. package/dist/drizzle/0061_easy_silver_surfer.sql +2 -0
  3. package/dist/drizzle/meta/0060_snapshot.json +5170 -0
  4. package/dist/drizzle/meta/0061_snapshot.json +5163 -0
  5. package/dist/drizzle/meta/_journal.json +16 -2
  6. package/dist/server/modules/events/catalog/claude.hooks.session.started.d.ts +2 -2
  7. package/dist/server/modules/events/catalog/index.d.ts +20 -2
  8. package/dist/server/modules/events/catalog/index.js +5 -1
  9. package/dist/server/modules/events/catalog/index.js.map +1 -1
  10. package/dist/server/modules/events/catalog/session.restored.d.ts +21 -0
  11. package/dist/server/modules/events/catalog/session.restored.js +14 -0
  12. package/dist/server/modules/events/catalog/session.restored.js.map +1 -0
  13. package/dist/server/modules/hooks/dtos/hook-event.dto.d.ts +2 -2
  14. package/dist/server/modules/providers/adapters/claude.adapter.d.ts +4 -1
  15. package/dist/server/modules/providers/adapters/claude.adapter.js +6 -0
  16. package/dist/server/modules/providers/adapters/claude.adapter.js.map +1 -1
  17. package/dist/server/modules/providers/adapters/codex.adapter.d.ts +4 -1
  18. package/dist/server/modules/providers/adapters/codex.adapter.js +6 -0
  19. package/dist/server/modules/providers/adapters/codex.adapter.js.map +1 -1
  20. package/dist/server/modules/providers/adapters/gemini.adapter.d.ts +4 -1
  21. package/dist/server/modules/providers/adapters/gemini.adapter.js +6 -0
  22. package/dist/server/modules/providers/adapters/gemini.adapter.js.map +1 -1
  23. package/dist/server/modules/providers/adapters/opencode.adapter.d.ts +4 -1
  24. package/dist/server/modules/providers/adapters/opencode.adapter.js +6 -0
  25. package/dist/server/modules/providers/adapters/opencode.adapter.js.map +1 -1
  26. package/dist/server/modules/providers/adapters/provider-adapter.interface.d.ts +8 -0
  27. package/dist/server/modules/reviews/dtos/review.dto.d.ts +2 -2
  28. package/dist/server/modules/session-reader/data/pricing.json +20 -0
  29. package/dist/server/modules/session-reader/services/transcript-persistence.listener.js +3 -3
  30. package/dist/server/modules/session-reader/services/transcript-persistence.listener.js.map +1 -1
  31. package/dist/server/modules/sessions/controllers/sessions.controller.d.ts +3 -1
  32. package/dist/server/modules/sessions/controllers/sessions.controller.js +57 -0
  33. package/dist/server/modules/sessions/controllers/sessions.controller.js.map +1 -1
  34. package/dist/server/modules/sessions/dtos/sessions.dto.d.ts +25 -1
  35. package/dist/server/modules/sessions/dtos/sessions.dto.js +4 -1
  36. package/dist/server/modules/sessions/dtos/sessions.dto.js.map +1 -1
  37. package/dist/server/modules/sessions/services/sessions.service.d.ts +35 -1
  38. package/dist/server/modules/sessions/services/sessions.service.js +471 -118
  39. package/dist/server/modules/sessions/services/sessions.service.js.map +1 -1
  40. package/dist/server/modules/sessions/utils/env-builder.d.ts +1 -1
  41. package/dist/server/modules/sessions/utils/env-builder.js +3 -3
  42. package/dist/server/modules/sessions/utils/env-builder.js.map +1 -1
  43. package/dist/server/modules/storage/db/schema.d.ts +38 -2
  44. package/dist/server/modules/storage/db/schema.js +4 -1
  45. package/dist/server/modules/storage/db/schema.js.map +1 -1
  46. package/dist/server/modules/subscribers/events/event-fields-catalog.js +12 -0
  47. package/dist/server/modules/subscribers/events/event-fields-catalog.js.map +1 -1
  48. package/dist/server/modules/terminal/gateways/terminal.gateway.d.ts +6 -0
  49. package/dist/server/modules/terminal/gateways/terminal.gateway.js +17 -0
  50. package/dist/server/modules/terminal/gateways/terminal.gateway.js.map +1 -1
  51. package/dist/server/templates/5-agents-dev.json +4 -4
  52. package/dist/server/templates/teams-dev.json +17 -20
  53. package/dist/server/tsconfig.tsbuildinfo +1 -1
  54. package/dist/server/ui/assets/{ReviewDetailPage-CEMxN6RQ.js → ReviewDetailPage-D0vzq3rI.js} +1 -1
  55. package/dist/server/ui/assets/{ReviewsPage-BzMksnGd.js → ReviewsPage-aX6m8vW_.js} +1 -1
  56. package/dist/server/ui/assets/index-BzphIngp.css +32 -0
  57. package/dist/server/ui/assets/{index-Csagg3g2.js → index-Cut7Gl_8.js} +218 -218
  58. package/dist/server/ui/assets/{useReviewSubscription-B2ejeWE4.js → useReviewSubscription-CrgRqMXe.js} +1 -1
  59. package/dist/server/ui/index.html +2 -2
  60. package/dist/templates/5-agents-dev.json +4 -4
  61. package/dist/templates/teams-dev.json +17 -20
  62. package/package.json +8 -1
  63. package/dist/server/ui/assets/index-ChJ1IUMI.css +0 -32
@@ -14,6 +14,7 @@ var __param = (this && this.__param) || function (paramIndex, decorator) {
14
14
  Object.defineProperty(exports, "__esModule", { value: true });
15
15
  exports.SessionsService = void 0;
16
16
  const common_1 = require("@nestjs/common");
17
+ const promises_1 = require("fs/promises");
17
18
  const error_types_1 = require("../../../common/errors/error-types");
18
19
  const confirmed_delivery_helper_1 = require("../../terminal/services/confirmed-delivery.helper");
19
20
  const core_1 = require("@nestjs/core");
@@ -67,6 +68,127 @@ let SessionsService = class SessionsService {
67
68
  this.sqlite = this.db.session?.client ?? this.db;
68
69
  logger.info('SessionsService initialized');
69
70
  }
71
+ async resolveLaunchTarget(params) {
72
+ const { agentId, projectId, epicId } = params;
73
+ const agent = await this.storage.getAgent(agentId);
74
+ const project = await this.storage.getProject(projectId);
75
+ if (agent.projectId !== projectId) {
76
+ throw new error_types_1.ValidationError(`Agent ${agentId} does not belong to project ${projectId}.`, {
77
+ agentId,
78
+ agentProjectId: agent.projectId,
79
+ requestedProjectId: projectId,
80
+ });
81
+ }
82
+ const epic = epicId ? await this.storage.getEpic(epicId) : null;
83
+ const profile = await this.storage.getAgentProfile(agent.profileId);
84
+ let provider;
85
+ let options;
86
+ let configEnv = null;
87
+ if (agent.providerConfigId) {
88
+ const config = await this.storage.getProfileProviderConfig(agent.providerConfigId);
89
+ provider = await this.storage.getProvider(config.providerId);
90
+ options = config.options;
91
+ configEnv = config.env;
92
+ logger.info({ agentId, configId: config.id, providerId: provider.id }, 'Resolved provider via config');
93
+ }
94
+ else {
95
+ const configs = await this.storage.listProfileProviderConfigsByProfile(profile.id);
96
+ if (configs.length > 0) {
97
+ const firstConfig = configs[0];
98
+ provider = await this.storage.getProvider(firstConfig.providerId);
99
+ options = firstConfig.options;
100
+ configEnv = firstConfig.env;
101
+ logger.info({ agentId, profileId: profile.id, configId: firstConfig.id, providerId: provider.id }, 'Resolved provider via first profile config (no providerConfigId set on agent)');
102
+ }
103
+ else {
104
+ throw new error_types_1.ValidationError(`Profile ${profile.id} has no provider configs - cannot launch session`);
105
+ }
106
+ }
107
+ return { agent, project, epic, profile, provider, options, configEnv };
108
+ }
109
+ verifyProviderBinary(provider) {
110
+ if (!provider.binPath) {
111
+ throw new error_types_1.ValidationError(`Provider ${provider.name} is missing a binary path. Set the path before launching sessions.`, {
112
+ code: 'PROVIDER_BINARY_NOT_FOUND',
113
+ providerId: provider.id,
114
+ providerName: provider.name,
115
+ });
116
+ }
117
+ }
118
+ composeLaunchEnv(params) {
119
+ const { sessionId, tmuxSessionName, projectId, agentId, provider, configEnv, optionArgs } = params;
120
+ const providerEnv = provider.env ?? {};
121
+ const mergedBaseEnv = { ...providerEnv, ...(configEnv ?? {}) };
122
+ let envVars = Object.keys(mergedBaseEnv).length > 0 ? mergedBaseEnv : null;
123
+ let processedOptionArgs = optionArgs;
124
+ if (provider.name.toLowerCase() === 'claude') {
125
+ const env = (0, env_config_1.getEnvConfig)();
126
+ const devchainEnv = {
127
+ DEVCHAIN_API_URL: `http://127.0.0.1:${env.PORT}`,
128
+ DEVCHAIN_PROJECT_ID: projectId,
129
+ DEVCHAIN_AGENT_ID: agentId,
130
+ DEVCHAIN_SESSION_ID: sessionId,
131
+ DEVCHAIN_TMUX_SESSION_NAME: tmuxSessionName,
132
+ };
133
+ if (provider.oneMillionContextEnabled) {
134
+ processedOptionArgs = (0, profile_options_1.rewriteModelTo1m)(processedOptionArgs);
135
+ }
136
+ const modelStr = (0, profile_options_1.extractModelFromArgs)(processedOptionArgs);
137
+ const family = modelStr ? (0, profile_options_1.detectClaudeModelFamily)(modelStr) : null;
138
+ if (provider.oneMillionContextEnabled &&
139
+ family === 'opus' &&
140
+ provider.autoCompactThreshold1m != null) {
141
+ devchainEnv.CLAUDE_AUTOCOMPACT_PCT_OVERRIDE = String(provider.autoCompactThreshold1m);
142
+ }
143
+ else if (provider.autoCompactThreshold != null) {
144
+ devchainEnv.CLAUDE_AUTOCOMPACT_PCT_OVERRIDE = String(provider.autoCompactThreshold);
145
+ }
146
+ envVars = { ...devchainEnv, ...providerEnv, ...(configEnv ?? {}) };
147
+ delete envVars.CLAUDE_CODE_DISABLE_1M_CONTEXT;
148
+ }
149
+ return { envVars, processedOptionArgs };
150
+ }
151
+ async ensureMcpConfig(provider, projectRootPath) {
152
+ let preflightResult = await this.preflightService.runChecks(projectRootPath);
153
+ let providerCheck = preflightResult.providers?.find((p) => p.id === provider.id);
154
+ if (providerCheck?.mcpStatus && providerCheck.mcpStatus !== 'pass') {
155
+ logger.info({
156
+ providerId: provider.id,
157
+ providerName: provider.name,
158
+ mcpStatus: providerCheck.mcpStatus,
159
+ }, 'MCP not configured, attempting auto-ensure');
160
+ const ensureResult = await this.mcpEnsureService.ensureMcp(provider, projectRootPath);
161
+ if (ensureResult.success) {
162
+ logger.info({ providerId: provider.id, action: ensureResult.action }, 'MCP auto-configured successfully');
163
+ preflightResult = await this.preflightService.runChecks(projectRootPath);
164
+ providerCheck = preflightResult.providers?.find((p) => p.id === provider.id);
165
+ }
166
+ else {
167
+ logger.warn({ providerId: provider.id, message: ensureResult.message }, 'MCP auto-ensure failed');
168
+ }
169
+ if (providerCheck?.mcpStatus && providerCheck.mcpStatus !== 'pass') {
170
+ throw new error_types_1.ValidationError('Provider MCP is not configured', {
171
+ code: 'MCP_NOT_CONFIGURED',
172
+ providerId: provider.id,
173
+ providerName: provider.name,
174
+ mcpStatus: providerCheck.mcpStatus,
175
+ mcpMessage: providerCheck.mcpMessage,
176
+ });
177
+ }
178
+ }
179
+ return preflightResult;
180
+ }
181
+ async setupHooksConfig(provider, projectRootPath) {
182
+ if (provider.name.toLowerCase() !== 'claude')
183
+ return;
184
+ try {
185
+ await this.hooksConfigService.ensureHooksConfig(projectRootPath);
186
+ logger.info({ projectRootPath }, 'Hooks config ensured for Claude provider');
187
+ }
188
+ catch (error) {
189
+ logger.warn({ error }, 'Failed to ensure hooks config (non-fatal)');
190
+ }
191
+ }
70
192
  async launchSession(data) {
71
193
  const { epicId, agentId, projectId, options: launchOptions } = data;
72
194
  const silent = launchOptions?.silent === true;
@@ -119,40 +241,7 @@ let SessionsService = class SessionsService {
119
241
  .run(new Date().toISOString(), new Date().toISOString(), existingSession.id);
120
242
  logger.info({ agentId }, 'Orphaned session cleaned up, proceeding to create new session');
121
243
  }
122
- const agent = await this.storage.getAgent(agentId);
123
- const project = await this.storage.getProject(projectId);
124
- if (agent.projectId !== projectId) {
125
- throw new error_types_1.ValidationError(`Agent ${agentId} does not belong to project ${projectId}.`, {
126
- agentId,
127
- agentProjectId: agent.projectId,
128
- requestedProjectId: projectId,
129
- });
130
- }
131
- const epic = epicId ? await this.storage.getEpic(epicId) : null;
132
- const profile = await this.storage.getAgentProfile(agent.profileId);
133
- let provider;
134
- let options;
135
- let configEnv = null;
136
- if (agent.providerConfigId) {
137
- const config = await this.storage.getProfileProviderConfig(agent.providerConfigId);
138
- provider = await this.storage.getProvider(config.providerId);
139
- options = config.options;
140
- configEnv = config.env;
141
- logger.info({ agentId, configId: config.id, providerId: provider.id }, 'Resolved provider via config');
142
- }
143
- else {
144
- const configs = await this.storage.listProfileProviderConfigsByProfile(profile.id);
145
- if (configs.length > 0) {
146
- const firstConfig = configs[0];
147
- provider = await this.storage.getProvider(firstConfig.providerId);
148
- options = firstConfig.options;
149
- configEnv = firstConfig.env;
150
- logger.info({ agentId, profileId: profile.id, configId: firstConfig.id, providerId: provider.id }, 'Resolved provider via first profile config (no providerConfigId set on agent)');
151
- }
152
- else {
153
- throw new error_types_1.ValidationError(`Profile ${profile.id} has no provider configs - cannot launch session`);
154
- }
155
- }
244
+ const { agent, project, epic, profile, provider, options, configEnv } = await this.resolveLaunchTarget({ agentId, projectId, epicId });
156
245
  if (provider.name.toLowerCase() === 'claude') {
157
246
  const { autoCompactEnabled, configState } = await (0, claude_config_1.checkClaudeAutoCompact)();
158
247
  if (!autoCompactEnabled && configState !== 'malformed') {
@@ -167,42 +256,8 @@ let SessionsService = class SessionsService {
167
256
  });
168
257
  }
169
258
  }
170
- let preflightResult = await this.preflightService.runChecks(project.rootPath);
171
- let providerCheck = preflightResult.providers?.find((p) => p.id === provider.id);
172
- if (providerCheck?.mcpStatus && providerCheck.mcpStatus !== 'pass') {
173
- logger.info({
174
- providerId: provider.id,
175
- providerName: provider.name,
176
- mcpStatus: providerCheck.mcpStatus,
177
- }, 'MCP not configured, attempting auto-ensure');
178
- const ensureResult = await this.mcpEnsureService.ensureMcp(provider, project.rootPath);
179
- if (ensureResult.success) {
180
- logger.info({ providerId: provider.id, action: ensureResult.action }, 'MCP auto-configured successfully');
181
- preflightResult = await this.preflightService.runChecks(project.rootPath);
182
- providerCheck = preflightResult.providers?.find((p) => p.id === provider.id);
183
- }
184
- else {
185
- logger.warn({ providerId: provider.id, message: ensureResult.message }, 'MCP auto-ensure failed');
186
- }
187
- if (providerCheck?.mcpStatus && providerCheck.mcpStatus !== 'pass') {
188
- throw new error_types_1.ValidationError('Provider MCP is not configured', {
189
- code: 'MCP_NOT_CONFIGURED',
190
- providerId: provider.id,
191
- providerName: provider.name,
192
- mcpStatus: providerCheck.mcpStatus,
193
- mcpMessage: providerCheck.mcpMessage,
194
- });
195
- }
196
- }
197
- if (provider.name.toLowerCase() === 'claude') {
198
- try {
199
- await this.hooksConfigService.ensureHooksConfig(project.rootPath);
200
- logger.info({ projectId, projectRootPath: project.rootPath }, 'Hooks config ensured for Claude provider');
201
- }
202
- catch (error) {
203
- logger.warn({ error, projectId }, 'Failed to ensure hooks config (non-fatal)');
204
- }
205
- }
259
+ const preflightResult = await this.ensureMcpConfig(provider, project.rootPath);
260
+ await this.setupHooksConfig(provider, project.rootPath);
206
261
  if (preflightResult.overall === 'fail') {
207
262
  const failedChecks = preflightResult.checks
208
263
  .filter((c) => c.status === 'fail')
@@ -224,12 +279,7 @@ let SessionsService = class SessionsService {
224
279
  await this.tmuxService.createSession(tmuxSessionName, project.rootPath);
225
280
  await this.tmuxService.setAlternateScreenOff(tmuxSessionName);
226
281
  this.tmuxService.startHealthCheck(tmuxSessionName, sessionId);
227
- if (!provider.binPath) {
228
- throw new error_types_1.ValidationError(`Provider ${provider.name} is missing a binary path. Set the path before launching sessions.`, {
229
- providerId: provider.id,
230
- providerName: provider.name,
231
- });
232
- }
282
+ this.verifyProviderBinary(provider);
233
283
  let optionArgs = [];
234
284
  try {
235
285
  optionArgs = (0, profile_options_1.parseProfileOptions)(options);
@@ -246,37 +296,29 @@ let SessionsService = class SessionsService {
246
296
  if (agent.modelOverride) {
247
297
  optionArgs = (0, profile_options_1.injectModelOverride)(optionArgs, agent.modelOverride);
248
298
  }
249
- const providerEnv = provider.env ?? {};
250
- const mergedBaseEnv = { ...providerEnv, ...(configEnv ?? {}) };
251
- let envVars = Object.keys(mergedBaseEnv).length > 0 ? mergedBaseEnv : null;
252
- if (provider.name.toLowerCase() === 'claude') {
253
- const env = (0, env_config_1.getEnvConfig)();
254
- const devchainEnv = {
255
- DEVCHAIN_API_URL: `http://127.0.0.1:${env.PORT}`,
256
- DEVCHAIN_PROJECT_ID: projectId,
257
- DEVCHAIN_AGENT_ID: agentId,
258
- DEVCHAIN_SESSION_ID: sessionId,
259
- DEVCHAIN_TMUX_SESSION_NAME: tmuxSessionName,
260
- };
261
- if (provider.oneMillionContextEnabled) {
262
- optionArgs = (0, profile_options_1.rewriteModelTo1m)(optionArgs);
263
- }
264
- const modelStr = (0, profile_options_1.extractModelFromArgs)(optionArgs);
265
- const family = modelStr ? (0, profile_options_1.detectClaudeModelFamily)(modelStr) : null;
266
- if (provider.oneMillionContextEnabled &&
267
- family === 'opus' &&
268
- provider.autoCompactThreshold1m != null) {
269
- devchainEnv.CLAUDE_AUTOCOMPACT_PCT_OVERRIDE = String(provider.autoCompactThreshold1m);
270
- }
271
- else if (provider.autoCompactThreshold != null) {
272
- devchainEnv.CLAUDE_AUTOCOMPACT_PCT_OVERRIDE = String(provider.autoCompactThreshold);
273
- }
274
- envVars = { ...devchainEnv, ...providerEnv, ...(configEnv ?? {}) };
275
- delete envVars.CLAUDE_CODE_DISABLE_1M_CONTEXT;
299
+ const { envVars, processedOptionArgs } = this.composeLaunchEnv({
300
+ sessionId,
301
+ tmuxSessionName,
302
+ projectId,
303
+ agentId,
304
+ provider,
305
+ configEnv,
306
+ optionArgs,
307
+ });
308
+ optionArgs = processedOptionArgs;
309
+ let launchArgv = optionArgs;
310
+ try {
311
+ const launchAdapter = this.providerAdapterFactory.getAdapter(provider.name);
312
+ launchArgv = launchAdapter.buildLaunchArgs({
313
+ mode: 'new',
314
+ profileOptionArgs: optionArgs,
315
+ }).argv;
316
+ }
317
+ catch {
276
318
  }
277
319
  let commandArgs;
278
320
  try {
279
- commandArgs = (0, env_builder_1.buildSessionCommand)(envVars, provider.binPath, optionArgs);
321
+ commandArgs = (0, env_builder_1.buildSessionCommand)(envVars, provider.binPath, launchArgv);
280
322
  }
281
323
  catch (error) {
282
324
  if (error instanceof env_builder_1.EnvBuilderError) {
@@ -290,10 +332,10 @@ let SessionsService = class SessionsService {
290
332
  try {
291
333
  this.sqlite
292
334
  .prepare(`
293
- INSERT INTO sessions (id, epic_id, agent_id, tmux_session_id, status, started_at, created_at, updated_at)
294
- VALUES (?, ?, ?, ?, ?, ?, ?, ?)
335
+ INSERT INTO sessions (id, epic_id, agent_id, tmux_session_id, status, started_at, provider_name_at_launch, created_at, updated_at)
336
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
295
337
  `)
296
- .run(sessionId, epicId ?? null, agentId, tmuxSessionName, 'running', now, now, now);
338
+ .run(sessionId, epicId ?? null, agentId, tmuxSessionName, 'running', now, provider.name.toLowerCase(), now, now);
297
339
  logger.info({ sessionId, tmuxSessionName }, 'Session created in database');
298
340
  }
299
341
  catch (error) {
@@ -427,7 +469,6 @@ let SessionsService = class SessionsService {
427
469
  startedAt: now,
428
470
  endedAt: null,
429
471
  transcriptPath: null,
430
- claudeSessionId: null,
431
472
  createdAt: now,
432
473
  updatedAt: now,
433
474
  epic: epic
@@ -450,6 +491,236 @@ let SessionsService = class SessionsService {
450
491
  };
451
492
  });
452
493
  }
494
+ async restoreSession(sessionId, projectId) {
495
+ const source = this.sqlite
496
+ .prepare(`SELECT id, epic_id, agent_id, tmux_session_id, status, started_at, ended_at,
497
+ transcript_path, provider_session_id, provider_name_at_launch, created_at, updated_at
498
+ FROM sessions WHERE id = ?`)
499
+ .get(sessionId);
500
+ if (!source) {
501
+ throw new common_1.NotFoundException('Session not found');
502
+ }
503
+ const sourceAgent = await this.storage.getAgent(source.agent_id);
504
+ if (sourceAgent.projectId !== projectId) {
505
+ throw new common_1.ForbiddenException('PROJECT_MISMATCH');
506
+ }
507
+ if (source.status !== 'stopped' && source.status !== 'failed') {
508
+ throw new common_1.ConflictException({
509
+ message: 'Session is not in a restorable state',
510
+ code: 'INVALID_SESSION_STATE',
511
+ });
512
+ }
513
+ if (!source.provider_session_id) {
514
+ throw new common_1.ConflictException({
515
+ message: 'Session has no provider session ID',
516
+ code: 'NO_PROVIDER_SESSION_ID',
517
+ });
518
+ }
519
+ const { provider: currentProvider } = await this.resolveLaunchTarget({
520
+ agentId: source.agent_id,
521
+ projectId,
522
+ epicId: source.epic_id,
523
+ });
524
+ if (source.provider_name_at_launch &&
525
+ currentProvider.name.toLowerCase() !== source.provider_name_at_launch.toLowerCase()) {
526
+ throw new common_1.ConflictException({
527
+ message: 'Current provider differs from launch-time provider',
528
+ code: 'PROVIDER_MISMATCH',
529
+ });
530
+ }
531
+ return this.sessionCoordinator.withAgentLock(source.agent_id, async () => {
532
+ const lockedSource = this.sqlite
533
+ .prepare(`SELECT id, epic_id, agent_id, tmux_session_id, status, started_at, ended_at,
534
+ transcript_path, provider_session_id, provider_name_at_launch, created_at, updated_at
535
+ FROM sessions WHERE id = ?`)
536
+ .get(sessionId);
537
+ if (!lockedSource) {
538
+ throw new common_1.NotFoundException('Session not found');
539
+ }
540
+ if (lockedSource.status !== 'stopped' && lockedSource.status !== 'failed') {
541
+ throw new common_1.ConflictException({
542
+ message: 'Session is not in a restorable state',
543
+ code: 'INVALID_SESSION_STATE',
544
+ });
545
+ }
546
+ if (!lockedSource.provider_session_id) {
547
+ throw new common_1.ConflictException({
548
+ message: 'Session has no provider session ID',
549
+ code: 'NO_PROVIDER_SESSION_ID',
550
+ });
551
+ }
552
+ if (lockedSource.provider_name_at_launch &&
553
+ currentProvider.name.toLowerCase() !== lockedSource.provider_name_at_launch.toLowerCase()) {
554
+ throw new common_1.ConflictException({
555
+ message: 'Current provider differs from launch-time provider',
556
+ code: 'PROVIDER_MISMATCH',
557
+ });
558
+ }
559
+ const existingRunning = this.getActiveSessionForAgent(lockedSource.agent_id);
560
+ if (existingRunning) {
561
+ throw new common_1.ConflictException({
562
+ message: 'Agent already has a running session',
563
+ code: 'INVALID_SESSION_STATE',
564
+ });
565
+ }
566
+ const prior = {
567
+ status: lockedSource.status,
568
+ ended_at: lockedSource.ended_at,
569
+ tmux_session_id: lockedSource.tmux_session_id,
570
+ };
571
+ const { agent, project, epic, profile, provider, options, configEnv } = await this.resolveLaunchTarget({
572
+ agentId: lockedSource.agent_id,
573
+ projectId,
574
+ epicId: lockedSource.epic_id,
575
+ });
576
+ this.verifyProviderBinary(provider);
577
+ if (lockedSource.provider_name_at_launch &&
578
+ provider.name.toLowerCase() !== lockedSource.provider_name_at_launch.toLowerCase()) {
579
+ throw new common_1.ConflictException({
580
+ message: 'Current provider differs from launch-time provider',
581
+ code: 'PROVIDER_MISMATCH',
582
+ });
583
+ }
584
+ let optionArgs = [];
585
+ try {
586
+ optionArgs = (0, profile_options_1.parseProfileOptions)(options);
587
+ }
588
+ catch (error) {
589
+ if (error instanceof profile_options_1.ProfileOptionsError) {
590
+ throw new error_types_1.ValidationError(error.message, {
591
+ profileId: profile.id,
592
+ profileName: profile.name,
593
+ });
594
+ }
595
+ throw error;
596
+ }
597
+ if (agent.modelOverride) {
598
+ optionArgs = (0, profile_options_1.injectModelOverride)(optionArgs, agent.modelOverride);
599
+ }
600
+ const projectSlug = project.name
601
+ .toLowerCase()
602
+ .replace(/[^a-z0-9]+/g, '-')
603
+ .replace(/(^-|-$)/g, '');
604
+ const epicSegment = lockedSource.epic_id ?? 'independent';
605
+ const tmuxSessionName = this.tmuxService.createSessionName(projectSlug, epicSegment, agent.id, lockedSource.id);
606
+ const { envVars, processedOptionArgs } = this.composeLaunchEnv({
607
+ sessionId: lockedSource.id,
608
+ tmuxSessionName,
609
+ projectId,
610
+ agentId: agent.id,
611
+ provider,
612
+ configEnv,
613
+ optionArgs,
614
+ });
615
+ optionArgs = processedOptionArgs;
616
+ const adapter = this.providerAdapterFactory.getAdapter(provider.name);
617
+ const launchArgv = adapter.buildLaunchArgs({
618
+ mode: 'restore',
619
+ providerSessionId: lockedSource.provider_session_id,
620
+ profileOptionArgs: optionArgs,
621
+ }).argv;
622
+ if (!launchArgv.includes(lockedSource.provider_session_id)) {
623
+ throw new error_types_1.ValidationError('Restore argv does not include provider session ID — adapter contract violation', {
624
+ code: 'RESTORE_ARGS_UNAVAILABLE',
625
+ providerName: provider.name,
626
+ providerSessionId: lockedSource.provider_session_id,
627
+ });
628
+ }
629
+ let commandArgs;
630
+ try {
631
+ commandArgs = (0, env_builder_1.buildSessionCommand)(envVars, provider.binPath, launchArgv);
632
+ }
633
+ catch (error) {
634
+ if (error instanceof env_builder_1.EnvBuilderError) {
635
+ throw new error_types_1.ValidationError(error.message, {
636
+ agentId: agent.id,
637
+ providerConfigId: agent.providerConfigId,
638
+ });
639
+ }
640
+ throw error;
641
+ }
642
+ const now = new Date().toISOString();
643
+ this.sqlite
644
+ .prepare(`UPDATE sessions
645
+ SET status = 'running', tmux_session_id = ?, ended_at = NULL,
646
+ last_activity_at = ?, updated_at = ?
647
+ WHERE id = ?`)
648
+ .run(tmuxSessionName, now, now, lockedSource.id);
649
+ try {
650
+ await this.tmuxService.createSession(tmuxSessionName, project.rootPath);
651
+ }
652
+ catch (tmuxError) {
653
+ this.sqlite
654
+ .prepare(`UPDATE sessions
655
+ SET status = ?, ended_at = ?, tmux_session_id = ?, updated_at = ?
656
+ WHERE id = ?`)
657
+ .run(prior.status, prior.ended_at, prior.tmux_session_id, now, lockedSource.id);
658
+ logger.error({ sessionId: lockedSource.id, error: String(tmuxError) }, 'Restore failed: tmux creation error — rolled back');
659
+ throw new common_1.InternalServerErrorException('RESTORE_FAILED');
660
+ }
661
+ await this.tmuxService.setAlternateScreenOff(tmuxSessionName);
662
+ this.tmuxService.startHealthCheck(tmuxSessionName, lockedSource.id);
663
+ try {
664
+ await this.tmuxService.sendCommandArgs(tmuxSessionName, commandArgs);
665
+ }
666
+ catch (sendError) {
667
+ this.sqlite
668
+ .prepare(`UPDATE sessions
669
+ SET status = ?, ended_at = ?, tmux_session_id = ?, updated_at = ?
670
+ WHERE id = ?`)
671
+ .run(prior.status, prior.ended_at, prior.tmux_session_id, now, lockedSource.id);
672
+ try {
673
+ await this.tmuxService.destroySession(tmuxSessionName);
674
+ }
675
+ catch (destroyErr) {
676
+ logger.warn({ tmuxSessionName, error: String(destroyErr) }, 'Failed to destroy tmux session during restore rollback');
677
+ }
678
+ logger.error({ sessionId: lockedSource.id, error: String(sendError) }, 'Restore failed: CLI launch error — rolled back');
679
+ throw new common_1.InternalServerErrorException('RESTORE_FAILED');
680
+ }
681
+ await this.ptyService.startStreaming(lockedSource.id, tmuxSessionName);
682
+ await this.getEventsService().publish('session.restored', {
683
+ sessionId: lockedSource.id,
684
+ epicId: lockedSource.epic_id,
685
+ agentId: agent.id,
686
+ tmuxSessionName,
687
+ });
688
+ if (lockedSource.transcript_path) {
689
+ await this.getEventsService().publish('session.transcript.discovered', {
690
+ sessionId: lockedSource.id,
691
+ agentId: agent.id,
692
+ projectId,
693
+ transcriptPath: lockedSource.transcript_path,
694
+ providerName: provider.name.toLowerCase(),
695
+ });
696
+ }
697
+ try {
698
+ this.getTerminalGateway().broadcastEvent(`agent/${agent.id}`, 'presence', {
699
+ online: true,
700
+ sessionId: lockedSource.id,
701
+ agentId: agent.id,
702
+ });
703
+ }
704
+ catch (error) {
705
+ logger.warn({ error, agentId: agent.id }, 'Failed to broadcast presence after restore');
706
+ }
707
+ return {
708
+ id: lockedSource.id,
709
+ epicId: lockedSource.epic_id,
710
+ agentId: agent.id,
711
+ tmuxSessionId: tmuxSessionName,
712
+ status: 'running',
713
+ startedAt: lockedSource.started_at,
714
+ endedAt: null,
715
+ transcriptPath: lockedSource.transcript_path,
716
+ createdAt: lockedSource.created_at,
717
+ updatedAt: now,
718
+ epic: epic ? { id: epic.id, title: epic.title, projectId: epic.projectId } : null,
719
+ agent: { id: agent.id, name: agent.name, profileId: agent.profileId },
720
+ project: { id: project.id, name: project.name, rootPath: project.rootPath },
721
+ };
722
+ });
723
+ }
453
724
  async terminateSession(sessionId) {
454
725
  logger.info({ sessionId }, 'Terminating session');
455
726
  const session = this.getSession(sessionId);
@@ -471,14 +742,24 @@ let SessionsService = class SessionsService {
471
742
  logger.warn({ sessionId, tmuxSessionId: session.tmuxSessionId }, 'Tmux session already gone, cleaning up database record');
472
743
  }
473
744
  }
745
+ let sizeBytes = null;
746
+ if (session.transcriptPath) {
747
+ try {
748
+ const fileStat = await (0, promises_1.stat)(session.transcriptPath);
749
+ sizeBytes = fileStat.size;
750
+ }
751
+ catch (error) {
752
+ logger.warn({ error, sessionId, transcriptPath: session.transcriptPath }, 'Could not stat transcript file for size_bytes — leaving NULL (best-effort)');
753
+ }
754
+ }
474
755
  const now = new Date().toISOString();
475
756
  this.sqlite
476
757
  .prepare(`
477
758
  UPDATE sessions
478
- SET status = ?, ended_at = ?, updated_at = ?
759
+ SET status = ?, ended_at = ?, size_bytes = ?, updated_at = ?
479
760
  WHERE id = ?
480
761
  `)
481
- .run('stopped', now, now, sessionId);
762
+ .run('stopped', now, sizeBytes, now, sessionId);
482
763
  logger.info({ sessionId }, 'Session terminated');
483
764
  await this.getEventsService().publish('session.stopped', { sessionId });
484
765
  if (session.agentId) {
@@ -494,10 +775,86 @@ let SessionsService = class SessionsService {
494
775
  }
495
776
  }
496
777
  }
778
+ async getAgentSessionHistory(agentId, projectId, cursor, limit) {
779
+ const agent = await this.storage.getAgent(agentId);
780
+ if (agent.projectId !== projectId) {
781
+ throw new common_1.ForbiddenException('PROJECT_MISMATCH');
782
+ }
783
+ const clampedLimit = Math.min(Math.max(1, limit), 100);
784
+ const sortExpr = `COALESCE(last_activity_at, ended_at, started_at)`;
785
+ const selectCols = `id, provider_session_id, provider_name_at_launch, status, started_at, ended_at, last_activity_at, size_bytes, transcript_path`;
786
+ const baseStatus = `status IN ('stopped','failed')`;
787
+ const { cnt: total } = this.sqlite
788
+ .prepare(`SELECT COUNT(*) as cnt FROM sessions WHERE agent_id = ? AND ${baseStatus}`)
789
+ .get(agentId);
790
+ let cursorSortKey = null;
791
+ let cursorId = null;
792
+ if (cursor) {
793
+ try {
794
+ const decoded = JSON.parse(Buffer.from(cursor, 'base64url').toString('utf-8'));
795
+ cursorSortKey = decoded.s;
796
+ cursorId = decoded.i;
797
+ }
798
+ catch {
799
+ }
800
+ }
801
+ let rows;
802
+ if (cursorSortKey && cursorId) {
803
+ rows = this.sqlite
804
+ .prepare(`SELECT ${selectCols} FROM sessions
805
+ WHERE agent_id = ? AND ${baseStatus}
806
+ AND (${sortExpr} < ? OR (${sortExpr} = ? AND id < ?))
807
+ ORDER BY ${sortExpr} DESC, id DESC
808
+ LIMIT ?`)
809
+ .all(agentId, cursorSortKey, cursorSortKey, cursorId, clampedLimit + 1);
810
+ }
811
+ else {
812
+ rows = this.sqlite
813
+ .prepare(`SELECT ${selectCols} FROM sessions
814
+ WHERE agent_id = ? AND ${baseStatus}
815
+ ORDER BY ${sortExpr} DESC, id DESC
816
+ LIMIT ?`)
817
+ .all(agentId, clampedLimit + 1);
818
+ }
819
+ const hasMore = rows.length > clampedLimit;
820
+ const pageRows = hasMore ? rows.slice(0, clampedLimit) : rows;
821
+ const now = new Date().toISOString();
822
+ for (const row of pageRows) {
823
+ if (row.size_bytes === null && row.transcript_path !== null) {
824
+ try {
825
+ const fileStat = await (0, promises_1.stat)(row.transcript_path);
826
+ this.sqlite
827
+ .prepare(`UPDATE sessions SET size_bytes = ?, updated_at = ? WHERE id = ?`)
828
+ .run(fileStat.size, now, row.id);
829
+ row.size_bytes = fileStat.size;
830
+ }
831
+ catch {
832
+ }
833
+ }
834
+ }
835
+ let nextCursor = null;
836
+ if (hasMore && pageRows.length > 0) {
837
+ const last = pageRows[pageRows.length - 1];
838
+ const sortKey = last.last_activity_at ?? last.ended_at ?? last.started_at;
839
+ nextCursor = Buffer.from(JSON.stringify({ s: sortKey, i: last.id })).toString('base64url');
840
+ }
841
+ const items = pageRows.map((row) => ({
842
+ id: row.id,
843
+ providerSessionId: row.provider_session_id ?? null,
844
+ providerNameAtLaunch: row.provider_name_at_launch,
845
+ status: row.status,
846
+ startedAt: row.started_at,
847
+ endedAt: row.ended_at,
848
+ lastActivityAt: row.last_activity_at,
849
+ sizeBytes: row.size_bytes,
850
+ transcriptAvailable: row.transcript_path !== null,
851
+ }));
852
+ return { items, nextCursor, hasMore, total };
853
+ }
497
854
  getSession(sessionId) {
498
855
  const row = this.sqlite
499
856
  .prepare(`
500
- SELECT id, epic_id, agent_id, tmux_session_id, status, started_at, ended_at, last_activity_at, activity_state, busy_since, transcript_path, claude_session_id, created_at, updated_at
857
+ SELECT id, epic_id, agent_id, tmux_session_id, status, started_at, ended_at, last_activity_at, activity_state, busy_since, transcript_path, created_at, updated_at
501
858
  FROM sessions
502
859
  WHERE id = ?
503
860
  `)
@@ -518,7 +875,6 @@ let SessionsService = class SessionsService {
518
875
  activityState: row.activity_state ?? null,
519
876
  busySince: row.busy_since ?? null,
520
877
  transcriptPath: row.transcript_path ?? null,
521
- claudeSessionId: row.claude_session_id ?? null,
522
878
  createdAt: row.created_at,
523
879
  updatedAt: row.updated_at,
524
880
  };
@@ -526,7 +882,7 @@ let SessionsService = class SessionsService {
526
882
  async listActiveSessions(projectId, allowedAgentIds) {
527
883
  const rows = this.sqlite
528
884
  .prepare(`
529
- SELECT id, epic_id, agent_id, tmux_session_id, status, started_at, ended_at, last_activity_at, activity_state, busy_since, transcript_path, claude_session_id, created_at, updated_at
885
+ SELECT id, epic_id, agent_id, tmux_session_id, status, started_at, ended_at, last_activity_at, activity_state, busy_since, transcript_path, created_at, updated_at
530
886
  FROM sessions
531
887
  WHERE status = 'running'
532
888
  ORDER BY started_at DESC
@@ -564,7 +920,6 @@ let SessionsService = class SessionsService {
564
920
  activityState: row.activity_state ?? null,
565
921
  busySince: row.busy_since ?? null,
566
922
  transcriptPath: row.transcript_path ?? null,
567
- claudeSessionId: row.claude_session_id ?? null,
568
923
  createdAt: row.created_at,
569
924
  updatedAt: row.updated_at,
570
925
  }));
@@ -583,7 +938,7 @@ let SessionsService = class SessionsService {
583
938
  .prepare(`
584
939
  SELECT id, epic_id, agent_id, tmux_session_id, status,
585
940
  started_at, ended_at, last_activity_at, activity_state,
586
- busy_since, transcript_path, claude_session_id,
941
+ busy_since, transcript_path,
587
942
  created_at, updated_at
588
943
  FROM sessions
589
944
  WHERE status = 'running' AND agent_id = ?
@@ -606,7 +961,6 @@ let SessionsService = class SessionsService {
606
961
  activityState: row.activity_state ?? null,
607
962
  busySince: row.busy_since ?? null,
608
963
  transcriptPath: row.transcript_path ?? null,
609
- claudeSessionId: row.claude_session_id ?? null,
610
964
  createdAt: row.created_at,
611
965
  updatedAt: row.updated_at,
612
966
  };
@@ -616,7 +970,7 @@ let SessionsService = class SessionsService {
616
970
  .prepare(`
617
971
  SELECT s.id, s.epic_id, s.agent_id, s.tmux_session_id, s.status,
618
972
  s.started_at, s.ended_at, s.last_activity_at, s.activity_state,
619
- s.busy_since, s.transcript_path, s.claude_session_id,
973
+ s.busy_since, s.transcript_path,
620
974
  s.created_at, s.updated_at
621
975
  FROM sessions s
622
976
  JOIN agents a ON s.agent_id = a.id
@@ -636,7 +990,6 @@ let SessionsService = class SessionsService {
636
990
  activityState: row.activity_state ?? null,
637
991
  busySince: row.busy_since ?? null,
638
992
  transcriptPath: row.transcript_path ?? null,
639
- claudeSessionId: row.claude_session_id ?? null,
640
993
  createdAt: row.created_at,
641
994
  updatedAt: row.updated_at,
642
995
  }));