aicodeman 1.41.1 → 1.43.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 +54 -24
- package/dist/claude-session-title.d.ts +6 -4
- package/dist/claude-session-title.d.ts.map +1 -1
- package/dist/claude-session-title.js +6 -4
- package/dist/claude-session-title.js.map +1 -1
- package/dist/cli-agent.js +1 -1
- package/dist/cli-agent.js.map +1 -1
- package/dist/config/cli-registry/patterns.d.ts +1 -1
- package/dist/config/cli-registry/patterns.d.ts.map +1 -1
- package/dist/config/cli-registry/patterns.js +3 -0
- package/dist/config/cli-registry/patterns.js.map +1 -1
- package/dist/config/cli-registry/schema.d.ts +3 -0
- package/dist/config/cli-registry/schema.d.ts.map +1 -1
- package/dist/config/cli-registry/schema.js +19 -3
- package/dist/config/cli-registry/schema.js.map +1 -1
- package/dist/config/cli-registry/stock.d.ts.map +1 -1
- package/dist/config/cli-registry/stock.js +302 -7
- package/dist/config/cli-registry/stock.js.map +1 -1
- package/dist/config/cli-registry/types.d.ts +37 -3
- package/dist/config/cli-registry/types.d.ts.map +1 -1
- package/dist/git-worktrees.d.ts +292 -0
- package/dist/git-worktrees.d.ts.map +1 -0
- package/dist/git-worktrees.js +791 -0
- package/dist/git-worktrees.js.map +1 -0
- package/dist/hooks-config.d.ts +9 -0
- package/dist/hooks-config.d.ts.map +1 -1
- package/dist/hooks-config.js +12 -0
- package/dist/hooks-config.js.map +1 -1
- package/dist/mcp-server.d.ts.map +1 -1
- package/dist/mcp-server.js +2 -1
- package/dist/mcp-server.js.map +1 -1
- package/dist/mcp-sync-targets.d.ts +6 -3
- package/dist/mcp-sync-targets.d.ts.map +1 -1
- package/dist/mcp-sync-targets.js +6 -3
- package/dist/mcp-sync-targets.js.map +1 -1
- package/dist/mux-interface.d.ts +7 -3
- package/dist/mux-interface.d.ts.map +1 -1
- package/dist/services/unified-session-service.d.ts +8 -0
- package/dist/services/unified-session-service.d.ts.map +1 -1
- package/dist/services/unified-session-service.js +21 -0
- package/dist/services/unified-session-service.js.map +1 -1
- package/dist/session-activity.d.ts +51 -0
- package/dist/session-activity.d.ts.map +1 -1
- package/dist/session-activity.js +62 -0
- package/dist/session-activity.js.map +1 -1
- package/dist/session-cli-registry-bridge.d.ts +2 -1
- package/dist/session-cli-registry-bridge.d.ts.map +1 -1
- package/dist/session-cli-registry-bridge.js.map +1 -1
- package/dist/session-submit-verifier.js +1 -1
- package/dist/session.d.ts +84 -9
- package/dist/session.d.ts.map +1 -1
- package/dist/session.js +232 -18
- package/dist/session.js.map +1 -1
- package/dist/tmux-manager.d.ts +62 -7
- package/dist/tmux-manager.d.ts.map +1 -1
- package/dist/tmux-manager.js +98 -20
- package/dist/tmux-manager.js.map +1 -1
- package/dist/types/session.d.ts +32 -4
- package/dist/types/session.d.ts.map +1 -1
- package/dist/types/session.js +2 -1
- package/dist/types/session.js.map +1 -1
- package/dist/utils/cli-installed-probes.d.ts +1 -1
- package/dist/utils/cli-installed-probes.d.ts.map +1 -1
- package/dist/utils/cli-installed-probes.js +4 -2
- package/dist/utils/cli-installed-probes.js.map +1 -1
- package/dist/utils/copilot-cli-resolver.d.ts +48 -0
- package/dist/utils/copilot-cli-resolver.d.ts.map +1 -0
- package/dist/utils/copilot-cli-resolver.js +112 -0
- package/dist/utils/copilot-cli-resolver.js.map +1 -0
- package/dist/utils/deepseek-cli-resolver.d.ts.map +1 -1
- package/dist/utils/deepseek-cli-resolver.js +1 -1
- package/dist/utils/deepseek-cli-resolver.js.map +1 -1
- package/dist/utils/index.d.ts +1 -0
- package/dist/utils/index.d.ts.map +1 -1
- package/dist/utils/index.js +1 -0
- package/dist/utils/index.js.map +1 -1
- package/dist/web/approval-inbox.d.ts +12 -2
- package/dist/web/approval-inbox.d.ts.map +1 -1
- package/dist/web/approval-inbox.js +7 -2
- package/dist/web/approval-inbox.js.map +1 -1
- package/dist/web/case-path.d.ts +4 -2
- package/dist/web/case-path.d.ts.map +1 -1
- package/dist/web/case-path.js +13 -4
- package/dist/web/case-path.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.ae50029a.js +43 -0
- package/dist/web/public/app.ae50029a.js.br +0 -0
- package/dist/web/public/app.ae50029a.js.gz +0 -0
- package/dist/web/public/approvals-ui.js.gz +0 -0
- package/dist/web/public/{constants.17d77b65.js → constants.e5e588db.js} +156 -2
- package/dist/web/public/constants.e5e588db.js.br +0 -0
- package/dist/web/public/constants.e5e588db.js.gz +0 -0
- package/dist/web/public/cron-ui.js.gz +0 -0
- package/dist/web/public/entrance-animations.js.gz +0 -0
- package/dist/web/public/git-status-ui.js.gz +0 -0
- package/dist/web/public/home-sessions.js.gz +0 -0
- package/dist/web/public/host-wake-ui.js.gz +0 -0
- package/dist/web/public/i18n.8f291f06.js +2 -0
- package/dist/web/public/i18n.8f291f06.js.br +0 -0
- package/dist/web/public/i18n.8f291f06.js.gz +0 -0
- package/dist/web/public/{image-input.9ed1d0d2.js → image-input.941386e7.js} +41 -22
- package/dist/web/public/image-input.941386e7.js.br +0 -0
- package/dist/web/public/image-input.941386e7.js.gz +0 -0
- package/dist/web/public/index.html +88 -16
- package/dist/web/public/index.html.br +0 -0
- package/dist/web/public/index.html.gz +0 -0
- package/dist/web/public/input-cjk.8bc46081.js.gz +0 -0
- package/dist/web/public/{keyboard-accessory.c458eea8.js → keyboard-accessory.c283e827.js} +40 -29
- package/dist/web/public/keyboard-accessory.c283e827.js.br +0 -0
- package/dist/web/public/keyboard-accessory.c283e827.js.gz +0 -0
- package/dist/web/public/mobile-handlers.0ef9c311.js.gz +0 -0
- package/dist/web/public/mobile-ime-preview.2dbaf262.js.gz +0 -0
- package/dist/web/public/mobile-overview.js +1 -0
- package/dist/web/public/mobile-overview.js.br +0 -0
- package/dist/web/public/mobile-overview.js.gz +0 -0
- package/dist/web/public/mobile.13514863.css +1 -0
- package/dist/web/public/mobile.13514863.css.br +0 -0
- package/dist/web/public/mobile.13514863.css.gz +0 -0
- package/dist/web/public/notification-manager.feb82c04.js.gz +0 -0
- package/dist/web/public/orchestrator-panel.js.gz +0 -0
- package/dist/web/public/{panels-ui.73ec0137.js → panels-ui.b7bfb9da.js} +6 -6
- package/dist/web/public/panels-ui.b7bfb9da.js.br +0 -0
- package/dist/web/public/panels-ui.b7bfb9da.js.gz +0 -0
- package/dist/web/public/ralph-panel.b350e2ef.js.gz +0 -0
- package/dist/web/public/ralph-wizard.b6c0dd19.js.gz +0 -0
- package/dist/web/public/readmymind-ui.js.gz +0 -0
- package/dist/web/public/reboot-restore-ui.js.gz +0 -0
- package/dist/web/public/{respawn-ui.ff0dae4c.js → respawn-ui.d7adcf6a.js} +1 -1
- package/dist/web/public/respawn-ui.d7adcf6a.js.br +0 -0
- package/dist/web/public/respawn-ui.d7adcf6a.js.gz +0 -0
- package/dist/web/public/sanitize-html.e7147e76.js.gz +0 -0
- package/dist/web/public/security-notices.json +21 -0
- package/dist/web/public/session-lineage.js.gz +0 -0
- package/dist/web/public/session-ui.be076c88.js +100 -0
- package/dist/web/public/session-ui.be076c88.js.br +0 -0
- package/dist/web/public/session-ui.be076c88.js.gz +0 -0
- package/dist/web/public/settings-ui.fc4cc50a.js +97 -0
- package/dist/web/public/settings-ui.fc4cc50a.js.br +0 -0
- package/dist/web/public/settings-ui.fc4cc50a.js.gz +0 -0
- package/dist/web/public/spreadsheet-preview-worker.js.gz +0 -0
- package/dist/web/public/spreadsheet-preview.75c751de.js.gz +0 -0
- package/dist/web/public/spreadsheet-xlsx-core.js.gz +0 -0
- package/dist/web/public/styles.dcfa907f.css +1 -0
- package/dist/web/public/styles.dcfa907f.css.br +0 -0
- package/dist/web/public/styles.dcfa907f.css.gz +0 -0
- package/dist/web/public/subagent-windows.07277a58.js.gz +0 -0
- package/dist/web/public/sw.js +2 -2
- package/dist/web/public/sw.js.br +0 -0
- package/dist/web/public/sw.js.gz +0 -0
- package/dist/web/public/tab-layout-browser.2d6189f7.js.gz +0 -0
- package/dist/web/public/tab-rail-resize.7157e0bd.js.gz +0 -0
- package/dist/web/public/terminal-keycode229-recovery.af09f99a.js.gz +0 -0
- package/dist/web/public/terminal-split.js.gz +0 -0
- package/dist/web/public/terminal-tile.js +1 -0
- package/dist/web/public/terminal-tile.js.br +0 -0
- package/dist/web/public/terminal-tile.js.gz +0 -0
- package/dist/web/public/terminal-ui.ae01a7d1.js +2 -0
- package/dist/web/public/terminal-ui.ae01a7d1.js.br +0 -0
- package/dist/web/public/terminal-ui.ae01a7d1.js.gz +0 -0
- package/dist/web/public/tile-grid.js +3 -6
- package/dist/web/public/tile-grid.js.br +0 -0
- package/dist/web/public/tile-grid.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/vendor/dompurify.min.js.gz +0 -0
- package/dist/web/public/vendor/exceljs.min.js.gz +0 -0
- package/dist/web/public/vendor/fflate.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-predictive-echo.ae5e3d5d.js.gz +0 -0
- package/dist/web/public/vendor/xterm-zerolag-input.0b69623c.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.2342c37b.js.gz +0 -0
- package/dist/web/public/voice-pcm-worklet.js.gz +0 -0
- package/dist/web/public/webview-tabs.js.gz +0 -0
- package/dist/web/public/whats-new.json +13 -0
- package/dist/web/response-viewer-transcript.d.ts +6 -0
- package/dist/web/response-viewer-transcript.d.ts.map +1 -1
- package/dist/web/response-viewer-transcript.js +134 -1
- package/dist/web/response-viewer-transcript.js.map +1 -1
- package/dist/web/routes/approval-routes.d.ts.map +1 -1
- package/dist/web/routes/approval-routes.js +12 -8
- package/dist/web/routes/approval-routes.js.map +1 -1
- package/dist/web/routes/case-routes.d.ts.map +1 -1
- package/dist/web/routes/case-routes.js +9 -2
- package/dist/web/routes/case-routes.js.map +1 -1
- package/dist/web/routes/index.d.ts +1 -0
- package/dist/web/routes/index.d.ts.map +1 -1
- package/dist/web/routes/index.js +1 -0
- package/dist/web/routes/index.js.map +1 -1
- package/dist/web/routes/mcp-sync-routes.d.ts +4 -4
- package/dist/web/routes/mcp-sync-routes.js +4 -4
- package/dist/web/routes/ralph-routes.d.ts.map +1 -1
- package/dist/web/routes/ralph-routes.js +19 -14
- package/dist/web/routes/ralph-routes.js.map +1 -1
- package/dist/web/routes/session-routes.d.ts +19 -7
- package/dist/web/routes/session-routes.d.ts.map +1 -1
- package/dist/web/routes/session-routes.js +227 -66
- package/dist/web/routes/session-routes.js.map +1 -1
- package/dist/web/routes/status-telemetry-routes.d.ts.map +1 -1
- package/dist/web/routes/status-telemetry-routes.js +9 -5
- package/dist/web/routes/status-telemetry-routes.js.map +1 -1
- package/dist/web/routes/system-routes.d.ts +9 -0
- package/dist/web/routes/system-routes.d.ts.map +1 -1
- package/dist/web/routes/system-routes.js +102 -12
- package/dist/web/routes/system-routes.js.map +1 -1
- package/dist/web/routes/worktree-routes.d.ts +52 -0
- package/dist/web/routes/worktree-routes.d.ts.map +1 -0
- package/dist/web/routes/worktree-routes.js +220 -0
- package/dist/web/routes/worktree-routes.js.map +1 -0
- package/dist/web/schemas.d.ts +30 -1
- package/dist/web/schemas.d.ts.map +1 -1
- package/dist/web/schemas.js +74 -3
- package/dist/web/schemas.js.map +1 -1
- package/dist/web/screen-blocked.d.ts +80 -0
- package/dist/web/screen-blocked.d.ts.map +1 -0
- package/dist/web/screen-blocked.js +106 -0
- package/dist/web/screen-blocked.js.map +1 -0
- package/dist/web/server.d.ts.map +1 -1
- package/dist/web/server.js +23 -2
- package/dist/web/server.js.map +1 -1
- package/dist/web/session-listener-wiring.d.ts +1 -0
- package/dist/web/session-listener-wiring.d.ts.map +1 -1
- package/dist/web/session-listener-wiring.js +20 -0
- package/dist/web/session-listener-wiring.js.map +1 -1
- package/dist/web/session-wait-registry.d.ts +27 -9
- package/dist/web/session-wait-registry.d.ts.map +1 -1
- package/dist/web/session-wait-registry.js +36 -13
- package/dist/web/session-wait-registry.js.map +1 -1
- package/dist/web/sse-events.d.ts +7 -2
- package/dist/web/sse-events.d.ts.map +1 -1
- package/dist/web/sse-events.js +7 -2
- package/dist/web/sse-events.js.map +1 -1
- package/package.json +2 -2
- package/skills/codeman/SKILL.md +21 -17
- package/skills/codeman/preamble.sh +14 -11
- package/skills/codeman/reference/endpoints.md +8 -8
- package/skills/codeman/reference/messaging.md +5 -5
- package/skills/codeman/reference/recipes.md +13 -10
- package/skills/codeman/reference/verbs.md +7 -5
- package/dist/web/public/app.24689381.js +0 -43
- package/dist/web/public/app.24689381.js.br +0 -0
- package/dist/web/public/app.24689381.js.gz +0 -0
- package/dist/web/public/constants.17d77b65.js.br +0 -0
- package/dist/web/public/constants.17d77b65.js.gz +0 -0
- package/dist/web/public/i18n.9afd8e4d.js +0 -2
- package/dist/web/public/i18n.9afd8e4d.js.br +0 -0
- package/dist/web/public/i18n.9afd8e4d.js.gz +0 -0
- package/dist/web/public/image-input.9ed1d0d2.js.br +0 -0
- package/dist/web/public/image-input.9ed1d0d2.js.gz +0 -0
- package/dist/web/public/keyboard-accessory.c458eea8.js.br +0 -0
- package/dist/web/public/keyboard-accessory.c458eea8.js.gz +0 -0
- package/dist/web/public/mobile.2dff1580.css +0 -1
- package/dist/web/public/mobile.2dff1580.css.br +0 -0
- package/dist/web/public/mobile.2dff1580.css.gz +0 -0
- package/dist/web/public/panels-ui.73ec0137.js.br +0 -0
- package/dist/web/public/panels-ui.73ec0137.js.gz +0 -0
- package/dist/web/public/respawn-ui.ff0dae4c.js.br +0 -0
- package/dist/web/public/respawn-ui.ff0dae4c.js.gz +0 -0
- package/dist/web/public/session-ui.dbc6819b.js +0 -100
- package/dist/web/public/session-ui.dbc6819b.js.br +0 -0
- package/dist/web/public/session-ui.dbc6819b.js.gz +0 -0
- package/dist/web/public/settings-ui.81ac264e.js +0 -97
- package/dist/web/public/settings-ui.81ac264e.js.br +0 -0
- package/dist/web/public/settings-ui.81ac264e.js.gz +0 -0
- package/dist/web/public/styles.900a28e4.css +0 -1
- package/dist/web/public/styles.900a28e4.css.br +0 -0
- package/dist/web/public/styles.900a28e4.css.gz +0 -0
- package/dist/web/public/terminal-ui.858f5da5.js +0 -2
- package/dist/web/public/terminal-ui.858f5da5.js.br +0 -0
- package/dist/web/public/terminal-ui.858f5da5.js.gz +0 -0
|
@@ -10,6 +10,7 @@ import { execFile } from 'node:child_process';
|
|
|
10
10
|
import fs from 'node:fs/promises';
|
|
11
11
|
import { finished } from 'node:stream/promises';
|
|
12
12
|
import { randomBytes, randomUUID } from 'node:crypto';
|
|
13
|
+
import { isUtf8 } from 'node:buffer';
|
|
13
14
|
import { performance } from 'node:perf_hooks';
|
|
14
15
|
import { ApiErrorCode, createErrorResponse, getErrorMessage, } from '../../types.js';
|
|
15
16
|
import { Session, cliTakesSessionModel, isAltScreenStripMode, isExternalCliMode, isMuxAltScreenOnlyStripMode, isMuxMouseStripMode, } from '../../session.js';
|
|
@@ -51,12 +52,13 @@ import { dataPath, getDataDir } from '../../config/instance.js';
|
|
|
51
52
|
import { checkRemoteTmuxAvailable, readRemoteCases, readRemoteHosts, rehydrateRemoteHostFields, toAttachedSessionRemote, toSessionRemote, } from '../../remote-hosts.js';
|
|
52
53
|
import { fetchRemoteScrollback } from '../../remote-pane-history.js';
|
|
53
54
|
import { checkDockerAvailable, checkDockerConfigDrift, probeAdoptableContainer, checkDockerTmuxAvailable, ensureAgentBaseImage, DEFAULT_AGENT_IMAGE, persistDockerCaseClaudeSessionId, readDockerCases, readDockerHosts, toSessionDocker, } from '../../docker-hosts.js';
|
|
55
|
+
import { resolveCaseRepository, resolveManagedWorktree } from '../../git-worktrees.js';
|
|
54
56
|
import { LRUMap } from '../../utils/lru-map.js';
|
|
55
57
|
import { describeUnknownPath, probePathKind } from '../../utils/index.js';
|
|
56
58
|
import { findLatestOmpSessionId } from '../../utils/omp-session-resolver.js';
|
|
57
59
|
import { scanOmpSessionsHistory } from '../../omp-transcript.js';
|
|
58
60
|
import { scanCodexSessionsHistory, codexThreadBySessionId } from '../../codex-transcript.js';
|
|
59
|
-
import { getLastTranscriptResponse, isExternalCliTranscriptMode, parseExternalCliTranscript, selectLastAnsweredTurn, } from '../response-viewer-transcript.js';
|
|
61
|
+
import { transcriptNeedsScreenCapture, getLastTranscriptResponse, isExternalCliTranscriptMode, parseExternalCliTranscript, selectLastAnsweredTurn, } from '../response-viewer-transcript.js';
|
|
60
62
|
import { readDeepSeekLastResponse } from '../../deepseek-transcript.js';
|
|
61
63
|
import { appendClaudeCustomTitle } from '../../claude-session-title.js';
|
|
62
64
|
import { UPLOADS_DIR } from '../paste-image-gc.js';
|
|
@@ -183,19 +185,53 @@ export function stripInkRedrawBloat(buffer) {
|
|
|
183
185
|
return parts.join('');
|
|
184
186
|
}
|
|
185
187
|
/**
|
|
186
|
-
*
|
|
187
|
-
*
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
188
|
+
* The text half of the document allowlist: these are judged on their encoding
|
|
189
|
+
* (valid UTF-8, no NUL byte) rather than on a signature, since plain text has none.
|
|
190
|
+
*/
|
|
191
|
+
const TEXT_UPLOAD_EXTS = new Set(['.txt', '.md', '.csv', '.tsv', '.json', '.log', '.yaml', '.yml', '.xml']);
|
|
192
|
+
/**
|
|
193
|
+
* Documents a user hands an agent to read: the office formats plus the text
|
|
194
|
+
* family. Curated on purpose, never SUPPORTED_ATTACHMENT_EXTENSIONS or
|
|
195
|
+
* EDITABLE_EXTENSIONS, which carry sh/py/js/html; a prompt upload is a thing
|
|
196
|
+
* to read, not a thing to run or serve. Each is judged on its bytes by
|
|
197
|
+
* imageMagicMatchesExt (signature, or UTF-8 without NUL for text). The browser
|
|
198
|
+
* keeps its own copies (test/upload-document-list-parity.test.ts pins them).
|
|
199
|
+
*/
|
|
200
|
+
export const ALLOWED_DOCUMENT_EXTS = new Set([
|
|
201
|
+
'.pdf',
|
|
202
|
+
'.docx',
|
|
203
|
+
'.pptx',
|
|
204
|
+
'.xlsx',
|
|
205
|
+
'.zip',
|
|
206
|
+
...TEXT_UPLOAD_EXTS,
|
|
207
|
+
]);
|
|
208
|
+
/**
|
|
209
|
+
* Validate upload bytes against a declared extension. Sniffs the first ~12 bytes
|
|
210
|
+
* for a known magic-number signature (a text file is instead required to be
|
|
211
|
+
* UTF-8 without a NUL byte, the one check plain text allows). Defends against
|
|
212
|
+
* polyglots (e.g. HTML or SVG disguised under a `Content-Type: image/png`
|
|
213
|
+
* header) and against simple extension-only spoofing — both the multipart
|
|
214
|
+
* filename and the Content-Type are attacker-controlled, the raw bytes are not.
|
|
215
|
+
* Fail-closed: an extension this function does not know is never a match.
|
|
191
216
|
*
|
|
192
217
|
* Signatures: https://en.wikipedia.org/wiki/List_of_file_signatures
|
|
193
218
|
*/
|
|
194
219
|
export function imageMagicMatchesExt(data, ext) {
|
|
220
|
+
if (TEXT_UPLOAD_EXTS.has(ext))
|
|
221
|
+
return data.length > 0 && !data.includes(0) && isUtf8(data);
|
|
195
222
|
if (data.length < 12)
|
|
196
223
|
return false;
|
|
197
224
|
const u32be = (off) => data.readUInt32BE(off);
|
|
198
225
|
switch (ext) {
|
|
226
|
+
case '.pdf':
|
|
227
|
+
// "%PDF-"
|
|
228
|
+
return u32be(0) === 0x25504446 && data[4] === 0x2d;
|
|
229
|
+
case '.docx':
|
|
230
|
+
case '.pptx':
|
|
231
|
+
case '.xlsx':
|
|
232
|
+
case '.zip':
|
|
233
|
+
// PK\x03\x04, the local-file header every non-empty zip (OOXML included) opens with
|
|
234
|
+
return u32be(0) === 0x504b0304;
|
|
199
235
|
case '.png':
|
|
200
236
|
return u32be(0) === 0x89504e47 && u32be(4) === 0x0d0a1a0a;
|
|
201
237
|
case '.jpg':
|
|
@@ -294,11 +330,26 @@ async function ensureUploadDir(workingDir) {
|
|
|
294
330
|
}
|
|
295
331
|
return uploadDir;
|
|
296
332
|
}
|
|
333
|
+
/**
|
|
334
|
+
* The part of an uploaded document's name that survives into the saved name,
|
|
335
|
+
* so `Q3 report (final).pdf` is findable as `paste-<ts>-<hex>-Q3-report-final.pdf`.
|
|
336
|
+
* Only `[A-Za-z0-9._-]` survive, runs of anything else become one `-`, and the
|
|
337
|
+
* result is capped at 40 chars; nothing in it can be a path separator or a
|
|
338
|
+
* second extension that matters (the allowlisted extension is appended after).
|
|
339
|
+
*/
|
|
340
|
+
function sanitizeUploadStem(filename) {
|
|
341
|
+
const stem = basename(filename).replace(/\.[^.]*$/, '');
|
|
342
|
+
return stem
|
|
343
|
+
.replace(/[^A-Za-z0-9._-]+/g, '-')
|
|
344
|
+
.replace(/^[-.]+|[-.]+$/g, '')
|
|
345
|
+
.slice(0, 40);
|
|
346
|
+
}
|
|
297
347
|
/** Open a fresh, uniquely named paste file for writing. */
|
|
298
|
-
async function openPasteFile(imageDir, ext) {
|
|
348
|
+
async function openPasteFile(imageDir, ext, stem = '') {
|
|
299
349
|
// Date.now() collides on same-ms uploads from two tabs (last-write wins
|
|
300
350
|
// silently). Append 8 hex chars so concurrent pastes get distinct names.
|
|
301
|
-
|
|
351
|
+
// The `paste-` prefix is what the GC (paste-image-gc.ts) matches on.
|
|
352
|
+
const filename = `paste-${Date.now()}-${randomBytes(4).toString('hex')}${stem ? `-${stem}` : ''}${ext}`;
|
|
302
353
|
const filepath = join(imageDir, filename);
|
|
303
354
|
// O_EXCL: refuse to overwrite (collision is impossible with random suffix,
|
|
304
355
|
// but defends against TOCTOU). O_NOFOLLOW: refuse if filepath is a symlink.
|
|
@@ -421,7 +472,7 @@ async function clampExternalCliBypassForOwner(owner, configs) {
|
|
|
421
472
|
* named individually, so `privilegedParams` on anything outside that list was declared but
|
|
422
473
|
* unreachable.
|
|
423
474
|
*/
|
|
424
|
-
export async function _clampExternalCliBypassForOwner(owner, codexConfig, geminiConfig, antigravityConfig, piConfig, grokConfig, deepSeekConfig) {
|
|
475
|
+
export async function _clampExternalCliBypassForOwner(owner, codexConfig, geminiConfig, antigravityConfig, piConfig, grokConfig, deepSeekConfig, copilotConfig) {
|
|
425
476
|
const out = await clampExternalCliBypassForOwner(owner, {
|
|
426
477
|
codexConfig,
|
|
427
478
|
geminiConfig,
|
|
@@ -429,6 +480,7 @@ export async function _clampExternalCliBypassForOwner(owner, codexConfig, gemini
|
|
|
429
480
|
piConfig,
|
|
430
481
|
grokConfig,
|
|
431
482
|
deepSeekConfig,
|
|
483
|
+
copilotConfig,
|
|
432
484
|
});
|
|
433
485
|
return out;
|
|
434
486
|
}
|
|
@@ -505,6 +557,23 @@ function currentSignalFor(session, dead) {
|
|
|
505
557
|
return 'exit';
|
|
506
558
|
return signalForStatus(session.status);
|
|
507
559
|
}
|
|
560
|
+
/**
|
|
561
|
+
* `currentSignalFor`, answering `blocked` for a session whose pane is showing a dialog
|
|
562
|
+
* its CLI is blocked on (`Session.screenBlocked`, read off the screen for a CLI with no
|
|
563
|
+
* hook: codex) when the caller asked for it. A hook-raised `blocked` is an edge with no
|
|
564
|
+
* history, but this one is a level the server is still looking at, so a caller that
|
|
565
|
+
* asks after the dialog appeared is answered at once instead of waiting out its timeout
|
|
566
|
+
* for an edge that already passed. Only when `blocked` was asked for: such a pane is also
|
|
567
|
+
* `idle`, and a caller that asked for `idle` alone must keep getting `idle`. Used by
|
|
568
|
+
* `GET .../wait` and by a DUPLICATE send-and-wait, every place a wait answers from the
|
|
569
|
+
* session's current state.
|
|
570
|
+
*/
|
|
571
|
+
function currentSignalForWait(session, dead, until) {
|
|
572
|
+
const signal = currentSignalFor(session, dead);
|
|
573
|
+
if (signal !== 'exit' && session.screenBlocked && until.includes('blocked'))
|
|
574
|
+
return 'blocked';
|
|
575
|
+
return signal;
|
|
576
|
+
}
|
|
508
577
|
// ── Worker liveness for tmux-backed sessions ────────────────────────────────
|
|
509
578
|
//
|
|
510
579
|
// `session.pid` is the LOCAL `tmux attach` client, not the worker. Codeman sets
|
|
@@ -1050,7 +1119,7 @@ options = {}) {
|
|
|
1050
1119
|
// Section 6.3: force non-granted users to a classifier-guarded mode.
|
|
1051
1120
|
const effectiveClaudeMode = await resolveClaudeModeForUsername(claudeModeConfig.claudeMode, owner);
|
|
1052
1121
|
// Section 6.3: clamp Codex/Gemini/Antigravity bypass switches for a non-granted owner (no-op single-user/granted).
|
|
1053
|
-
const { codexConfig: gatedCodexConfig, geminiConfig: gatedGeminiConfig, antigravityConfig: gatedAntigravityConfig, piConfig: gatedPiConfig, grokConfig: gatedGrokConfig, deepSeekConfig: gatedDeepSeekConfig, } = await _clampExternalCliBypassForOwner(owner, launchBody.codexConfig, launchBody.geminiConfig, launchBody.antigravityConfig, launchBody.piConfig, launchBody.grokConfig, launchBody.deepSeekConfig);
|
|
1122
|
+
const { codexConfig: gatedCodexConfig, geminiConfig: gatedGeminiConfig, antigravityConfig: gatedAntigravityConfig, piConfig: gatedPiConfig, grokConfig: gatedGrokConfig, deepSeekConfig: gatedDeepSeekConfig, copilotConfig: gatedCopilotConfig, } = await _clampExternalCliBypassForOwner(owner, launchBody.codexConfig, launchBody.geminiConfig, launchBody.antigravityConfig, launchBody.piConfig, launchBody.grokConfig, launchBody.deepSeekConfig, launchBody.copilotConfig);
|
|
1054
1123
|
const terminalHistoryConfig = await ctx.getTerminalHistoryConfig();
|
|
1055
1124
|
const session = new Session({
|
|
1056
1125
|
workingDir,
|
|
@@ -1068,6 +1137,7 @@ options = {}) {
|
|
|
1068
1137
|
antigravityConfig: mode === 'antigravity' ? gatedAntigravityConfig : undefined,
|
|
1069
1138
|
piConfig: mode === 'pi' ? gatedPiConfig : undefined,
|
|
1070
1139
|
grokConfig: mode === 'grok' ? gatedGrokConfig : undefined,
|
|
1140
|
+
copilotConfig: mode === 'copilot' ? gatedCopilotConfig : undefined,
|
|
1071
1141
|
deepSeekConfig: mode === 'deepseek' ? gatedDeepSeekConfig : undefined,
|
|
1072
1142
|
ompConfig: resolveOmpConfigForCreate(mode, workingDir, launchBody.ompConfig),
|
|
1073
1143
|
resumeSessionId: validatedResumeId,
|
|
@@ -1646,7 +1716,9 @@ options = {}) {
|
|
|
1646
1716
|
// A DUPLICATE has no new turn coming, so it answers from the current state
|
|
1647
1717
|
// instead of blocking for a transition that already happened.
|
|
1648
1718
|
requireTransition: !duplicate,
|
|
1649
|
-
|
|
1719
|
+
// The `...ForWait` variant, as on GET .../wait: a screen-read dialog the first
|
|
1720
|
+
// delivery raised is a level, and a retry asking for `blocked` must find it.
|
|
1721
|
+
currentSignal: duplicate ? currentSignalForWait(session, workerDead, until) : undefined,
|
|
1650
1722
|
});
|
|
1651
1723
|
}
|
|
1652
1724
|
catch (err) {
|
|
@@ -1663,7 +1735,7 @@ options = {}) {
|
|
|
1663
1735
|
}
|
|
1664
1736
|
const stopDeathWatch = wantsWait ? watchForDeadWorker(ctx.mux, session, id) : () => { };
|
|
1665
1737
|
// Write input to PTY. Direct write is synchronous; writeViaMux
|
|
1666
|
-
// (tmux
|
|
1738
|
+
// (tmux paste + Enter) is fire-and-forget to avoid blocking the HTTP response.
|
|
1667
1739
|
// Every write here is `fromUser`: this route carries a person's prompt, or an
|
|
1668
1740
|
// agent's on their behalf, so it may name the tab (Ralph, respawn, cron and
|
|
1669
1741
|
// approvals write through the session directly and never say so).
|
|
@@ -1685,7 +1757,7 @@ options = {}) {
|
|
|
1685
1757
|
// A plain prompt (`<text>\r`) goes through the mux even when the caller did not
|
|
1686
1758
|
// ask for it: written straight into the pane it arrives as one burst, and Claude
|
|
1687
1759
|
// Code takes a long burst as a paste whose `\r` becomes a newline, so the prompt
|
|
1688
|
-
// sat unsent (see isPlainPromptInput). The mux path
|
|
1760
|
+
// sat unsent (see isPlainPromptInput). The mux path pastes the text, presses Enter
|
|
1689
1761
|
// separately and arms the SubmitVerifier. An explicit `useMux: false` keeps the
|
|
1690
1762
|
// raw write for a caller that really wants it.
|
|
1691
1763
|
const autoMux = useMux === undefined && isPlainPromptInput(inputStr);
|
|
@@ -1833,7 +1905,7 @@ options = {}) {
|
|
|
1833
1905
|
abortSignal: abort.signal,
|
|
1834
1906
|
requireTransition: query.fresh === '1' || query.fresh === 'true',
|
|
1835
1907
|
// Read BEFORE awaiting: this is the state the caller is asking about.
|
|
1836
|
-
currentSignal:
|
|
1908
|
+
currentSignal: currentSignalForWait(session, workerIsDead(ctx.mux, session), until),
|
|
1837
1909
|
});
|
|
1838
1910
|
return {
|
|
1839
1911
|
success: true,
|
|
@@ -2363,7 +2435,13 @@ options = {}) {
|
|
|
2363
2435
|
// where a real rollout file is the better source.
|
|
2364
2436
|
if (isExternalCliTranscriptMode(session.mode)) {
|
|
2365
2437
|
const externalQuery = req.query;
|
|
2366
|
-
|
|
2438
|
+
// A repainting full-screen TUI (copilot) streams absolute cursor moves, not lines, so the
|
|
2439
|
+
// buffer cannot be segmented; read the pane's visible screen instead, and fall back to the
|
|
2440
|
+
// buffer when there is no tmux pane to ask (direct PTY, tests).
|
|
2441
|
+
const screen = transcriptNeedsScreenCapture(session.mode) && session.muxName
|
|
2442
|
+
? (ctx.mux.capturePaneText?.(session.muxName) ?? null)
|
|
2443
|
+
: null;
|
|
2444
|
+
const blocks = parseExternalCliTranscript(screen ?? session.terminalBuffer, session.mode);
|
|
2367
2445
|
return {
|
|
2368
2446
|
text: getLastTranscriptResponse(blocks),
|
|
2369
2447
|
timestamp: '',
|
|
@@ -3173,7 +3251,7 @@ options = {}) {
|
|
|
3173
3251
|
const capMsg = sessionCapacityMessage(ctx.sessions, owner);
|
|
3174
3252
|
if (capMsg)
|
|
3175
3253
|
return createErrorResponse(ApiErrorCode.SESSION_BUSY, capMsg);
|
|
3176
|
-
const { caseName = 'testcase', sessionName, mode = 'claude', modelOverride, openCodeConfig, codexConfig, geminiConfig, antigravityConfig, piConfig, grokConfig, deepSeekConfig, ompConfig, envOverrides, effort, advisorModel, parentSessionId, agentOrigin, customModel, tabGroupId, } = parseBody(QuickStartSchema, req.body);
|
|
3254
|
+
const { caseName = 'testcase', sessionName, mode = 'claude', modelOverride, openCodeConfig, codexConfig, geminiConfig, antigravityConfig, piConfig, grokConfig, copilotConfig, deepSeekConfig, ompConfig, envOverrides, effort, advisorModel, parentSessionId, agentOrigin, customModel, tabGroupId, worktree, } = parseBody(QuickStartSchema, req.body);
|
|
3177
3255
|
// Resolved ONCE here: the same value labels a case directory this request creates
|
|
3178
3256
|
// (agent-case-marker.ts) and draws the tab lineage line on the session below.
|
|
3179
3257
|
const qsParentSessionId = resolveParentSessionId(ctx, req, parentSessionId, owner);
|
|
@@ -3199,6 +3277,13 @@ options = {}) {
|
|
|
3199
3277
|
const dockerCase = remoteCase
|
|
3200
3278
|
? undefined
|
|
3201
3279
|
: (await readDockerCases(CODEMAN_CONFIG_DIR)).find((item) => item.name === caseName && canAccessOwned(getAuthUser(req), item.owner));
|
|
3280
|
+
// Worktrees are local only (src/git-worktrees.ts): refused BEFORE a remote host is woken or a
|
|
3281
|
+
// container is touched, since neither branch below could honour it.
|
|
3282
|
+
if (worktree && (remoteCase || dockerCase)) {
|
|
3283
|
+
return createErrorResponse(ApiErrorCode.INVALID_INPUT, remoteCase
|
|
3284
|
+
? 'worktree is not available for remote (SSH) cases: the repository is on another machine'
|
|
3285
|
+
: 'worktree is not available for Docker cases: git would run on this machine over a workspace the container can write');
|
|
3286
|
+
}
|
|
3202
3287
|
if (remoteCase) {
|
|
3203
3288
|
const host = (await readRemoteHosts(CODEMAN_CONFIG_DIR)).find((item) => item.id === remoteCase.hostId);
|
|
3204
3289
|
if (!host)
|
|
@@ -3216,6 +3301,7 @@ options = {}) {
|
|
|
3216
3301
|
antigravityConfig ||
|
|
3217
3302
|
piConfig ||
|
|
3218
3303
|
grokConfig ||
|
|
3304
|
+
copilotConfig ||
|
|
3219
3305
|
deepSeekConfig ||
|
|
3220
3306
|
ompConfig ||
|
|
3221
3307
|
openCodeConfig ||
|
|
@@ -3274,6 +3360,7 @@ options = {}) {
|
|
|
3274
3360
|
antigravityConfig ||
|
|
3275
3361
|
piConfig ||
|
|
3276
3362
|
grokConfig ||
|
|
3363
|
+
copilotConfig ||
|
|
3277
3364
|
deepSeekConfig ||
|
|
3278
3365
|
ompConfig ||
|
|
3279
3366
|
openCodeConfig ||
|
|
@@ -3358,6 +3445,7 @@ options = {}) {
|
|
|
3358
3445
|
antigravityConfig,
|
|
3359
3446
|
piConfig,
|
|
3360
3447
|
grokConfig,
|
|
3448
|
+
copilotConfig,
|
|
3361
3449
|
deepSeekConfig,
|
|
3362
3450
|
}));
|
|
3363
3451
|
if (qsLaunchError) {
|
|
@@ -3403,6 +3491,28 @@ options = {}) {
|
|
|
3403
3491
|
if (localCaseState === 'unknown') {
|
|
3404
3492
|
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, describeUnknownPath('Case folder', resolvedCasePath, { pastCap: true }));
|
|
3405
3493
|
}
|
|
3494
|
+
// Where the session runs: the case folder, or one of its git worktrees. A worktree is only
|
|
3495
|
+
// ever USED here (created through POST /api/cases/:name/worktrees), so a case that does not
|
|
3496
|
+
// exist yet is a 404 rather than a scaffold, and the worktree must be a registered, managed
|
|
3497
|
+
// one, every path component a real directory (src/git-worktrees.ts).
|
|
3498
|
+
let workspacePath = resolvedCasePath;
|
|
3499
|
+
if (worktree) {
|
|
3500
|
+
if (localCaseState === 'absent') {
|
|
3501
|
+
return createErrorResponse(ApiErrorCode.NOT_FOUND, `Case "${caseName}" not found`);
|
|
3502
|
+
}
|
|
3503
|
+
if (localCaseState !== 'directory') {
|
|
3504
|
+
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Case path is not a folder');
|
|
3505
|
+
}
|
|
3506
|
+
const caseRepo = await resolveCaseRepository(resolvedCasePath, {
|
|
3507
|
+
dockerWorkspaces: (await readDockerCases(CODEMAN_CONFIG_DIR)).map((c) => c.hostWorkspacePath).filter(Boolean),
|
|
3508
|
+
});
|
|
3509
|
+
if (!caseRepo.ok)
|
|
3510
|
+
return createErrorResponse(ApiErrorCode[caseRepo.code], caseRepo.message);
|
|
3511
|
+
const managed = await resolveManagedWorktree(caseRepo.repo, worktree);
|
|
3512
|
+
if (!managed.ok)
|
|
3513
|
+
return createErrorResponse(ApiErrorCode[managed.code], managed.message);
|
|
3514
|
+
workspacePath = managed.path;
|
|
3515
|
+
}
|
|
3406
3516
|
// Create case folder and CLAUDE.md if it doesn't exist (only for non-linked, non-remote,
|
|
3407
3517
|
// non-docker cases — docker workspaces are scaffolded in their own block below)
|
|
3408
3518
|
if (localCaseState === 'absent') {
|
|
@@ -3454,10 +3564,10 @@ options = {}) {
|
|
|
3454
3564
|
// of its own. Skipped for remote cases — resolvedCasePath is a REMOTE path that
|
|
3455
3565
|
// doesn't exist on the local filesystem.
|
|
3456
3566
|
if (getCli(mode)?.capabilities.hooks === 'always') {
|
|
3457
|
-
await applyWorkspaceHooks(
|
|
3567
|
+
await applyWorkspaceHooks(workspacePath, await ctx.getWorkspaceHooksEnabled());
|
|
3458
3568
|
}
|
|
3459
3569
|
else {
|
|
3460
|
-
await refreshStaleCodemanHooks(
|
|
3570
|
+
await refreshStaleCodemanHooks(workspacePath).catch(() => { });
|
|
3461
3571
|
}
|
|
3462
3572
|
}
|
|
3463
3573
|
// Agent skill injection (docs/agent-control-plan.md §2): ADD-ONLY on create,
|
|
@@ -3468,7 +3578,7 @@ options = {}) {
|
|
|
3468
3578
|
// hostWorkspacePath is a real host dir and the skill crosses the bind mount.
|
|
3469
3579
|
const quickStartSkillDir = agentSkillDir(getCli(mode)?.capabilities);
|
|
3470
3580
|
if (!remote && quickStartSkillDir && (await ctx.getAgentSkillEnabled())) {
|
|
3471
|
-
await injectAgentSkill(
|
|
3581
|
+
await injectAgentSkill(workspacePath, quickStartSkillDir);
|
|
3472
3582
|
}
|
|
3473
3583
|
// Docker cases: the workspace is a REAL host dir bind-mounted into the container.
|
|
3474
3584
|
// Scaffold hooks (+ a CLAUDE.md) if MISSING so in-container permission prompts and
|
|
@@ -3501,13 +3611,13 @@ options = {}) {
|
|
|
3501
3611
|
// docker — the docker workspace is a real host dir, so the settings file crosses
|
|
3502
3612
|
// the bind mount and the in-container claude reads it). Remote was rejected above.
|
|
3503
3613
|
if (getCli(mode)?.capabilities.model.source === 'claude-settings-file' && modelOverride !== undefined) {
|
|
3504
|
-
await updateCaseModel(
|
|
3614
|
+
await updateCaseModel(workspacePath, modelOverride || null);
|
|
3505
3615
|
}
|
|
3506
3616
|
// Strip stale disk entries for keys this request is actively setting (Claude only —
|
|
3507
3617
|
// see POST /api/sessions for full rationale).
|
|
3508
3618
|
// Same chain, same replacement as the create path above: byte-identical, drift-proof.
|
|
3509
3619
|
if (!isExternalCliMode(mode) && !remote && envOverrides && Object.keys(envOverrides).length > 0) {
|
|
3510
|
-
await stripCaseEnvKeys(
|
|
3620
|
+
await stripCaseEnvKeys(workspacePath, Object.keys(envOverrides));
|
|
3511
3621
|
}
|
|
3512
3622
|
// Create a new session with the case as working directory
|
|
3513
3623
|
// Apply global Nice priority config and model config from settings
|
|
@@ -3522,6 +3632,7 @@ options = {}) {
|
|
|
3522
3632
|
antigravityConfig,
|
|
3523
3633
|
piConfig,
|
|
3524
3634
|
grokConfig,
|
|
3635
|
+
copilotConfig,
|
|
3525
3636
|
deepSeekConfig,
|
|
3526
3637
|
ompConfig,
|
|
3527
3638
|
};
|
|
@@ -3540,10 +3651,10 @@ options = {}) {
|
|
|
3540
3651
|
const qsClaudeModeConfig = await ctx.getClaudeModeConfig();
|
|
3541
3652
|
const qsEffectiveClaudeMode = await resolveClaudeModeForUsername(qsClaudeModeConfig.claudeMode, owner);
|
|
3542
3653
|
// Section 6.3: clamp Codex/Gemini/Antigravity bypass switches for a non-granted owner (no-op single-user/granted).
|
|
3543
|
-
const { codexConfig: qsGatedCodexConfig, geminiConfig: qsGatedGeminiConfig, antigravityConfig: qsGatedAntigravityConfig, piConfig: qsGatedPiConfig, grokConfig: qsGatedGrokConfig, deepSeekConfig: qsGatedDeepSeekConfig, } = await _clampExternalCliBypassForOwner(owner, qsLaunchConfigs.codexConfig, qsLaunchConfigs.geminiConfig, qsLaunchConfigs.antigravityConfig, qsLaunchConfigs.piConfig, qsLaunchConfigs.grokConfig, qsLaunchConfigs.deepSeekConfig);
|
|
3654
|
+
const { codexConfig: qsGatedCodexConfig, geminiConfig: qsGatedGeminiConfig, antigravityConfig: qsGatedAntigravityConfig, piConfig: qsGatedPiConfig, grokConfig: qsGatedGrokConfig, deepSeekConfig: qsGatedDeepSeekConfig, copilotConfig: qsGatedCopilotConfig, } = await _clampExternalCliBypassForOwner(owner, qsLaunchConfigs.codexConfig, qsLaunchConfigs.geminiConfig, qsLaunchConfigs.antigravityConfig, qsLaunchConfigs.piConfig, qsLaunchConfigs.grokConfig, qsLaunchConfigs.deepSeekConfig, qsLaunchConfigs.copilotConfig);
|
|
3544
3655
|
const qsTerminalHistoryConfig = await ctx.getTerminalHistoryConfig();
|
|
3545
3656
|
const qsGatedEnvOverrides = await clampEnvOverridesForOwner(owner, envOverrides);
|
|
3546
|
-
const qsResolvedOmpConfig = resolveOmpConfigForCreate(mode,
|
|
3657
|
+
const qsResolvedOmpConfig = resolveOmpConfigForCreate(mode, workspacePath, qsLaunchOmpConfig);
|
|
3547
3658
|
// Custom Model Endpoint Profiles, applied AT CREATE TIME (docs/custom-model-endpoints-plan.md)
|
|
3548
3659
|
// rather than via the dedicated restart-in-place route (POST /api/sessions/:id/custom-
|
|
3549
3660
|
// model, still what an ALREADY-RUNNING session uses to switch later): computing the
|
|
@@ -3627,7 +3738,7 @@ options = {}) {
|
|
|
3627
3738
|
// with, not a placeholder: `new Session({ id: ... })` accepts an explicit id for
|
|
3628
3739
|
// exactly this reason.
|
|
3629
3740
|
qsCustomModelSessionId = randomUUID();
|
|
3630
|
-
const cmApplied = applyCustomModelInjection(cmEntry, cmEndpoint, customModel.modelId, qsCustomModelSessionId, cmContextLength,
|
|
3741
|
+
const cmApplied = applyCustomModelInjection(cmEntry, cmEndpoint, customModel.modelId, qsCustomModelSessionId, cmContextLength, workspacePath);
|
|
3631
3742
|
if (!cmApplied) {
|
|
3632
3743
|
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, `${mode} has no known custom-model mechanism`);
|
|
3633
3744
|
}
|
|
@@ -3659,7 +3770,7 @@ options = {}) {
|
|
|
3659
3770
|
}
|
|
3660
3771
|
const qsSessionOptions = {
|
|
3661
3772
|
id: qsCustomModelSessionId,
|
|
3662
|
-
workingDir:
|
|
3773
|
+
workingDir: workspacePath,
|
|
3663
3774
|
name: sessionName ? sessionName.slice(0, MAX_SESSION_NAME_LENGTH) : '',
|
|
3664
3775
|
mux: ctx.mux,
|
|
3665
3776
|
useMux: true,
|
|
@@ -3675,6 +3786,7 @@ options = {}) {
|
|
|
3675
3786
|
antigravityConfig: mode === 'antigravity' ? qsGatedAntigravityConfig : undefined,
|
|
3676
3787
|
piConfig: mode === 'pi' ? qsGatedPiConfig : undefined,
|
|
3677
3788
|
grokConfig: mode === 'grok' ? qsGatedGrokConfig : undefined,
|
|
3789
|
+
copilotConfig: mode === 'copilot' ? qsGatedCopilotConfig : undefined,
|
|
3678
3790
|
deepSeekConfig: mode === 'deepseek' ? qsGatedDeepSeekConfig : undefined,
|
|
3679
3791
|
ompConfig: qsResolvedOmpConfig,
|
|
3680
3792
|
envOverrides: qsCustomModelEnvOverrides,
|
|
@@ -3716,7 +3828,7 @@ options = {}) {
|
|
|
3716
3828
|
// Auto-detect completion phrase from CLAUDE.md BEFORE broadcasting
|
|
3717
3829
|
// so the initial state already has the phrase configured (only if globally enabled)
|
|
3718
3830
|
if (getCli(mode)?.capabilities.ralph && !remote && !docker && ctx.store.getConfig().ralphEnabled) {
|
|
3719
|
-
autoConfigureRalph(session,
|
|
3831
|
+
autoConfigureRalph(session, workspacePath, ctx);
|
|
3720
3832
|
if (!session.ralphTracker.enabled) {
|
|
3721
3833
|
session.ralphTracker.enable();
|
|
3722
3834
|
session.ralphTracker.enableAutoEnable(); // Allow re-enabling on restart
|
|
@@ -3771,36 +3883,42 @@ options = {}) {
|
|
|
3771
3883
|
if (docker && mode === 'claude') {
|
|
3772
3884
|
void persistDockerCaseClaudeSessionId(CODEMAN_CONFIG_DIR, docker.containerName, session.claudeSessionId || session.id).catch(() => { });
|
|
3773
3885
|
}
|
|
3774
|
-
// Save lastUsedCase to settings for TUI/web sync
|
|
3775
|
-
|
|
3776
|
-
|
|
3777
|
-
|
|
3886
|
+
// Save lastUsedCase to settings for TUI/web sync. It is a key of the server-wide
|
|
3887
|
+
// settings.json, readable by every user through GET /api/settings, so a non-admin's
|
|
3888
|
+
// case name is not written there in multi-user mode (writes are admin-only, like
|
|
3889
|
+
// PUT /api/settings). isAdmin is always true in single-user mode.
|
|
3890
|
+
if (isAdmin(req)) {
|
|
3778
3891
|
try {
|
|
3779
|
-
|
|
3892
|
+
const settingsFilePath = SETTINGS_PATH;
|
|
3893
|
+
let settings = {};
|
|
3894
|
+
try {
|
|
3895
|
+
settings = JSON.parse(await fs.readFile(settingsFilePath, 'utf-8'));
|
|
3896
|
+
}
|
|
3897
|
+
catch (err) {
|
|
3898
|
+
if (err.code !== 'ENOENT')
|
|
3899
|
+
throw err;
|
|
3900
|
+
}
|
|
3901
|
+
settings.lastUsedCase = caseName;
|
|
3902
|
+
const dir = dirname(settingsFilePath);
|
|
3903
|
+
if (!existsSync(dir)) {
|
|
3904
|
+
mkdirSync(dir, { recursive: true });
|
|
3905
|
+
}
|
|
3906
|
+
// Use async write to avoid blocking event loop
|
|
3907
|
+
fs.writeFile(settingsFilePath, JSON.stringify(settings, null, 2)).catch((err) => {
|
|
3908
|
+
// Non-critical but log for debugging
|
|
3909
|
+
console.warn('[Server] Failed to save settings (lastUsedCase):', err);
|
|
3910
|
+
});
|
|
3780
3911
|
}
|
|
3781
3912
|
catch (err) {
|
|
3782
|
-
if (err.code !== 'ENOENT')
|
|
3783
|
-
throw err;
|
|
3784
|
-
}
|
|
3785
|
-
settings.lastUsedCase = caseName;
|
|
3786
|
-
const dir = dirname(settingsFilePath);
|
|
3787
|
-
if (!existsSync(dir)) {
|
|
3788
|
-
mkdirSync(dir, { recursive: true });
|
|
3789
|
-
}
|
|
3790
|
-
// Use async write to avoid blocking event loop
|
|
3791
|
-
fs.writeFile(settingsFilePath, JSON.stringify(settings, null, 2)).catch((err) => {
|
|
3792
3913
|
// Non-critical but log for debugging
|
|
3793
|
-
console.warn('[Server] Failed to
|
|
3794
|
-
}
|
|
3795
|
-
}
|
|
3796
|
-
catch (err) {
|
|
3797
|
-
// Non-critical but log for debugging
|
|
3798
|
-
console.warn('[Server] Failed to prepare settings update:', err);
|
|
3914
|
+
console.warn('[Server] Failed to prepare settings update:', err);
|
|
3915
|
+
}
|
|
3799
3916
|
}
|
|
3800
3917
|
return {
|
|
3801
3918
|
sessionId: session.id,
|
|
3802
3919
|
casePath: resolvedCasePath,
|
|
3803
3920
|
caseName,
|
|
3921
|
+
...(worktree ? { worktree, workingDir: workspacePath } : {}),
|
|
3804
3922
|
...(customModel ? { modelSwapInProgress: qsCustomModelSwapInProgress } : {}),
|
|
3805
3923
|
...(tabGroupId ? { tabLayout } : {}),
|
|
3806
3924
|
};
|
|
@@ -4701,6 +4819,20 @@ options = {}) {
|
|
|
4701
4819
|
ogg: '.ogv',
|
|
4702
4820
|
};
|
|
4703
4821
|
// The per-file size cap (MAX_PASTE_IMAGE_BYTES) is enforced by @fastify/multipart (registered in server.ts).
|
|
4822
|
+
/** Write buffered upload bytes into the session's upload dir. Null: the dir is not a real directory. */
|
|
4823
|
+
async function writeBufferedUpload(workingDir, ext, bytes, stem = '') {
|
|
4824
|
+
const uploadDir = await ensureUploadDir(workingDir);
|
|
4825
|
+
if (!uploadDir)
|
|
4826
|
+
return null;
|
|
4827
|
+
const { filename, filepath, fh } = await openPasteFile(uploadDir, ext, stem);
|
|
4828
|
+
try {
|
|
4829
|
+
await fh.writeFile(bytes);
|
|
4830
|
+
}
|
|
4831
|
+
finally {
|
|
4832
|
+
await fh.close();
|
|
4833
|
+
}
|
|
4834
|
+
return { path: filepath, filename };
|
|
4835
|
+
}
|
|
4704
4836
|
app.post('/api/sessions/:id/paste-image', async (req, reply) => {
|
|
4705
4837
|
// CSRF defense: state-changing routes must come from same origin.
|
|
4706
4838
|
// Cookies are SameSite=lax, multipart/form-data is a "simple" CORS request
|
|
@@ -4819,13 +4951,13 @@ options = {}) {
|
|
|
4819
4951
|
reply.code(400);
|
|
4820
4952
|
return createErrorResponse(ApiErrorCode.INVALID_INPUT, `Unsupported video type. Allowed: ${[...ALLOWED_VIDEO_EXTS].join(', ')}`);
|
|
4821
4953
|
}
|
|
4822
|
-
const
|
|
4823
|
-
if (!
|
|
4954
|
+
const uploadDir = await ensureUploadDir(session.workingDir);
|
|
4955
|
+
if (!uploadDir) {
|
|
4824
4956
|
await finished(part.file.resume()).catch(() => { });
|
|
4825
4957
|
reply.code(403);
|
|
4826
4958
|
return createErrorResponse(ApiErrorCode.INVALID_INPUT, `${UPLOADS_DIR} is not a regular directory`);
|
|
4827
4959
|
}
|
|
4828
|
-
const { filename, filepath, fh } = await openPasteFile(
|
|
4960
|
+
const { filename, filepath, fh } = await openPasteFile(uploadDir, ext);
|
|
4829
4961
|
// Written as it arrives: a phone video is far too large to buffer the way an
|
|
4830
4962
|
// image is. The container is judged on the first 12 bytes as soon as they are
|
|
4831
4963
|
// in, so a mislabeled upload stops touching disk at its first chunk; the rest
|
|
@@ -4887,13 +5019,41 @@ options = {}) {
|
|
|
4887
5019
|
reply.code(400);
|
|
4888
5020
|
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Empty file');
|
|
4889
5021
|
}
|
|
5022
|
+
// Dispatch mirrors the client's _promptAttachKind: an image MIME type takes
|
|
5023
|
+
// the image path whatever the name says, a document extension (the MIME a
|
|
5024
|
+
// phone gives a .md is anyone's guess) takes the document branch, anything
|
|
5025
|
+
// else the image path exactly as before, HEIC sniff included. See CLAUDE.md.
|
|
5026
|
+
const origExt = part.filename ? extname(part.filename).toLowerCase() : '';
|
|
5027
|
+
const imageMime = /^image\//i.test(part.mimetype || '');
|
|
5028
|
+
if (!imageMime && ALLOWED_DOCUMENT_EXTS.has(origExt)) {
|
|
5029
|
+
if (!imageMagicMatchesExt(imageBytes, origExt)) {
|
|
5030
|
+
// The first bytes of a text file are user content, so for text the log
|
|
5031
|
+
// names the failure and never the bytes; a signature is safe to print.
|
|
5032
|
+
// A text refusal names the encoding, since an Excel or Notepad export
|
|
5033
|
+
// (UTF-16 with a BOM, or Windows-1252) is not a corrupt file.
|
|
5034
|
+
const isText = TEXT_UPLOAD_EXTS.has(origExt);
|
|
5035
|
+
const bom = imageBytes.length >= 2 ? imageBytes.readUInt16BE(0) : 0;
|
|
5036
|
+
const utf16 = bom === 0xfffe || bom === 0xfeff;
|
|
5037
|
+
const detail = isText
|
|
5038
|
+
? `reason=${utf16 ? 'utf16-bom' : imageBytes.includes(0) ? 'nul-byte' : 'not-utf8'}`
|
|
5039
|
+
: `magic=${imageBytes.subarray(0, 12).toString('hex')}`;
|
|
5040
|
+
console.warn(`[paste-image] document mismatch: filename=${JSON.stringify(part.filename)} mime=${JSON.stringify(part.mimetype)} declaredExt=${origExt} size=${imageBytes.length} ${detail}`);
|
|
5041
|
+
reply.code(415);
|
|
5042
|
+
return createErrorResponse(ApiErrorCode.INVALID_INPUT, isText
|
|
5043
|
+
? `This ${origExt} file is ${utf16 ? 'UTF-16 text, not UTF-8' : 'not UTF-8 text'}; save it as UTF-8 and attach it again`
|
|
5044
|
+
: `File bytes do not match declared type ${origExt}`);
|
|
5045
|
+
}
|
|
5046
|
+
const saved = await writeBufferedUpload(session.workingDir, origExt, imageBytes, sanitizeUploadStem(part.filename));
|
|
5047
|
+
if (!saved) {
|
|
5048
|
+
reply.code(403);
|
|
5049
|
+
return createErrorResponse(ApiErrorCode.INVALID_INPUT, `${UPLOADS_DIR} is not a regular directory`);
|
|
5050
|
+
}
|
|
5051
|
+
return saved;
|
|
5052
|
+
}
|
|
4890
5053
|
// Determine extension from filename or Content-Type.
|
|
4891
5054
|
let ext = '.png';
|
|
4892
|
-
if (
|
|
4893
|
-
|
|
4894
|
-
if (ALLOWED_IMAGE_EXTS.has(origExt))
|
|
4895
|
-
ext = origExt;
|
|
4896
|
-
}
|
|
5055
|
+
if (ALLOWED_IMAGE_EXTS.has(origExt))
|
|
5056
|
+
ext = origExt;
|
|
4897
5057
|
const mimeMatch = (part.mimetype || '').toLowerCase().match(/^image\/(png|jpeg|jpg|webp|gif|bmp|heic|heif)$/);
|
|
4898
5058
|
if (mimeMatch) {
|
|
4899
5059
|
const map = {
|
|
@@ -4929,6 +5089,15 @@ options = {}) {
|
|
|
4929
5089
|
}
|
|
4930
5090
|
}
|
|
4931
5091
|
else if (!imageMagicMatchesExt(imageBytes, ext)) {
|
|
5092
|
+
// A foreign extension with no image MIME (a script, an HTML page, a video
|
|
5093
|
+
// without its flag) was never going to be an image: say so, rather than
|
|
5094
|
+
// the ".png" the default extension would name. Decided only HERE, after
|
|
5095
|
+
// the sniff, so anything the image path ever accepted by its bytes (a
|
|
5096
|
+
// HEIC under a generic name) still is.
|
|
5097
|
+
if (origExt && !ALLOWED_IMAGE_EXTS.has(origExt) && !imageMime) {
|
|
5098
|
+
reply.code(400);
|
|
5099
|
+
return createErrorResponse(ApiErrorCode.INVALID_INPUT, `Unsupported file type: ${origExt}`);
|
|
5100
|
+
}
|
|
4932
5101
|
// Sniff actual bytes — a polyglot HTML/PNG would otherwise pass and
|
|
4933
5102
|
// serve back with image/png MIME. Log the real header so format
|
|
4934
5103
|
// mismatches can be pinned down without a reproduce-and-guess loop. The
|
|
@@ -4937,20 +5106,12 @@ options = {}) {
|
|
|
4937
5106
|
reply.code(415);
|
|
4938
5107
|
return createErrorResponse(ApiErrorCode.INVALID_INPUT, `Image bytes do not match declared type ${ext}`);
|
|
4939
5108
|
}
|
|
4940
|
-
|
|
4941
|
-
|
|
4942
|
-
if (!imageDir) {
|
|
5109
|
+
const saved = await writeBufferedUpload(session.workingDir, ext, imageBytes);
|
|
5110
|
+
if (!saved) {
|
|
4943
5111
|
reply.code(403);
|
|
4944
5112
|
return createErrorResponse(ApiErrorCode.INVALID_INPUT, `${UPLOADS_DIR} is not a regular directory`);
|
|
4945
5113
|
}
|
|
4946
|
-
|
|
4947
|
-
try {
|
|
4948
|
-
await fh.writeFile(imageBytes);
|
|
4949
|
-
}
|
|
4950
|
-
finally {
|
|
4951
|
-
await fh.close();
|
|
4952
|
-
}
|
|
4953
|
-
return { path: filepath, filename };
|
|
5114
|
+
return saved;
|
|
4954
5115
|
});
|
|
4955
5116
|
// Returned so the server can own the registry's LIFETIME (drop state when a session is
|
|
4956
5117
|
// cleaned up on any of its paths, resolve in-flight wakes on shutdown). The wake-CAPABLE
|