aicodeman 1.5.0 → 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (143) hide show
  1. package/README.md +10 -8
  2. package/README.zh-CN.md +289 -92
  3. package/dist/docker-export.d.ts +13 -0
  4. package/dist/docker-export.d.ts.map +1 -1
  5. package/dist/docker-export.js +55 -8
  6. package/dist/docker-export.js.map +1 -1
  7. package/dist/docker-hosts.d.ts +36 -5
  8. package/dist/docker-hosts.d.ts.map +1 -1
  9. package/dist/docker-hosts.js +90 -16
  10. package/dist/docker-hosts.js.map +1 -1
  11. package/dist/remote-hosts.d.ts +61 -1
  12. package/dist/remote-hosts.d.ts.map +1 -1
  13. package/dist/remote-hosts.js +142 -0
  14. package/dist/remote-hosts.js.map +1 -1
  15. package/dist/remote-reconnect.d.ts +129 -0
  16. package/dist/remote-reconnect.d.ts.map +1 -0
  17. package/dist/remote-reconnect.js +119 -0
  18. package/dist/remote-reconnect.js.map +1 -0
  19. package/dist/services/unified-session-service.d.ts +13 -1
  20. package/dist/services/unified-session-service.d.ts.map +1 -1
  21. package/dist/services/unified-session-service.js +88 -3
  22. package/dist/services/unified-session-service.js.map +1 -1
  23. package/dist/session-order.d.ts +40 -0
  24. package/dist/session-order.d.ts.map +1 -0
  25. package/dist/session-order.js +67 -0
  26. package/dist/session-order.js.map +1 -0
  27. package/dist/session.d.ts +39 -0
  28. package/dist/session.d.ts.map +1 -1
  29. package/dist/session.js +87 -19
  30. package/dist/session.js.map +1 -1
  31. package/dist/state-store.d.ts +12 -0
  32. package/dist/state-store.d.ts.map +1 -1
  33. package/dist/state-store.js +33 -0
  34. package/dist/state-store.js.map +1 -1
  35. package/dist/tmux-manager.d.ts +66 -0
  36. package/dist/tmux-manager.d.ts.map +1 -1
  37. package/dist/tmux-manager.js +260 -8
  38. package/dist/tmux-manager.js.map +1 -1
  39. package/dist/types/app-state.d.ts +2 -0
  40. package/dist/types/app-state.d.ts.map +1 -1
  41. package/dist/types/app-state.js.map +1 -1
  42. package/dist/types/session.d.ts +43 -1
  43. package/dist/types/session.d.ts.map +1 -1
  44. package/dist/types/session.js +1 -1
  45. package/dist/types/session.js.map +1 -1
  46. package/dist/web/public/admin-ui.js.gz +0 -0
  47. package/dist/web/public/api-client.c9b1cddc.js.gz +0 -0
  48. package/dist/web/public/app.b78b0465.js +38 -0
  49. package/dist/web/public/app.b78b0465.js.br +0 -0
  50. package/dist/web/public/app.b78b0465.js.gz +0 -0
  51. package/dist/web/public/{constants.5ce97313.js → constants.21a0a83e.js} +10 -0
  52. package/dist/web/public/constants.21a0a83e.js.br +0 -0
  53. package/dist/web/public/{constants.5ce97313.js.gz → constants.21a0a83e.js.gz} +0 -0
  54. package/dist/web/public/cron-ui.js.gz +0 -0
  55. package/dist/web/public/image-input.ee16ad88.js.gz +0 -0
  56. package/dist/web/public/index.html +32 -13
  57. package/dist/web/public/index.html.br +0 -0
  58. package/dist/web/public/index.html.gz +0 -0
  59. package/dist/web/public/input-cjk.63794d0b.js.gz +0 -0
  60. package/dist/web/public/keyboard-accessory.3f91acef.js.gz +0 -0
  61. package/dist/web/public/mobile-handlers.b5a667ef.js.gz +0 -0
  62. package/dist/web/public/mobile.597136a6.css +1 -0
  63. package/dist/web/public/mobile.597136a6.css.br +0 -0
  64. package/dist/web/public/mobile.597136a6.css.gz +0 -0
  65. package/dist/web/public/notification-manager.ca9dc40d.js.gz +0 -0
  66. package/dist/web/public/orchestrator-panel.js.gz +0 -0
  67. package/dist/web/public/{panels-ui.3c9b0465.js → panels-ui.58582d4c.js} +2 -2
  68. package/dist/web/public/panels-ui.58582d4c.js.br +0 -0
  69. package/dist/web/public/panels-ui.58582d4c.js.gz +0 -0
  70. package/dist/web/public/ralph-panel.6de2d0f8.js.gz +0 -0
  71. package/dist/web/public/ralph-wizard.13a1831e.js.gz +0 -0
  72. package/dist/web/public/respawn-ui.2d249da9.js.gz +0 -0
  73. package/dist/web/public/sanitize-html.bc7078d6.js.gz +0 -0
  74. package/dist/web/public/session-ui.0cf86dd4.js +68 -0
  75. package/dist/web/public/session-ui.0cf86dd4.js.br +0 -0
  76. package/dist/web/public/session-ui.0cf86dd4.js.gz +0 -0
  77. package/dist/web/public/{settings-ui.29214cf1.js → settings-ui.bc5f4792.js} +2 -2
  78. package/dist/web/public/settings-ui.bc5f4792.js.br +0 -0
  79. package/dist/web/public/settings-ui.bc5f4792.js.gz +0 -0
  80. package/dist/web/public/styles.7295e6f4.css +1 -0
  81. package/dist/web/public/styles.7295e6f4.css.br +0 -0
  82. package/dist/web/public/styles.7295e6f4.css.gz +0 -0
  83. package/dist/web/public/subagent-windows.69b60c35.js.gz +0 -0
  84. package/dist/web/public/sw.js.gz +0 -0
  85. package/dist/web/public/terminal-ui.9ea677e9.js +3 -0
  86. package/dist/web/public/terminal-ui.9ea677e9.js.br +0 -0
  87. package/dist/web/public/terminal-ui.9ea677e9.js.gz +0 -0
  88. package/dist/web/public/ultracode-panel.js.gz +0 -0
  89. package/dist/web/public/ultracode-windows.js.gz +0 -0
  90. package/dist/web/public/upload.html.gz +0 -0
  91. package/dist/web/public/vendor/dompurify.min.js.gz +0 -0
  92. package/dist/web/public/vendor/marked.min.js.gz +0 -0
  93. package/dist/web/public/vendor/xterm-addon-fit.min.js.gz +0 -0
  94. package/dist/web/public/vendor/xterm-addon-serialize.min.js.gz +0 -0
  95. package/dist/web/public/vendor/xterm-addon-unicode11.min.js.gz +0 -0
  96. package/dist/web/public/vendor/xterm-addon-webgl.min.js.gz +0 -0
  97. package/dist/web/public/vendor/xterm-zerolag-input.137ad9f0.js.gz +0 -0
  98. package/dist/web/public/vendor/xterm.css.gz +0 -0
  99. package/dist/web/public/vendor/xterm.min.js.gz +0 -0
  100. package/dist/web/public/voice-input.085e9e73.js.gz +0 -0
  101. package/dist/web/routes/case-routes.d.ts +2 -2
  102. package/dist/web/routes/case-routes.d.ts.map +1 -1
  103. package/dist/web/routes/case-routes.js +75 -21
  104. package/dist/web/routes/case-routes.js.map +1 -1
  105. package/dist/web/routes/hook-event-routes.d.ts.map +1 -1
  106. package/dist/web/routes/hook-event-routes.js +9 -0
  107. package/dist/web/routes/hook-event-routes.js.map +1 -1
  108. package/dist/web/routes/session-routes.d.ts.map +1 -1
  109. package/dist/web/routes/session-routes.js +172 -7
  110. package/dist/web/routes/session-routes.js.map +1 -1
  111. package/dist/web/schemas.d.ts +14 -0
  112. package/dist/web/schemas.d.ts.map +1 -1
  113. package/dist/web/schemas.js +42 -0
  114. package/dist/web/schemas.js.map +1 -1
  115. package/dist/web/server.d.ts +9 -0
  116. package/dist/web/server.d.ts.map +1 -1
  117. package/dist/web/server.js +57 -1
  118. package/dist/web/server.js.map +1 -1
  119. package/dist/web/sse-events.d.ts +18 -0
  120. package/dist/web/sse-events.d.ts.map +1 -1
  121. package/dist/web/sse-events.js +21 -0
  122. package/dist/web/sse-events.js.map +1 -1
  123. package/package.json +1 -1
  124. package/dist/web/public/app.6c4b9893.js +0 -38
  125. package/dist/web/public/app.6c4b9893.js.br +0 -0
  126. package/dist/web/public/app.6c4b9893.js.gz +0 -0
  127. package/dist/web/public/constants.5ce97313.js.br +0 -0
  128. package/dist/web/public/mobile.0644e3ac.css +0 -1
  129. package/dist/web/public/mobile.0644e3ac.css.br +0 -0
  130. package/dist/web/public/mobile.0644e3ac.css.gz +0 -0
  131. package/dist/web/public/panels-ui.3c9b0465.js.br +0 -0
  132. package/dist/web/public/panels-ui.3c9b0465.js.gz +0 -0
  133. package/dist/web/public/session-ui.4851c61a.js +0 -58
  134. package/dist/web/public/session-ui.4851c61a.js.br +0 -0
  135. package/dist/web/public/session-ui.4851c61a.js.gz +0 -0
  136. package/dist/web/public/settings-ui.29214cf1.js.br +0 -0
  137. package/dist/web/public/settings-ui.29214cf1.js.gz +0 -0
  138. package/dist/web/public/styles.9e6cdb9d.css +0 -1
  139. package/dist/web/public/styles.9e6cdb9d.css.br +0 -0
  140. package/dist/web/public/styles.9e6cdb9d.css.gz +0 -0
  141. package/dist/web/public/terminal-ui.efda6083.js +0 -3
  142. package/dist/web/public/terminal-ui.efda6083.js.br +0 -0
  143. package/dist/web/public/terminal-ui.efda6083.js.gz +0 -0
