@junctionpanel/server 0.1.18 → 0.1.20

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 (38) hide show
  1. package/dist/server/client/daemon-client.d.ts +8 -2
  2. package/dist/server/client/daemon-client.d.ts.map +1 -1
  3. package/dist/server/client/daemon-client.js +27 -1
  4. package/dist/server/client/daemon-client.js.map +1 -1
  5. package/dist/server/server/agent/agent-storage.d.ts +48 -0
  6. package/dist/server/server/agent/agent-storage.d.ts.map +1 -1
  7. package/dist/server/server/agent/agent-storage.js +16 -0
  8. package/dist/server/server/agent/agent-storage.js.map +1 -1
  9. package/dist/server/server/agent/provider-manifest.d.ts.map +1 -1
  10. package/dist/server/server/agent/provider-manifest.js +29 -0
  11. package/dist/server/server/agent/provider-manifest.js.map +1 -1
  12. package/dist/server/server/agent/provider-registry.d.ts.map +1 -1
  13. package/dist/server/server/agent/provider-registry.js +8 -0
  14. package/dist/server/server/agent/provider-registry.js.map +1 -1
  15. package/dist/server/server/agent/providers/gemini-agent.d.ts +425 -0
  16. package/dist/server/server/agent/providers/gemini-agent.d.ts.map +1 -0
  17. package/dist/server/server/agent/providers/gemini-agent.js +973 -0
  18. package/dist/server/server/agent/providers/gemini-agent.js.map +1 -0
  19. package/dist/server/server/persistence-hooks.d.ts.map +1 -1
  20. package/dist/server/server/persistence-hooks.js +4 -1
  21. package/dist/server/server/persistence-hooks.js.map +1 -1
  22. package/dist/server/server/session.d.ts +6 -1
  23. package/dist/server/server/session.d.ts.map +1 -1
  24. package/dist/server/server/session.js +434 -146
  25. package/dist/server/server/session.js.map +1 -1
  26. package/dist/server/shared/messages.d.ts +314 -106
  27. package/dist/server/shared/messages.d.ts.map +1 -1
  28. package/dist/server/shared/messages.js +16 -0
  29. package/dist/server/shared/messages.js.map +1 -1
  30. package/dist/server/shared/project-grouping.d.ts +6 -0
  31. package/dist/server/shared/project-grouping.d.ts.map +1 -0
  32. package/dist/server/shared/project-grouping.js +62 -0
  33. package/dist/server/shared/project-grouping.js.map +1 -0
  34. package/dist/server/utils/worktree.d.ts +8 -0
  35. package/dist/server/utils/worktree.d.ts.map +1 -1
  36. package/dist/server/utils/worktree.js +61 -0
  37. package/dist/server/utils/worktree.js.map +1 -1
  38. package/package.json +2 -2
@@ -17,7 +17,8 @@ import { projectTimelineRows, selectTimelineWindowByProjectedLimit, } from './ag
17
17
  import { DEFAULT_STRUCTURED_GENERATION_PROVIDERS, StructuredAgentFallbackError, StructuredAgentResponseError, generateStructuredAgentResponseWithFallback, } from './agent/agent-response-loop.js';
18
18
  import { isValidAgentProvider, AGENT_PROVIDER_IDS } from './agent/provider-manifest.js';
19
19
  import { listDirectoryEntries, readExplorerFile, getDownloadableFileInfo, } from './file-explorer/service.js';
20
- import { slugify, validateBranchSlug, listJunctionWorktrees, deleteJunctionWorktree, isJunctionOwnedWorktreeCwd, resolveJunctionWorktreeRootForCwd, createInRepoWorktree, } from '../utils/worktree.js';
20
+ import { slugify, validateBranchSlug, listJunctionWorktrees, deleteJunctionWorktree, isJunctionOwnedWorktreeCwd, resolveJunctionWorktreeRootForCwd, createInRepoWorktree, restoreInRepoWorktree, } from '../utils/worktree.js';
21
+ import { readJunctionWorktreeMetadata } from '../utils/worktree-metadata.js';
21
22
  import { runAsyncWorktreeBootstrap } from './worktree-bootstrap.js';
22
23
  import { getCheckoutDiff, getCheckoutStatus, getCheckoutStatusLite, listBranchSuggestions, NotGitRepoError, MergeConflictError, MergeFromBaseConflictError, commitChanges, mergeToBase, mergeFromBase, pushCurrentBranch, createPullRequest, getPullRequestStatus, resolveBaseRef, } from '../utils/checkout-git.js';
23
24
  import { getProjectIcon } from '../utils/project-icon.js';
