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.
- package/README.md +10 -8
- package/README.zh-CN.md +289 -92
- package/dist/docker-export.d.ts +13 -0
- package/dist/docker-export.d.ts.map +1 -1
- package/dist/docker-export.js +55 -8
- package/dist/docker-export.js.map +1 -1
- package/dist/docker-hosts.d.ts +36 -5
- package/dist/docker-hosts.d.ts.map +1 -1
- package/dist/docker-hosts.js +90 -16
- package/dist/docker-hosts.js.map +1 -1
- package/dist/remote-hosts.d.ts +61 -1
- package/dist/remote-hosts.d.ts.map +1 -1
- package/dist/remote-hosts.js +142 -0
- package/dist/remote-hosts.js.map +1 -1
- package/dist/remote-reconnect.d.ts +129 -0
- package/dist/remote-reconnect.d.ts.map +1 -0
- package/dist/remote-reconnect.js +119 -0
- package/dist/remote-reconnect.js.map +1 -0
- package/dist/services/unified-session-service.d.ts +13 -1
- package/dist/services/unified-session-service.d.ts.map +1 -1
- package/dist/services/unified-session-service.js +88 -3
- package/dist/services/unified-session-service.js.map +1 -1
- package/dist/session-order.d.ts +40 -0
- package/dist/session-order.d.ts.map +1 -0
- package/dist/session-order.js +67 -0
- package/dist/session-order.js.map +1 -0
- package/dist/session.d.ts +39 -0
- package/dist/session.d.ts.map +1 -1
- package/dist/session.js +87 -19
- package/dist/session.js.map +1 -1
- package/dist/state-store.d.ts +12 -0
- package/dist/state-store.d.ts.map +1 -1
- package/dist/state-store.js +33 -0
- package/dist/state-store.js.map +1 -1
- package/dist/tmux-manager.d.ts +66 -0
- package/dist/tmux-manager.d.ts.map +1 -1
- package/dist/tmux-manager.js +260 -8
- package/dist/tmux-manager.js.map +1 -1
- package/dist/types/app-state.d.ts +2 -0
- package/dist/types/app-state.d.ts.map +1 -1
- package/dist/types/app-state.js.map +1 -1
- package/dist/types/session.d.ts +43 -1
- package/dist/types/session.d.ts.map +1 -1
- package/dist/types/session.js +1 -1
- package/dist/types/session.js.map +1 -1
- package/dist/web/public/admin-ui.js.gz +0 -0
- package/dist/web/public/api-client.c9b1cddc.js.gz +0 -0
- package/dist/web/public/app.b78b0465.js +38 -0
- package/dist/web/public/app.b78b0465.js.br +0 -0
- package/dist/web/public/app.b78b0465.js.gz +0 -0
- package/dist/web/public/{constants.5ce97313.js → constants.21a0a83e.js} +10 -0
- package/dist/web/public/constants.21a0a83e.js.br +0 -0
- package/dist/web/public/{constants.5ce97313.js.gz → constants.21a0a83e.js.gz} +0 -0
- package/dist/web/public/cron-ui.js.gz +0 -0
- package/dist/web/public/image-input.ee16ad88.js.gz +0 -0
- package/dist/web/public/index.html +32 -13
- package/dist/web/public/index.html.br +0 -0
- package/dist/web/public/index.html.gz +0 -0
- package/dist/web/public/input-cjk.63794d0b.js.gz +0 -0
- package/dist/web/public/keyboard-accessory.3f91acef.js.gz +0 -0
- package/dist/web/public/mobile-handlers.b5a667ef.js.gz +0 -0
- package/dist/web/public/mobile.597136a6.css +1 -0
- package/dist/web/public/mobile.597136a6.css.br +0 -0
- package/dist/web/public/mobile.597136a6.css.gz +0 -0
- package/dist/web/public/notification-manager.ca9dc40d.js.gz +0 -0
- package/dist/web/public/orchestrator-panel.js.gz +0 -0
- package/dist/web/public/{panels-ui.3c9b0465.js → panels-ui.58582d4c.js} +2 -2
- package/dist/web/public/panels-ui.58582d4c.js.br +0 -0
- package/dist/web/public/panels-ui.58582d4c.js.gz +0 -0
- package/dist/web/public/ralph-panel.6de2d0f8.js.gz +0 -0
- package/dist/web/public/ralph-wizard.13a1831e.js.gz +0 -0
- package/dist/web/public/respawn-ui.2d249da9.js.gz +0 -0
- package/dist/web/public/sanitize-html.bc7078d6.js.gz +0 -0
- package/dist/web/public/session-ui.0cf86dd4.js +68 -0
- package/dist/web/public/session-ui.0cf86dd4.js.br +0 -0
- package/dist/web/public/session-ui.0cf86dd4.js.gz +0 -0
- package/dist/web/public/{settings-ui.29214cf1.js → settings-ui.bc5f4792.js} +2 -2
- package/dist/web/public/settings-ui.bc5f4792.js.br +0 -0
- package/dist/web/public/settings-ui.bc5f4792.js.gz +0 -0
- package/dist/web/public/styles.7295e6f4.css +1 -0
- package/dist/web/public/styles.7295e6f4.css.br +0 -0
- package/dist/web/public/styles.7295e6f4.css.gz +0 -0
- package/dist/web/public/subagent-windows.69b60c35.js.gz +0 -0
- package/dist/web/public/sw.js.gz +0 -0
- package/dist/web/public/terminal-ui.9ea677e9.js +3 -0
- package/dist/web/public/terminal-ui.9ea677e9.js.br +0 -0
- package/dist/web/public/terminal-ui.9ea677e9.js.gz +0 -0
- package/dist/web/public/ultracode-panel.js.gz +0 -0
- package/dist/web/public/ultracode-windows.js.gz +0 -0
- package/dist/web/public/upload.html.gz +0 -0
- package/dist/web/public/vendor/dompurify.min.js.gz +0 -0
- package/dist/web/public/vendor/marked.min.js.gz +0 -0
- package/dist/web/public/vendor/xterm-addon-fit.min.js.gz +0 -0
- package/dist/web/public/vendor/xterm-addon-serialize.min.js.gz +0 -0
- package/dist/web/public/vendor/xterm-addon-unicode11.min.js.gz +0 -0
- package/dist/web/public/vendor/xterm-addon-webgl.min.js.gz +0 -0
- package/dist/web/public/vendor/xterm-zerolag-input.137ad9f0.js.gz +0 -0
- package/dist/web/public/vendor/xterm.css.gz +0 -0
- package/dist/web/public/vendor/xterm.min.js.gz +0 -0
- package/dist/web/public/voice-input.085e9e73.js.gz +0 -0
- package/dist/web/routes/case-routes.d.ts +2 -2
- package/dist/web/routes/case-routes.d.ts.map +1 -1
- package/dist/web/routes/case-routes.js +75 -21
- package/dist/web/routes/case-routes.js.map +1 -1
- package/dist/web/routes/hook-event-routes.d.ts.map +1 -1
- package/dist/web/routes/hook-event-routes.js +9 -0
- package/dist/web/routes/hook-event-routes.js.map +1 -1
- package/dist/web/routes/session-routes.d.ts.map +1 -1
- package/dist/web/routes/session-routes.js +172 -7
- package/dist/web/routes/session-routes.js.map +1 -1
- package/dist/web/schemas.d.ts +14 -0
- package/dist/web/schemas.d.ts.map +1 -1
- package/dist/web/schemas.js +42 -0
- package/dist/web/schemas.js.map +1 -1
- package/dist/web/server.d.ts +9 -0
- package/dist/web/server.d.ts.map +1 -1
- package/dist/web/server.js +57 -1
- package/dist/web/server.js.map +1 -1
- package/dist/web/sse-events.d.ts +18 -0
- package/dist/web/sse-events.d.ts.map +1 -1
- package/dist/web/sse-events.js +21 -0
- package/dist/web/sse-events.js.map +1 -1
- package/package.json +1 -1
- package/dist/web/public/app.6c4b9893.js +0 -38
- package/dist/web/public/app.6c4b9893.js.br +0 -0
- package/dist/web/public/app.6c4b9893.js.gz +0 -0
- package/dist/web/public/constants.5ce97313.js.br +0 -0
- package/dist/web/public/mobile.0644e3ac.css +0 -1
- package/dist/web/public/mobile.0644e3ac.css.br +0 -0
- package/dist/web/public/mobile.0644e3ac.css.gz +0 -0
- package/dist/web/public/panels-ui.3c9b0465.js.br +0 -0
- package/dist/web/public/panels-ui.3c9b0465.js.gz +0 -0
- package/dist/web/public/session-ui.4851c61a.js +0 -58
- package/dist/web/public/session-ui.4851c61a.js.br +0 -0
- package/dist/web/public/session-ui.4851c61a.js.gz +0 -0
- package/dist/web/public/settings-ui.29214cf1.js.br +0 -0
- package/dist/web/public/settings-ui.29214cf1.js.gz +0 -0
- package/dist/web/public/styles.9e6cdb9d.css +0 -1
- package/dist/web/public/styles.9e6cdb9d.css.br +0 -0
- package/dist/web/public/styles.9e6cdb9d.css.gz +0 -0
- package/dist/web/public/terminal-ui.efda6083.js +0 -3
- package/dist/web/public/terminal-ui.efda6083.js.br +0 -0
- 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
|
-
|
|
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
|
|
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
|
}
|