@@ -12,7 +12,8 @@ import { randomBytes } from 'node:crypto';
12
12
  import { ApiErrorCode, createErrorResponse, getErrorMessage, } from '../../types.js';
13
13
  import { Session, isAltScreenStripMode } from '../../session.js';
14
14
  import { SseEvent } from '../sse-events.js';
15
- import { CreateSessionSchema, SessionNameSchema, SessionColorSchema, RunPromptSchema, SessionInputWithLimitSchema, ResizeSchema, AutoClearSchema, AutoCompactSchema, AutoResumeSchema, ImageWatcherSchema, FlickerFilterSchema, QuickRunSchema, QuickStartSchema, InteractiveStartSchema, } from '../schemas.js';
15
+ import { CreateSessionSchema, SessionNameSchema, SessionColorSchema, RunPromptSchema, SessionInputWithLimitSchema, ResizeSchema, AutoClearSchema, AutoCompactSchema, AutoResumeSchema, PinSessionSchema, ImageWatcherSchema, FlickerFilterSchema, QuickRunSchema, QuickStartSchema, InteractiveStartSchema, SessionOrderUpdateSchema, } from '../schemas.js';
16
+ import { mergeSessionOrder } from '../../session-order.js';
16
17
  import { autoConfigureRalph, canAccessOwned, CASES_DIR, findSessionOrFail, getAuthUser, isAdmin, isWorkingDirAllowed, ownerFor, parseBody, persistAndBroadcastSession, resolveCasesDir, sessionCapacityMessage, SETTINGS_PATH, validatePathWithinBase, } from '../route-helpers.js';