@@ -26,11 +27,13 @@ import { searchHomeDirectories, searchWorkspaceEntries, searchGitRepositories, c
26
27
  import { cloneRepository } from '../utils/git-clone.js';
27
28
  import { initRepository } from '../utils/git-init.js';
28
29
  import { resolveClientMessageId } from './client-message-id.js';
30
+ import { deriveProjectGroupingKey, deriveProjectGroupingName } from '../shared/project-grouping.js';
29
31
  const execAsync = promisify(exec);
30
32
  const READ_ONLY_GIT_ENV = {
31
33
  ...process.env,
32
34
  GIT_OPTIONAL_LOCKS: '0',
33
35
  };
36
+ const DEFAULT_STORED_TIMELINE_FETCH_LIMIT = 200;
34
37
  const pendingAgentInitializations = new Map();
35
38
  const DEFAULT_AGENT_PROVIDER = AGENT_PROVIDER_IDS[0];
36
39
  const CHECKOUT_DIFF_WATCH_DEBOUNCE_MS = 150;
@@ -38,67 +41,7 @@ const CHECKOUT_DIFF_FALLBACK_REFRESH_MS = 5000;
38
41
  const TERMINAL_STREAM_WINDOW_BYTES = 256 * 1024;
39
42
  const TERMINAL_STREAM_MAX_PENDING_BYTES = 2 * 1024 * 1024;
40
43
  const TERMINAL_STREAM_MAX_PENDING_CHUNKS = 2048;
41
- function deriveRemoteProjectKey(remoteUrl) {
42
- if (!remoteUrl) {
43
- return null;
44
- }
45
- const trimmed = remoteUrl.trim();
46
- if (!trimmed) {
47
- return null;
48
- }
49
- let host = null;
50
- let path = null;
51
- const scpLike = trimmed.match(/^[^@]+@([^:]+):(.+)$/);
52
- if (scpLike) {
53
- host = scpLike[1] ?? null;
54
- path = scpLike[2] ?? null;
55
- }
56
- else if (trimmed.includes('://')) {
57
- try {
58
- const parsed = new URL(trimmed);
59
- host = parsed.hostname || null;
60
- path = parsed.pathname ? parsed.pathname.replace(/^\//, '') : null;
61
- }
62
- catch {
63
- return null;
64
- }
65
- }
66
- if (!host || !path) {
67
- return null;
68
- }
69
- let cleanedPath = path.trim().replace(/^\/+/, '').replace(/\/+$/, '');
70
- if (cleanedPath.endsWith('.git')) {
71
- cleanedPath = cleanedPath.slice(0, -4);
72
- }
73
- if (!cleanedPath.includes('/')) {
74
- return null;
75
- }
76
- const cleanedHost = host.toLowerCase();
77
- if (cleanedHost === 'github.com') {
78
- return `remote:github.com/${cleanedPath}`;
79
- }
80
- return `remote:${cleanedHost}/${cleanedPath}`;
81
- }
82
- function deriveProjectGroupingKey(options) {
83
- const remoteKey = deriveRemoteProjectKey(options.remoteUrl);
84
- if (remoteKey) {
85
- return remoteKey;
86
- }
87
- const worktreeMarker = '.junction/';
88
- const idx = options.cwd.indexOf(worktreeMarker);
89
- if (idx !== -1) {
90
- return options.cwd.slice(0, idx).replace(/\/$/, '');
91
- }
92
- return options.cwd;
93
- }
94
- function deriveProjectGroupingName(projectKey) {
95
- const githubRemotePrefix = 'remote:github.com/';
96
- if (projectKey.startsWith(githubRemotePrefix)) {
97
- return projectKey.slice(githubRemotePrefix.length) || projectKey;
98
- }
99
- const segments = projectKey.split(/[\\/]/).filter(Boolean);
100
- return segments[segments.length - 1] || projectKey;
101
- }
44
+ const DIRTY_WORKTREE_CONFIRMATION_REQUIRED = 'dirty_worktree_confirmation_required';
102
45
  class SessionRequestError extends Error {
103
46
  constructor(code, message) {
104
47
  super(message);
@@ -431,6 +374,123 @@ export class Session {
431
374
  labels: record.labels,
432
375
  };
433
376
  }
377
+ fetchStoredTimeline(record, options) {
378
+ const rows = (record.timelineRows ?? []).map((row) => ({ ...row }));
379
+ const epoch = record.timelineEpoch ?? record.id;
380
+ const nextSeq = record.timelineNextSeq ??
381
+ (rows.length > 0 ? rows[rows.length - 1].seq + 1 : 1);
382
+ const minSeq = rows.length > 0 ? rows[0].seq : 0;
383
+ const maxSeq = rows.length > 0 ? rows[rows.length - 1].seq : 0;
384
+ const direction = options?.direction ?? 'tail';
385
+ const requestedLimit = options?.limit;
386
+ const limit = requestedLimit === undefined
387
+ ? DEFAULT_STORED_TIMELINE_FETCH_LIMIT
388
+ : Math.max(0, Math.floor(requestedLimit));
389
+ const cursor = options?.cursor;
390
+ const window = { minSeq, maxSeq, nextSeq };
391
+ if (cursor && cursor.epoch !== epoch) {
392
+ return {
393
+ epoch,
394
+ direction,
395
+ reset: true,
396
+ staleCursor: true,
397
+ gap: false,
398
+ window,
399
+ hasOlder: false,
400
+ hasNewer: false,
401
+ rows,
402
+ };
403
+ }
404
+ const selectAll = limit === 0;
405
+ const cloneRows = (items) => items.map((row) => ({ ...row }));
406
+ if (direction === 'after' && cursor && rows.length > 0 && cursor.seq < minSeq - 1) {
407
+ return {
408
+ epoch,
409
+ direction,
410
+ reset: true,
411
+ staleCursor: false,
412
+ gap: true,
413
+ window,
414
+ hasOlder: false,
415
+ hasNewer: false,
416
+ rows,
417
+ };
418
+ }
419
+ if (rows.length === 0) {
420
+ return {
421
+ epoch,
422
+ direction,
423
+ reset: false,
424
+ staleCursor: false,
425
+ gap: false,
426
+ window,
427
+ hasOlder: false,
428
+ hasNewer: false,
429
+ rows: [],
430
+ };
431
+ }
432
+ if (direction === 'tail') {
433
+ const selected = selectAll || limit >= rows.length ? rows : rows.slice(rows.length - limit);
434
+ return {
435
+ epoch,
436
+ direction,
437
+ reset: false,
438
+ staleCursor: false,
439
+ gap: false,
440
+ window,
441
+ hasOlder: selected.length > 0 && selected[0].seq > minSeq,
442
+ hasNewer: false,
443
+ rows: cloneRows(selected),
444
+ };
445
+ }
446
+ if (direction === 'after') {
447
+ const baseSeq = cursor?.seq ?? 0;
448
+ const startIdx = rows.findIndex((row) => row.seq > baseSeq);
449
+ if (startIdx < 0) {
450
+ return {
451
+ epoch,
452
+ direction,
453
+ reset: false,
454
+ staleCursor: false,
455
+ gap: false,
456
+ window,
457
+ hasOlder: baseSeq >= minSeq,
458
+ hasNewer: false,
459
+ rows: [],
460
+ };
461
+ }
462
+ const selected = selectAll ? rows.slice(startIdx) : rows.slice(startIdx, startIdx + limit);
463
+ const lastSelected = selected[selected.length - 1];
464
+ return {
465
+ epoch,
466
+ direction,
467
+ reset: false,
468
+ staleCursor: false,
469
+ gap: false,
470
+ window,
471
+ hasOlder: selected[0].seq > minSeq,
472
+ hasNewer: Boolean(lastSelected && lastSelected.seq < maxSeq),
473
+ rows: cloneRows(selected),
474
+ };
475
+ }
476
+ const beforeSeq = cursor?.seq ?? nextSeq;
477
+ const endExclusive = rows.findIndex((row) => row.seq >= beforeSeq);
478
+ const boundedRows = endExclusive < 0 ? rows : rows.slice(0, endExclusive);
479
+ const selected = selectAll || limit >= boundedRows.length
480
+ ? boundedRows
481
+ : boundedRows.slice(boundedRows.length - limit);
482
+ return {
483
+ epoch,
484
+ direction,
485
+ reset: false,
486
+ staleCursor: false,
487
+ gap: false,
488
+ window,
489
+ hasOlder: selected.length > 0 && selected[0].seq > minSeq,
490
+ hasNewer: endExclusive >= 0,
491
+ rows: cloneRows(selected),
492
+ };
493
+ }
434
494
  async ensureAgentLoaded(agentId) {
435
495
  const existing = this.agentManager.getAgent(agentId);
436
496
  if (existing) {
@@ -632,6 +692,36 @@ export class Session {
632
692
  this.sessionLogger.error({ err: error }, 'Failed to emit agent update');
633
693
  }
634
694
  }
695
+ async forwardStoredAgentRecordUpdate(record) {
696
+ try {
697
+ const subscription = this.agentUpdatesSubscription;
698
+ if (!subscription || record.internal) {
699
+ return;
700
+ }
701
+ const payload = this.buildStoredAgentPayload(record);
702
+ const project = await this.buildProjectPlacement(payload.cwd);
703
+ const matches = this.matchesAgentFilter({
704
+ agent: payload,
705
+ project,
706
+ filter: subscription.filter,
707
+ });
708
+ if (matches) {
709
+ this.bufferOrEmitAgentUpdate(subscription, {
710
+ kind: 'upsert',
711
+ agent: payload,
712
+ project,
713
+ });
714
+ return;
715
+ }
716
+ this.bufferOrEmitAgentUpdate(subscription, {
717
+ kind: 'remove',
718
+ agentId: payload.id,
719
+ });
720
+ }
721
+ catch (error) {
722
+ this.sessionLogger.error({ err: error, agentId: record.id }, 'Failed to emit stored agent update');
723
+ }
724
+ }
635
725
  /**
636
726
  * Main entry point for processing session messages
637
727
  */
@@ -651,7 +741,10 @@ export class Session {
651
741
  await this.handleDeleteAgentRequest(msg.agentId, msg.requestId);
652
742
  break;
653
743
  case 'archive_agent_request':
654
- await this.handleArchiveAgentRequest(msg.agentId, msg.requestId);
744
+ await this.handleArchiveAgentRequest(msg.agentId, msg.requestId, msg.dirtyWorktreeBehavior ?? 'reject');
745
+ break;
746
+ case 'unarchive_agent_request':
747
+ await this.handleUnarchiveAgentRequest(msg.agentId, msg.requestId);
655
748
  break;
656
749
  case 'update_agent_request':
657
750
  await this.handleUpdateAgentRequest(msg.agentId, msg.name, msg.labels, msg.requestId);
@@ -821,13 +914,15 @@ export class Session {
821
914
  const requestId = msg.requestId;
822
915
  if (typeof requestId === 'string') {
823
916
  try {
917
+ const message = error instanceof SessionRequestError ? error.message : 'Request failed';
918
+ const code = error instanceof SessionRequestError ? error.code : 'handler_error';
824
919
  this.emit({
825
920
  type: 'rpc_error',
826
921
  payload: {
827
922
  requestId,
828
923
  requestType: msg.type,
829
- error: 'Request failed',
830
- code: 'handler_error',
924
+ error: message,
925
+ code,
831
926
  },
832
927
  });
833
928
  }
@@ -976,9 +1071,10 @@ export class Session {
976
1071
  });
977
1072
  }
978
1073
  }
979
- async handleArchiveAgentRequest(agentId, requestId) {
1074
+ async handleArchiveAgentRequest(agentId, requestId, dirtyWorktreeBehavior) {
980
1075
  this.sessionLogger.info({ agentId }, `Archiving agent ${agentId}`);
981
- if (this.agentManager.getAgent(agentId)) {
1076
+ const liveAgent = this.agentManager.getAgent(agentId);
1077
+ if (liveAgent) {
982
1078
  await this.interruptAgentIfRunning(agentId);
983
1079
  }
984
1080
  const archivedAt = new Date().toISOString();
@@ -997,12 +1093,27 @@ export class Session {
997
1093
  throw new Error(`Agent not found in storage after snapshot: ${agentId}`);
998
1094
  }
999
1095
  }
1096
+ const worktreeArchiveTarget = await this.resolveWorktreeArchiveTargetAfterAgentArchive({
1097
+ archivedAgentId: agentId,
1098
+ archivedAgentCwd: archivedRecord.cwd,
1099
+ });
1100
+ if (worktreeArchiveTarget &&
1101
+ dirtyWorktreeBehavior !== 'discard' &&
1102
+ (await this.hasDirtyWorktreeState(worktreeArchiveTarget.targetPath))) {
1103
+ throw new SessionRequestError(DIRTY_WORKTREE_CONFIRMATION_REQUIRED, 'Archiving this agent will discard uncommitted or untracked files in the worktree.');
1104
+ }
1000
1105
  archivedRecord = {
1001
1106
  ...archivedRecord,
1002
1107
  archivedAt,
1108
+ archivedWorktree: await this.buildArchivedWorktreeState(archivedRecord.cwd, archivedAt),
1003
1109
  };
1004
1110
  await this.agentStorage.upsert(archivedRecord);
1005
- this.agentManager.notifyAgentState(agentId);
1111
+ if (liveAgent) {
1112
+ this.agentManager.notifyAgentState(agentId);
1113
+ }
1114
+ else {
1115
+ await this.forwardStoredAgentRecordUpdate(archivedRecord);
1116
+ }
1006
1117
  this.emit({
1007
1118
  type: 'agent_archived',
1008
1119
  payload: {
@@ -1011,11 +1122,124 @@ export class Session {
1011
1122
  requestId,
1012
1123
  },
1013
1124
  });
1014
- await this.maybeArchiveWorktreeAfterLastAgentArchived({
1015
- archivedAgentId: agentId,
1016
- archivedAgentCwd: archivedRecord.cwd,
1017
- requestId,
1125
+ if (worktreeArchiveTarget) {
1126
+ try {
1127
+ await this.archiveJunctionWorktree({
1128
+ targetPath: worktreeArchiveTarget.targetPath,
1129
+ repoRoot: worktreeArchiveTarget.repoRoot,
1130
+ requestId,
1131
+ });
1132
+ }
1133
+ catch (error) {
1134
+ this.sessionLogger.warn({ err: error, agentId, cwd: archivedRecord.cwd }, 'Failed to auto-archive worktree after agent archive');
1135
+ }
1136
+ }
1137
+ }
1138
+ async handleUnarchiveAgentRequest(agentId, requestId) {
1139
+ this.sessionLogger.info({ agentId }, `Unarchiving agent ${agentId}`);
1140
+ const record = await this.agentStorage.get(agentId);
1141
+ if (!record) {
1142
+ throw new Error(`Agent not found: ${agentId}`);
1143
+ }
1144
+ let nextRecord = {
1145
+ ...record,
1146
+ archivedAt: null,
1147
+ };
1148
+ let restoredWorktree = null;
1149
+ if (record.archivedWorktree?.cleanupState === 'deleted') {
1150
+ restoredWorktree = await restoreInRepoWorktree({
1151
+ repoRoot: record.archivedWorktree.repoRoot,
1152
+ baseBranch: record.archivedWorktree.baseBranch,
1153
+ branchName: record.archivedWorktree.branchName,
1154
+ worktreeSlug: record.archivedWorktree.worktreeSlug,
1155
+ runSetup: false,
1156
+ });
1157
+ nextRecord = {
1158
+ ...nextRecord,
1159
+ cwd: restoredWorktree.worktreePath,
1160
+ archivedWorktree: {
1161
+ ...record.archivedWorktree,
1162
+ originalCwd: restoredWorktree.worktreePath,
1163
+ cleanupState: 'active',
1164
+ cleanedUpAt: null,
1165
+ },
1166
+ };
1167
+ }
1168
+ await this.agentStorage.upsert(nextRecord);
1169
+ const liveAgent = this.agentManager.getAgent(agentId);
1170
+ if (liveAgent) {
1171
+ this.agentManager.notifyAgentState(agentId);
1172
+ }
1173
+ else {
1174
+ await this.forwardStoredAgentRecordUpdate(nextRecord);
1175
+ }
1176
+ this.emit({
1177
+ type: 'agent_unarchived',
1178
+ payload: {
1179
+ agentId,
1180
+ requestId,
1181
+ },
1018
1182
  });
1183
+ if (restoredWorktree) {
1184
+ void runAsyncWorktreeBootstrap({
1185
+ agentId,
1186
+ worktree: restoredWorktree,
1187
+ terminalManager: this.terminalManager,
1188
+ appendTimelineItem: (item) => appendTimelineItemIfAgentKnown({
1189
+ agentManager: this.agentManager,
1190
+ agentId,
1191
+ item,
1192
+ }),
1193
+ emitLiveTimelineItem: (item) => emitLiveTimelineItemIfAgentKnown({
1194
+ agentManager: this.agentManager,
1195
+ agentId,
1196
+ item,
1197
+ }),
1198
+ logger: this.sessionLogger,
1199
+ });
1200
+ }
1201
+ }
1202
+ async buildArchivedWorktreeState(cwd, archivedAt) {
1203
+ try {
1204
+ const ownership = await isJunctionOwnedWorktreeCwd(cwd, {
1205
+ junctionHome: this.junctionHome,
1206
+ });
1207
+ if (!ownership.allowed || !ownership.repoRoot) {
1208
+ return null;
1209
+ }
1210
+ const resolvedWorktree = await resolveJunctionWorktreeRootForCwd(cwd, {
1211
+ junctionHome: this.junctionHome,
1212
+ });
1213
+ if (!resolvedWorktree) {
1214
+ return null;
1215
+ }
1216
+ const metadata = readJunctionWorktreeMetadata(resolvedWorktree.worktreePath);
1217
+ if (!metadata?.baseRefName) {
1218
+ return null;
1219
+ }
1220
+ const { stdout } = await execAsync('git branch --show-current', {
1221
+ cwd: resolvedWorktree.worktreePath,
1222
+ env: READ_ONLY_GIT_ENV,
1223
+ });
1224
+ const branchName = stdout.trim();
1225
+ if (!branchName) {
1226
+ return null;
1227
+ }
1228
+ return {
1229
+ repoRoot: ownership.repoRoot,
1230
+ baseBranch: metadata.baseRefName,
1231
+ branchName,
1232
+ worktreeSlug: basename(resolvedWorktree.worktreePath),
1233
+ originalCwd: resolvedWorktree.worktreePath,
1234
+ cleanupState: 'active',
1235
+ archivedAt,
1236
+ cleanedUpAt: null,
1237
+ };
1238
+ }
1239
+ catch (error) {
1240
+ this.sessionLogger.warn({ err: error, cwd }, 'Failed to capture archived worktree restore metadata');
1241
+ return null;
1242
+ }
1019
1243
  }
1020
1244
  async getArchivedAt(agentId) {
1021
1245
  const record = await this.agentStorage.get(agentId);
@@ -1095,6 +1319,11 @@ export class Session {
1095
1319
  */
1096
1320
  async handleSendAgentMessage(agentId, text, messageId, images, runOptions) {
1097
1321
  this.sessionLogger.info({ agentId, textPreview: text.substring(0, 50), imageCount: images?.length ?? 0 }, `Sending text to agent ${agentId}${images && images.length > 0 ? ` with ${images.length} image attachment(s)` : ''}`);
1322
+ const archivedAt = await this.getArchivedAt(agentId);
1323
+ if (archivedAt) {
1324
+ this.handleAgentRunError(agentId, new Error(`Agent ${agentId} is archived`), 'Refusing to send prompt to archived agent');
1325
+ return;
1326
+ }
1098
1327
  try {
1099
1328
  await this.ensureAgentLoaded(agentId);
1100
1329
  }
@@ -1102,11 +1331,6 @@ export class Session {
1102
1331
  this.handleAgentRunError(agentId, error, 'Failed to initialize agent before sending prompt');
1103
1332
  return;
1104
1333
  }
1105
- const archivedAt = await this.getArchivedAt(agentId);
1106
- if (archivedAt) {
1107
- this.handleAgentRunError(agentId, new Error(`Agent ${agentId} is archived`), 'Refusing to send prompt to archived agent');
1108
- return;
1109
- }
1110
1334
  try {
1111
1335
  await this.interruptAgentIfRunning(agentId);
1112
1336
  }
@@ -2695,58 +2919,59 @@ export class Session {
2695
2919
  });
2696
2920
  }
2697
2921
  }
2698
- async maybeArchiveWorktreeAfterLastAgentArchived(options) {
2699
- try {
2700
- const ownership = await isJunctionOwnedWorktreeCwd(options.archivedAgentCwd, {
2701
- junctionHome: this.junctionHome,
2702
- });
2703
- if (!ownership.allowed) {
2704
- return;
2705
- }
2706
- const resolvedWorktree = await resolveJunctionWorktreeRootForCwd(options.archivedAgentCwd, {
2707
- junctionHome: this.junctionHome,
2708
- });
2709
- if (!resolvedWorktree) {
2710
- return;
2711
- }
2712
- const records = await this.agentStorage.list();
2713
- const recordsById = new Map(records.map((record) => [record.id, record]));
2714
- const targetPath = resolvedWorktree.worktreePath;
2715
- const hasRemainingNonArchivedRecord = records.some((record) => {
2716
- if (record.id === options.archivedAgentId || record.archivedAt) {
2717
- return false;
2718
- }
2719
- return this.isPathWithinRoot(targetPath, record.cwd);
2720
- });
2721
- if (hasRemainingNonArchivedRecord) {
2722
- return;
2922
+ async resolveWorktreeArchiveTargetAfterAgentArchive(options) {
2923
+ const ownership = await isJunctionOwnedWorktreeCwd(options.archivedAgentCwd, {
2924
+ junctionHome: this.junctionHome,
2925
+ });
2926
+ if (!ownership.allowed) {
2927
+ return null;
2928
+ }
2929
+ const resolvedWorktree = await resolveJunctionWorktreeRootForCwd(options.archivedAgentCwd, {
2930
+ junctionHome: this.junctionHome,
2931
+ });
2932
+ if (!resolvedWorktree) {
2933
+ return null;
2934
+ }
2935
+ const records = await this.agentStorage.list();
2936
+ const recordsById = new Map(records.map((record) => [record.id, record]));
2937
+ const targetPath = resolvedWorktree.worktreePath;
2938
+ const hasRemainingNonArchivedRecord = records.some((record) => {
2939
+ if (record.id === options.archivedAgentId || record.archivedAt) {
2940
+ return false;
2723
2941
  }
2724
- const hasUnknownLiveAgent = this.agentManager.listAgents().some((agent) => {
2725
- if (agent.id === options.archivedAgentId) {
2726
- return false;
2727
- }
2728
- if (!this.isPathWithinRoot(targetPath, agent.cwd)) {
2729
- return false;
2730
- }
2731
- return !recordsById.has(agent.id);
2732
- });
2733
- if (hasUnknownLiveAgent) {
2734
- return;
2942
+ return this.isPathWithinRoot(targetPath, record.cwd);
2943
+ });
2944
+ if (hasRemainingNonArchivedRecord) {
2945
+ return null;
2946
+ }
2947
+ const hasUnknownLiveAgent = this.agentManager.listAgents().some((agent) => {
2948
+ if (agent.id === options.archivedAgentId) {
2949
+ return false;
2735
2950
  }
2736
- const repoRoot = ownership.repoRoot;
2737
- if (!repoRoot) {
2738
- this.sessionLogger.warn({ agentId: options.archivedAgentId, worktreePath: targetPath }, 'Unable to resolve repo root for auto-archive after agent archive');
2739
- return;
2951
+ if (!this.isPathWithinRoot(targetPath, agent.cwd)) {
2952
+ return false;
2740
2953
  }
2741
- await this.archiveJunctionWorktree({
2742
- targetPath,
2743
- repoRoot,
2744
- requestId: options.requestId,
2745
- });
2954
+ return !recordsById.has(agent.id);
2955
+ });
2956
+ if (hasUnknownLiveAgent) {
2957
+ return null;
2746
2958
  }
2747
- catch (error) {
2748
- this.sessionLogger.warn({ err: error, agentId: options.archivedAgentId, cwd: options.archivedAgentCwd }, 'Failed to auto-archive worktree after agent archive');
2959
+ const repoRoot = ownership.repoRoot;
2960
+ if (!repoRoot) {
2961
+ this.sessionLogger.warn({ agentId: options.archivedAgentId, worktreePath: targetPath }, 'Unable to resolve repo root for auto-archive after agent archive');
2962
+ return null;
2749
2963
  }
2964
+ return {
2965
+ targetPath,
2966
+ repoRoot,
2967
+ };
2968
+ }
2969
+ async hasDirtyWorktreeState(cwd) {
2970
+ const { stdout } = await execAsync('git status --porcelain', {
2971
+ cwd,
2972
+ env: READ_ONLY_GIT_ENV,
2973
+ });
2974
+ return stdout.trim().length > 0;
2750
2975
  }
2751
2976
  async archiveJunctionWorktree(options) {
2752
2977
  let targetPath = options.targetPath;
@@ -2757,16 +2982,32 @@ export class Session {
2757
2982
  targetPath = resolvedWorktree.worktreePath;
2758
2983
  }
2759
2984
  const removedAgents = new Set();
2985
+ const preservedArchivedRecords = new Map();
2986
+ const cleanedUpAt = new Date().toISOString();
2760
2987
  const agents = this.agentManager.listAgents();
2761
2988
  for (const agent of agents) {
2762
2989
  if (this.isPathWithinRoot(targetPath, agent.cwd)) {
2763
- removedAgents.add(agent.id);
2764
2990
  try {
2765
2991
  await this.agentManager.closeAgent(agent.id);
2766
2992
  }
2767
2993
  catch {
2768
2994
  // ignore cleanup errors
2769
2995
  }
2996
+ const record = await this.agentStorage.get(agent.id);
2997
+ if (record?.archivedAt) {
2998
+ preservedArchivedRecords.set(agent.id, {
2999
+ ...record,
3000
+ archivedWorktree: record.archivedWorktree
3001
+ ? {
3002
+ ...record.archivedWorktree,
3003
+ cleanupState: 'deleted',
3004
+ cleanedUpAt,
3005
+ }
3006
+ : record.archivedWorktree,
3007
+ });
3008
+ continue;
3009
+ }
3010
+ removedAgents.add(agent.id);
2770
3011
  try {
2771
3012
  await this.agentStorage.remove(agent.id);
2772
3013
  }
@@ -2778,6 +3019,19 @@ export class Session {
2778
3019
  const registryRecords = await this.agentStorage.list();
2779
3020
  for (const record of registryRecords) {
2780
3021
  if (this.isPathWithinRoot(targetPath, record.cwd)) {
3022
+ if (record.archivedAt) {
3023
+ preservedArchivedRecords.set(record.id, {
3024
+ ...record,
3025
+ archivedWorktree: record.archivedWorktree
3026
+ ? {
3027
+ ...record.archivedWorktree,
3028
+ cleanupState: 'deleted',
3029
+ cleanedUpAt,
3030
+ }
3031
+ : record.archivedWorktree,
3032
+ });
3033
+ continue;
3034
+ }
2781
3035
  removedAgents.add(record.id);
2782
3036
  try {
2783
3037
  await this.agentStorage.remove(record.id);
@@ -2793,6 +3047,9 @@ export class Session {
2793
3047
  worktreePath: targetPath,
2794
3048
  junctionHome: this.junctionHome,
2795
3049
  });
3050
+ for (const record of preservedArchivedRecords.values()) {
3051
+ await this.agentStorage.upsert(record);
3052
+ }
2796
3053
  for (const agentId of removedAgents) {
2797
3054
  this.emit({
2798
3055
  type: 'agent_deleted',
@@ -3496,14 +3753,38 @@ export class Session {
3496
3753
  }
3497
3754
  : undefined;
3498
3755
  try {
3499
- const snapshot = await this.ensureAgentLoaded(msg.agentId);
3500
- let timeline = this.agentManager.fetchTimeline(msg.agentId, {
3501
- direction,
3502
- cursor,
3503
- limit: shouldLimitByProjectedWindow && typeof requestedLimit === 'number'
3504
- ? Math.max(1, Math.floor(requestedLimit))
3505
- : limit,
3506
- });
3756
+ const liveAgent = this.agentManager.getAgent(msg.agentId);
3757
+ const storedRecord = liveAgent ? null : await this.agentStorage.get(msg.agentId);
3758
+ const fetchLimit = shouldLimitByProjectedWindow && typeof requestedLimit === 'number'
3759
+ ? Math.max(1, Math.floor(requestedLimit))
3760
+ : limit;
3761
+ let provider;
3762
+ let timeline;
3763
+ if (liveAgent) {
3764
+ provider = liveAgent.provider;
3765
+ timeline = this.agentManager.fetchTimeline(msg.agentId, {
3766
+ direction,
3767
+ cursor,
3768
+ limit: fetchLimit,
3769
+ });
3770
+ }
3771
+ else if (storedRecord?.archivedAt) {
3772
+ provider = coerceAgentProvider(this.sessionLogger, storedRecord.provider, storedRecord.id);
3773
+ timeline = this.fetchStoredTimeline(storedRecord, {
3774
+ direction,
3775
+ cursor,
3776
+ limit: fetchLimit,
3777
+ });
3778
+ }
3779
+ else {
3780
+ const snapshot = await this.ensureAgentLoaded(msg.agentId);
3781
+ provider = snapshot.provider;
3782
+ timeline = this.agentManager.fetchTimeline(msg.agentId, {
3783
+ direction,
3784
+ cursor,
3785
+ limit: fetchLimit,
3786
+ });
3787
+ }
3507
3788
  let hasOlder = timeline.hasOlder;
3508
3789
  let hasNewer = timeline.hasNewer;
3509
3790
  let startCursor = null;
@@ -3511,10 +3792,10 @@ export class Session {
3511
3792
  let entries;
3512
3793
  if (shouldLimitByProjectedWindow) {
3513
3794
  const projectedLimit = Math.max(1, Math.floor(requestedLimit));
3514
- let fetchLimit = projectedLimit;
3795
+ let projectedFetchLimit = projectedLimit;
3515
3796
  let projectedWindow = selectTimelineWindowByProjectedLimit({
3516
3797
  rows: timeline.rows,
3517
- provider: snapshot.provider,
3798
+ provider,
3518
3799
  direction,
3519
3800
  limit: projectedLimit,
3520
3801
  collapseToolLifecycle: false,
@@ -3531,26 +3812,33 @@ export class Session {
3531
3812
  break;
3532
3813
  }
3533
3814
  const maxRows = Math.max(0, timeline.window.maxSeq - timeline.window.minSeq + 1);
3534
- const nextFetchLimit = Math.min(maxRows, fetchLimit * 2);
3535
- if (nextFetchLimit <= fetchLimit) {
3815
+ const nextFetchLimit = Math.min(maxRows, projectedFetchLimit * 2);
3816
+ if (nextFetchLimit <= projectedFetchLimit) {
3536
3817
  break;
3537
3818
  }
3538
- fetchLimit = nextFetchLimit;
3539
- timeline = this.agentManager.fetchTimeline(msg.agentId, {
3540
- direction,
3541
- cursor,
3542
- limit: fetchLimit,
3543
- });
3819
+ projectedFetchLimit = nextFetchLimit;
3820
+ timeline =
3821
+ storedRecord?.archivedAt && !liveAgent
3822
+ ? this.fetchStoredTimeline(storedRecord, {
3823
+ direction,
3824
+ cursor,
3825
+ limit: projectedFetchLimit,
3826
+ })
3827
+ : this.agentManager.fetchTimeline(msg.agentId, {
3828
+ direction,
3829
+ cursor,
3830
+ limit: projectedFetchLimit,
3831
+ });
3544
3832
  projectedWindow = selectTimelineWindowByProjectedLimit({
3545
3833
  rows: timeline.rows,
3546
- provider: snapshot.provider,
3834
+ provider,
3547
3835
  direction,
3548
3836
  limit: projectedLimit,
3549
3837
  collapseToolLifecycle: false,
3550
3838
  });
3551
3839
  }
3552
3840
  const selectedRows = projectedWindow.selectedRows;
3553
- entries = projectTimelineRows(selectedRows, snapshot.provider, projection);
3841
+ entries = projectTimelineRows(selectedRows, provider, projection);
3554
3842
  if (projectedWindow.minSeq !== null && projectedWindow.maxSeq !== null) {
3555
3843
  startCursor = { epoch: timeline.epoch, seq: projectedWindow.minSeq };
3556
3844
  endCursor = { epoch: timeline.epoch, seq: projectedWindow.maxSeq };
@@ -3563,7 +3851,7 @@ export class Session {
3563
3851
  const lastRow = timeline.rows[timeline.rows.length - 1];
3564
3852
  startCursor = firstRow ? { epoch: timeline.epoch, seq: firstRow.seq } : null;
3565
3853
  endCursor = lastRow ? { epoch: timeline.epoch, seq: lastRow.seq } : null;
3566
- entries = projectTimelineRows(timeline.rows, snapshot.provider, projection);
3854
+ entries = projectTimelineRows(timeline.rows, provider, projection);
3567
3855
  }
3568
3856
  this.emit({
3569
3857
  type: 'fetch_agent_timeline_response',