17
18
  import { canUsernameRunPrivilegedCommands, resolveClaudeModeForUsername } from '../../user-store.js';
18
19
  import { isMultiUserMode } from '../../config/multiuser.js';
@@ -27,8 +28,8 @@ import { RunSummaryTracker } from '../../run-summary.js';
27
28
  import { MAX_INPUT_LENGTH, MAX_SESSION_NAME_LENGTH } from '../../config/terminal-limits.js';
28
29
  import { MAX_PASTE_IMAGE_BYTES } from '../../config/buffer-limits.js';
29
30
  import { dataPath, getDataDir } from '../../config/instance.js';
30
- import { checkRemoteTmuxAvailable, readRemoteCases, readRemoteHosts, toSessionRemote } from '../../remote-hosts.js';
31
- import { checkDockerAvailable, checkDockerTmuxAvailable, ensureAgentBaseImage, DEFAULT_AGENT_IMAGE, readDockerCases, readDockerHosts, toSessionDocker, } from '../../docker-hosts.js';
31
+ import { checkRemoteTmuxAvailable, readRemoteCases, readRemoteHosts, toAttachedSessionRemote, toSessionRemote, } from '../../remote-hosts.js';
32
+ import { checkDockerAvailable, checkDockerConfigDrift, checkDockerTmuxAvailable, ensureAgentBaseImage, DEFAULT_AGENT_IMAGE, persistDockerCaseClaudeSessionId, readDockerCases, readDockerHosts, toSessionDocker, } from '../../docker-hosts.js';
32
33
  import { LRUMap } from '../../utils/lru-map.js';
33
34
  // Path to linked-cases registry (same file used by case-routes resolveCasePath)
34
35
  const LINKED_CASES_FILE = dataPath('linked-cases.json');
@@ -244,6 +245,16 @@ export function registerSessionRoutes(app, ctx) {
244
245
  return list;
245
246
  return list.filter((s) => canAccessOwned(user, s.owner));
246
247
  });
248
+ // ========== Session Tab Order (global sync, COD-131) ==========
249
+ app.put('/api/session-order', async (req) => {
250
+ const { order } = parseBody(SessionOrderUpdateSchema, req.body, 'Invalid session order');
251
+ // Server is authoritative but never drops ids it knows about that the
252
+ // pushing device hadn't loaded yet — those fall to the end (mergeSessionOrder).
253
+ const merged = mergeSessionOrder(order, ctx.store.getSessionOrder());
254
+ ctx.store.setSessionOrder(merged);
255
+ ctx.broadcast(SseEvent.SessionOrderChanged, { order: merged });
256
+ return { success: true, data: { order: merged } };
257
+ });
247
258
  // ========== Session Creation ==========
248
259
  app.post('/api/sessions', async (req) => {
249
260
  const owner = ownerFor(req);
@@ -252,7 +263,21 @@ export function registerSessionRoutes(app, ctx) {
252
263
  if (capMsg)
253
264
  return createErrorResponse(ApiErrorCode.OPERATION_FAILED, capMsg);
254
265
  const body = parseBody(CreateSessionSchema, req.body);
255
- const workingDir = body.workingDir || process.cwd();
266
+ let workingDir = body.workingDir || process.cwd();
267
+ let remote = undefined;
268
+ // COD-105 — attach to a discovered (non-owned) remote tmux session. The
269
+ // remote session is already running, so we skip the tmux-prereq probe and
270
+ // build a NON-owned SessionRemote (detach-not-kill on close). Remote CASE
271
+ // creation (owned durable sessions) is handled by the dedicated case-create
272
+ // endpoint below, which #145 consolidated remote-host resolution into.
273
+ if (body.attachRemoteSession) {
274
+ const { hostId, remoteSessionName } = body.attachRemoteSession;
275
+ const host = (await readRemoteHosts(CODEMAN_CONFIG_DIR)).find((item) => item.id === hostId);
276
+ if (!host)
277
+ return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Remote host not found');
278
+ workingDir = `${host.username}@${host.host}:${remoteSessionName}`;
279
+ remote = toAttachedSessionRemote(host, remoteSessionName, workingDir);
280
+ }
256
281
  // Multi-user: shell mode is arbitrary command execution as the host account,
257
282
  // gated behind the same grant as bypass (section 6.3). Resolve the owner's grant
258
283
  // from the store so a GRANTED regular user is not wrongly denied (AuthUser role alone can't tell).
@@ -407,6 +432,7 @@ export function registerSessionRoutes(app, ctx) {
407
432
  envOverrides: body.envOverrides,
408
433
  effort: body.effort,
409
434
  tmuxHistoryLimit: terminalHistoryConfig.tmuxHistoryLimit,
435
+ remote,
410
436
  owner,
411
437
  });
412
438
  ctx.addSession(session);
@@ -821,6 +847,10 @@ export function registerSessionRoutes(app, ctx) {
821
847
  const activeId = await resolveActiveClaudeSessionIdFromHistory(session, projectsDir);
822
848
  if (activeId && activeId !== session.claudeSessionId) {
823
849
  session.adoptClaudeSessionId(activeId);
850
+ // Docker sessions: keep the case's resume seed following the live conversation.
851
+ if (session.docker) {
852
+ void persistDockerCaseClaudeSessionId(CODEMAN_CONFIG_DIR, session.docker.containerName, activeId).catch(() => { });
853
+ }
824
854
  }
825
855
  // The Claude conversation ID (used as JSONL filename)
826
856
  const claudeSessionId = session.claudeSessionId || session.id;
@@ -1399,6 +1429,45 @@ export function registerSessionRoutes(app, ctx) {
1399
1429
  },
1400
1430
  };
1401
1431
  });
1432
+ // ========== Pin (float to top of the session manager list, COD-139) ==========
1433
+ app.post('/api/sessions/:id/pin', async (req) => {
1434
+ const { id } = req.params;
1435
+ const body = parseBody(PinSessionSchema, req.body, 'Invalid request body');
1436
+ const session = ctx.sessions.get(id);
1437
+ if (session) {
1438
+ if (!canAccessOwned(getAuthUser(req), session.owner)) {
1439
+ return createErrorResponse(ApiErrorCode.NOT_FOUND, `Session ${id} not found`);
1440
+ }
1441
+ session.setPinned(body.pinned);
1442
+ // Persist + broadcast session:updated (keeps tabs/state consistent), then a
1443
+ // dedicated session:pinned event so the session manager list re-sorts live.
1444
+ persistAndBroadcastSession(ctx, session);
1445
+ ctx.broadcast(SseEvent.SessionPinned, {
1446
+ id,
1447
+ pinned: session.pinned,
1448
+ pinnedAt: session.pinnedAt ?? undefined,
1449
+ });
1450
+ return {
1451
+ success: true,
1452
+ data: {
1453
+ pinned: session.pinned,
1454
+ pinnedAt: session.pinnedAt ?? undefined,
1455
+ },
1456
+ };
1457
+ }
1458
+ // COD-142 keeps a pinned session's record after kill (demoteOrRemoveSession),
1459
+ // so pin toggles must also work WITHOUT a live Session — otherwise a
1460
+ // pinned-then-killed record could never be unpinned (cleanup skips pinned
1461
+ // records, and the record has no live session to route through).
1462
+ const persisted = ctx.store.getSession(id);
1463
+ if (!persisted || !canAccessOwned(getAuthUser(req), persisted.owner)) {
1464
+ return createErrorResponse(ApiErrorCode.NOT_FOUND, `Session ${id} not found`);
1465
+ }
1466
+ const pinnedAt = body.pinned ? Date.now() : undefined;
1467
+ ctx.store.setSession(id, { ...persisted, pinned: body.pinned || undefined, pinnedAt });
1468
+ ctx.broadcast(SseEvent.SessionPinned, { id, pinned: body.pinned, pinnedAt });
1469
+ return { success: true, data: { pinned: body.pinned, pinnedAt } };
1470
+ });
1402
1471
  // ========== Image Watcher ==========
1403
1472
  app.post('/api/sessions/:id/image-watcher', async (req) => {
1404
1473
  const { id } = req.params;
@@ -1505,7 +1574,7 @@ export function registerSessionRoutes(app, ctx) {
1505
1574
  const capMsg = sessionCapacityMessage(ctx.sessions, owner);
1506
1575
  if (capMsg)
1507
1576
  return createErrorResponse(ApiErrorCode.SESSION_BUSY, capMsg);
1508
- const { caseName = 'testcase', sessionName, mode = 'claude', openCodeConfig, codexConfig, geminiConfig, envOverrides, effort, } = parseBody(QuickStartSchema, req.body);
1577
+ const { caseName = 'testcase', sessionName, mode = 'claude', modelOverride, openCodeConfig, codexConfig, geminiConfig, envOverrides, effort, } = parseBody(QuickStartSchema, req.body);
1509
1578
  // Multi-user: shell mode is arbitrary host-account execution, gated by the grant.
1510
1579
  // Resolve the owner's grant from the store so a GRANTED regular user is not wrongly denied.
1511
1580
  if (mode === 'shell' && !(await canUsernameRunPrivilegedCommands(owner))) {
@@ -1538,10 +1607,11 @@ export function registerSessionRoutes(app, ctx) {
1538
1607
  // remote command/env customization goes through the per-host command override.
1539
1608
  if ((envOverrides && Object.keys(envOverrides).length > 0) ||
1540
1609
  effort ||
1610
+ modelOverride !== undefined ||
1541
1611
  codexConfig ||
1542
1612
  geminiConfig ||
1543
1613
  openCodeConfig) {
1544
- return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'envOverrides, effort, and per-CLI config are not supported for remote cases (they do not cross ssh). Configure the remote command via the host command override instead.');
1614
+ return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'envOverrides, effort, modelOverride, and per-CLI config are not supported for remote cases (they do not cross ssh). Configure the remote command via the host command override instead.');
1545
1615
  }
1546
1616
  // tmux is a hard prerequisite on the remote host (the agent runs inside a remote
1547
1617
  // tmux server so it survives ssh drops). Probe before spawning so a missing tmux
@@ -1576,7 +1646,7 @@ export function registerSessionRoutes(app, ctx) {
1576
1646
  // Ensure the base image exists, auto-building the default image on first use so
1577
1647
  // it is never a blocker. Dedup'd with any build kicked off at case-create, so
1578
1648
  // this awaits the SAME in-flight build rather than starting a second one.
1579
- const ensured = await ensureAgentBaseImage(sessionDocker.engine, sessionDocker.image, {
1649
+ const ensured = await ensureAgentBaseImage(sessionDocker, sessionDocker.image, {
1580
1650
  onProgress: (line) => ctx.broadcast(SseEvent.DockerImageBuildProgress, { name: dockerCase.name, line }),
1581
1651
  });
1582
1652
  if (!ensured.ok) {
@@ -1594,6 +1664,15 @@ export function registerSessionRoutes(app, ctx) {
1594
1664
  return createErrorResponse(ApiErrorCode.OPERATION_FAILED, tmuxCheck.error || 'base image is missing tmux');
1595
1665
  }
1596
1666
  }
1667
+ // Config drift (docs/docker-cases-plan.md §4): the desired create-config no
1668
+ // longer matches the existing container's codeman.confighash label. Refuse to
1669
+ // silently launch into the stale container — the frontend confirms a recreate
1670
+ // (POST /api/docker-cases/:name/recreate; workspace + transcripts ride bind
1671
+ // mounts and the conversation resumes), or the user reverts the host edit.
1672
+ const drift = await checkDockerConfigDrift(sessionDocker);
1673
+ if (drift.exists && drift.drifted) {
1674
+ return createErrorResponse(ApiErrorCode.CONFLICT, `Container config for case "${dockerCase.name}" changed since the container was created. Recreate the container to apply it (workspace and conversation survive), or revert the docker host edit.`);
1675
+ }
1597
1676
  casePath = dockerCase.hostWorkspacePath; // a REAL host dir (bind-mounted into the container)
1598
1677
  docker = sessionDocker;
1599
1678
  // Seed resume so a relaunch resumes the case's last conversation from the
@@ -1704,6 +1783,12 @@ export function registerSessionRoutes(app, ctx) {
1704
1783
  /* non-fatal — the session still runs, hooks may be degraded */
1705
1784
  }
1706
1785
  }
1786
+ // Model override → <case>/.claude/settings.local.json (claude-mode; local AND
1787
+ // docker — the docker workspace is a real host dir, so the settings file crosses
1788
+ // the bind mount and the in-container claude reads it). Remote was rejected above.
1789
+ if (mode === 'claude' && modelOverride !== undefined) {
1790
+ await updateCaseModel(resolvedCasePath, modelOverride || null);
1791
+ }
1707
1792
  // Strip stale disk entries for keys this request is actively setting (Claude only —
1708
1793
  // see POST /api/sessions for full rationale).
1709
1794
  if (mode !== 'opencode' &&
@@ -1797,6 +1882,14 @@ export function registerSessionRoutes(app, ctx) {
1797
1882
  ctx.broadcast(SseEvent.SessionInteractive, { id: session.id, mode });
1798
1883
  }
1799
1884
  ctx.broadcast(SseEvent.SessionUpdated, { session: ctx.getSessionStateWithRespawn(session) });
1885
+ // Docker + claude: the pane command pins the conversation id (--session-id /
1886
+ // --resume, claudeDockerPaneCommand), so persist it as the case's resume seed
1887
+ // NOW — a later container stop/reboot relaunch resumes this conversation even
1888
+ // if no in-container hook ever reaches the host (loopback bind, no bridge
1889
+ // listener). Hook/last-response adoption updates it again after /clear.
1890
+ if (docker && mode === 'claude') {
1891
+ void persistDockerCaseClaudeSessionId(CODEMAN_CONFIG_DIR, docker.containerName, session.claudeSessionId || session.id).catch(() => { });
1892
+ }
1800
1893
  // Save lastUsedCase to settings for TUI/web sync
1801
1894
  try {
1802
1895
  const settingsFilePath = SETTINGS_PATH;
@@ -1891,6 +1984,63 @@ export function registerSessionRoutes(app, ctx) {
1891
1984
  }
1892
1985
  return undefined;
1893
1986
  }
1987
+ /**
1988
+ * Extract the text of the LAST user message from a JSONL transcript chunk
1989
+ * (COD-145). Mirrors `extractFirstUserPrompt` exactly — same user-message
1990
+ * detection, same noise/secret/slash-command filters, same 120-char cap — but
1991
+ * keeps the last qualifying match instead of returning on the first. Scan the
1992
+ * file tail for this (the most recent prompt lives near the end).
1993
+ */
1994
+ function extractLastUserPrompt(text) {
1995
+ const MAX_PROMPT_LEN = 120;
1996
+ let result;
1997
+ let start = 0;
1998
+ while (start < text.length) {
1999
+ const end = text.indexOf('\n', start);
2000
+ const line = end === -1 ? text.slice(start) : text.slice(start, end);
2001
+ start = end === -1 ? text.length : end + 1;
2002
+ if (!line.includes('"type":"user"'))
2003
+ continue;
2004
+ try {
2005
+ const entry = JSON.parse(line);
2006
+ if (entry.type !== 'user' || !entry.message)
2007
+ continue;
2008
+ const content = entry.message.content;
2009
+ let msgText;
2010
+ if (typeof content === 'string') {
2011
+ msgText = content;
2012
+ }
2013
+ else if (Array.isArray(content)) {
2014
+ const textBlock = content.find((b) => b.type === 'text');
2015
+ if (textBlock)
2016
+ msgText = textBlock.text;
2017
+ }
2018
+ if (!msgText)
2019
+ continue;
2020
+ msgText = msgText
2021
+ .replace(/<[^>]+>/g, '')
2022
+ .replace(new RegExp(String.raw `\x1b\[[0-9;]*[a-zA-Z]`, 'g'), '')
2023
+ .trim()
2024
+ .replace(/\s+/g, ' ');
2025
+ if (!msgText)
2026
+ continue;
2027
+ if (/^(Caveat:|init\b|clear\b|resume\b|\/[a-z][\w-]*\b|You are a |\[Request |Set model to )/i.test(msgText) ||
2028
+ /^(Please )?(analyze|review) this codebase/i.test(msgText) ||
2029
+ /^(Read|Implement the following) .+, then (search|list|check) /i.test(msgText) ||
2030
+ /^\d+ vulnerabilit/i.test(msgText) ||
2031
+ /\btoolu_/.test(msgText) ||
2032
+ /^[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]+/.test(msgText) ||
2033
+ /\b(sk-ant-|ANTHROPIC_API_KEY|API_KEY=|SECRET|TOKEN=)/i.test(msgText) ||
2034
+ msgText.length < 8)
2035
+ continue;
2036
+ result = msgText.length > MAX_PROMPT_LEN ? msgText.slice(0, MAX_PROMPT_LEN) + '…' : msgText;
2037
+ }
2038
+ catch {
2039
+ // Malformed line — skip
2040
+ }
2041
+ }
2042
+ return result;
2043
+ }
1894
2044
  /**
1895
2045
  * Decode a Claude project key (e.g. "-Users-teigen-Documents-Workspace-AI-project-Mirror")
1896
2046
  * back to a filesystem path ("/Users/teigen/Documents/Workspace/AI_project/Mirror").
@@ -2068,6 +2218,15 @@ export function registerSessionRoutes(app, ctx) {
2068
2218
  if (tail)
2069
2219
  firstPrompt = extractFirstUserPrompt(tail);
2070
2220
  }
2221
+ // COD-145: last (most recent) user prompt lives near the END of the file, so
2222
+ // prefer the tail. For large files where no tail was read yet, read one
2223
+ // (mirrors the firstPrompt > 65536 block). Small files fit in `head`, which
2224
+ // then contains the whole transcript — scan it for the last match instead.
2225
+ if (!tail && fileStat.size > 65536) {
2226
+ const tailBuf = Buffer.alloc(32768);
2227
+ tail = await readFileTail(filePath, tailBuf, fileStat.size);
2228
+ }
2229
+ const lastPrompt = (tail ? extractLastUserPrompt(tail) : undefined) ?? (head ? extractLastUserPrompt(head) : undefined);
2071
2230
  out.push({
2072
2231
  sessionId,
2073
2232
  workingDir,
@@ -2075,6 +2234,7 @@ export function registerSessionRoutes(app, ctx) {
2075
2234
  sizeBytes: fileStat.size,
2076
2235
  lastModified: fileStat.mtime.toISOString(),
2077
2236
  firstPrompt,
2237
+ lastPrompt,
2078
2238
  });
2079
2239
  }
2080
2240
  return out;
@@ -2146,6 +2306,8 @@ export function registerSessionRoutes(app, ctx) {
2146
2306
  createdAt: st.createdAt,
2147
2307
  lastActivityAt: st.lastActivityAt,
2148
2308
  claudeSessionId: s.claudeSessionId ?? undefined,
2309
+ pinned: st.pinned,
2310
+ pinnedAt: st.pinnedAt,
2149
2311
  };
2150
2312
  });
2151
2313
  // Persisted sessions (state.json). resumeSessionId is the Claude
@@ -2160,6 +2322,8 @@ export function registerSessionRoutes(app, ctx) {
2160
2322
  createdAt: p.createdAt,
2161
2323
  lastActivityAt: p.lastActivityAt,
2162
2324
  claudeSessionId: p.resumeSessionId,
2325
+ pinned: p.pinned,
2326
+ pinnedAt: p.pinnedAt,
2163
2327
  }));
2164
2328
  // Lifecycle audit log (newest-first, capped).
2165
2329
  let lifecycle = [];
@@ -2192,6 +2356,7 @@ export function registerSessionRoutes(app, ctx) {
2192
2356
  sizeBytes: h.sizeBytes,
2193
2357
  lastModified: h.lastModified,
2194
2358
  firstPrompt: h.firstPrompt,
2359
+ lastPrompt: h.lastPrompt,
2195
2360
  projectKey: h.projectKey,
2196
2361
  });
2197
2362
  }