aicodeman 1.41.0 → 1.41.1
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 +58 -8
- package/dist/attachment-registry.d.ts +2 -0
- package/dist/attachment-registry.d.ts.map +1 -1
- package/dist/attachment-registry.js +14 -3
- package/dist/attachment-registry.js.map +1 -1
- package/dist/cli-agent.d.ts +2 -0
- package/dist/cli-agent.d.ts.map +1 -1
- package/dist/cli-agent.js +12 -8
- package/dist/cli-agent.js.map +1 -1
- package/dist/cli.d.ts +25 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +97 -11
- package/dist/cli.js.map +1 -1
- package/dist/config/buffer-limits.d.ts +9 -0
- package/dist/config/buffer-limits.d.ts.map +1 -1
- package/dist/config/buffer-limits.js +9 -0
- package/dist/config/buffer-limits.js.map +1 -1
- package/dist/config/cli-registry/schema.d.ts +8 -0
- package/dist/config/cli-registry/schema.d.ts.map +1 -1
- package/dist/config/cli-registry/schema.js +12 -0
- 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 +42 -0
- package/dist/config/cli-registry/stock.js.map +1 -1
- package/dist/config/cli-registry/types.d.ts +35 -0
- package/dist/config/cli-registry/types.d.ts.map +1 -1
- package/dist/folder-listing.d.ts +94 -0
- package/dist/folder-listing.d.ts.map +1 -0
- package/dist/folder-listing.js +241 -0
- package/dist/folder-listing.js.map +1 -0
- package/dist/hooks-config.d.ts +36 -16
- package/dist/hooks-config.d.ts.map +1 -1
- package/dist/hooks-config.js +45 -20
- package/dist/hooks-config.js.map +1 -1
- package/dist/html-view-capabilities.d.ts +54 -0
- package/dist/html-view-capabilities.d.ts.map +1 -0
- package/dist/html-view-capabilities.js +103 -0
- package/dist/html-view-capabilities.js.map +1 -0
- package/dist/mcp-server.d.ts +114 -0
- package/dist/mcp-server.d.ts.map +1 -0
- package/dist/mcp-server.js +917 -0
- package/dist/mcp-server.js.map +1 -0
- package/dist/mcp-sync.d.ts +5 -0
- package/dist/mcp-sync.d.ts.map +1 -1
- package/dist/mcp-sync.js +51 -5
- package/dist/mcp-sync.js.map +1 -1
- package/dist/mux-interface.d.ts +36 -1
- package/dist/mux-interface.d.ts.map +1 -1
- package/dist/remote-files.d.ts +37 -0
- package/dist/remote-files.d.ts.map +1 -1
- package/dist/remote-files.js +103 -0
- package/dist/remote-files.js.map +1 -1
- package/dist/remote-pane-history.d.ts +103 -0
- package/dist/remote-pane-history.d.ts.map +1 -0
- package/dist/remote-pane-history.js +258 -0
- package/dist/remote-pane-history.js.map +1 -0
- package/dist/remote-ssh-limiter.d.ts +16 -3
- package/dist/remote-ssh-limiter.d.ts.map +1 -1
- package/dist/remote-ssh-limiter.js +37 -6
- package/dist/remote-ssh-limiter.js.map +1 -1
- package/dist/session.d.ts +29 -1
- package/dist/session.d.ts.map +1 -1
- package/dist/session.js +53 -0
- package/dist/session.js.map +1 -1
- package/dist/tab-layout-legacy-order.d.ts.map +1 -1
- package/dist/tab-layout-legacy-order.js +72 -13
- package/dist/tab-layout-legacy-order.js.map +1 -1
- package/dist/tab-layout-service.d.ts +33 -2
- package/dist/tab-layout-service.d.ts.map +1 -1
- package/dist/tab-layout-service.js +102 -7
- package/dist/tab-layout-service.js.map +1 -1
- package/dist/tab-layout.d.ts +9 -0
- package/dist/tab-layout.d.ts.map +1 -1
- package/dist/tab-layout.js +15 -2
- package/dist/tab-layout.js.map +1 -1
- package/dist/tmux-manager.d.ts +71 -2
- package/dist/tmux-manager.d.ts.map +1 -1
- package/dist/tmux-manager.js +169 -18
- package/dist/tmux-manager.js.map +1 -1
- package/dist/types/session.d.ts +53 -0
- package/dist/types/session.d.ts.map +1 -1
- package/dist/types/session.js.map +1 -1
- package/dist/usage-telemetry.d.ts +44 -7
- package/dist/usage-telemetry.d.ts.map +1 -1
- package/dist/usage-telemetry.js +72 -7
- package/dist/usage-telemetry.js.map +1 -1
- package/dist/web/approval-inbox.d.ts +12 -0
- package/dist/web/approval-inbox.d.ts.map +1 -1
- package/dist/web/approval-inbox.js +1 -0
- package/dist/web/approval-inbox.js.map +1 -1
- package/dist/web/middleware/auth.d.ts.map +1 -1
- package/dist/web/middleware/auth.js +33 -0
- package/dist/web/middleware/auth.js.map +1 -1
- package/dist/web/ports/session-port.d.ts +8 -1
- package/dist/web/ports/session-port.d.ts.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.24689381.js +43 -0
- package/dist/web/public/app.24689381.js.br +0 -0
- package/dist/web/public/app.24689381.js.gz +0 -0
- package/dist/web/public/approvals-ui.js +4 -0
- package/dist/web/public/approvals-ui.js.br +0 -0
- package/dist/web/public/approvals-ui.js.gz +0 -0
- package/dist/web/public/{constants.bc9c1f63.js → constants.17d77b65.js} +395 -7
- package/dist/web/public/constants.17d77b65.js.br +0 -0
- package/dist/web/public/constants.17d77b65.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/gesture/gesture-codeman.js +112 -6
- 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.9afd8e4d.js +2 -0
- 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.e00471c6.js → image-input.9ed1d0d2.js} +186 -16
- 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/index.html +105 -23
- 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.c1e7c7b7.js → keyboard-accessory.c458eea8.js} +115 -35
- 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-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 +11 -2
- 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.2dff1580.css +1 -0
- package/dist/web/public/mobile.2dff1580.css.br +0 -0
- package/dist/web/public/mobile.2dff1580.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.71846fcb.js → panels-ui.73ec0137.js} +87 -87
- 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/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.gz +0 -0
- package/dist/web/public/sanitize-html.e7147e76.js.gz +0 -0
- package/dist/web/public/session-lineage.js.gz +0 -0
- package/dist/web/public/{session-ui.f731602c.js → session-ui.dbc6819b.js} +33 -33
- 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.4e33390f.js → settings-ui.81ac264e.js} +9 -9
- 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/spreadsheet-preview-worker.js +11 -0
- package/dist/web/public/spreadsheet-preview-worker.js.br +0 -0
- package/dist/web/public/spreadsheet-preview-worker.js.gz +0 -0
- package/dist/web/public/{spreadsheet-preview.af82fa2f.js → spreadsheet-preview.75c751de.js} +1 -1
- package/dist/web/public/spreadsheet-preview.75c751de.js.br +0 -0
- package/dist/web/public/spreadsheet-preview.75c751de.js.gz +0 -0
- package/dist/web/public/spreadsheet-xlsx-core.js +110 -0
- package/dist/web/public/spreadsheet-xlsx-core.js.br +0 -0
- package/dist/web/public/spreadsheet-xlsx-core.js.gz +0 -0
- package/dist/web/public/styles.900a28e4.css +1 -0
- package/dist/web/public/styles.900a28e4.css.br +0 -0
- package/dist/web/public/styles.900a28e4.css.gz +0 -0
- package/dist/web/public/subagent-windows.07277a58.js.gz +0 -0
- package/dist/web/public/sw.js +6 -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 +1 -0
- package/dist/web/public/tab-layout-browser.2d6189f7.js.br +0 -0
- package/dist/web/public/tab-layout-browser.2d6189f7.js.gz +0 -0
- package/dist/web/public/{tab-rail-resize.a5867793.js → tab-rail-resize.7157e0bd.js} +1 -1
- package/dist/web/public/tab-rail-resize.7157e0bd.js.br +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 +44 -24
- 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.858f5da5.js +2 -0
- package/dist/web/public/terminal-ui.858f5da5.js.br +0 -0
- package/dist/web/public/terminal-ui.858f5da5.js.gz +0 -0
- package/dist/web/public/tile-grid.js +439 -18
- 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 +12 -0
- package/dist/web/public/webview-tabs.js.br +0 -0
- package/dist/web/public/webview-tabs.js.gz +0 -0
- package/dist/web/route-helpers.d.ts +8 -0
- package/dist/web/route-helpers.d.ts.map +1 -1
- package/dist/web/route-helpers.js +8 -0
- package/dist/web/route-helpers.js.map +1 -1
- package/dist/web/routes/admin-routes.d.ts.map +1 -1
- package/dist/web/routes/admin-routes.js +4 -1
- package/dist/web/routes/admin-routes.js.map +1 -1
- package/dist/web/routes/approval-routes.d.ts.map +1 -1
- package/dist/web/routes/approval-routes.js +6 -8
- package/dist/web/routes/approval-routes.js.map +1 -1
- package/dist/web/routes/case-routes.js +1 -1
- package/dist/web/routes/case-routes.js.map +1 -1
- package/dist/web/routes/file-routes.d.ts +46 -1
- package/dist/web/routes/file-routes.d.ts.map +1 -1
- package/dist/web/routes/file-routes.js +214 -27
- package/dist/web/routes/file-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 +8 -1
- package/dist/web/routes/hook-event-routes.js.map +1 -1
- package/dist/web/routes/html-view-routes.d.ts +60 -0
- package/dist/web/routes/html-view-routes.d.ts.map +1 -0
- package/dist/web/routes/html-view-routes.js +288 -0
- package/dist/web/routes/html-view-routes.js.map +1 -0
- 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/reboot-restore-routes.js +2 -2
- package/dist/web/routes/reboot-restore-routes.js.map +1 -1
- package/dist/web/routes/session-routes.d.ts +1 -0
- package/dist/web/routes/session-routes.d.ts.map +1 -1
- package/dist/web/routes/session-routes.js +278 -56
- 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 +16 -2
- package/dist/web/routes/status-telemetry-routes.js.map +1 -1
- package/dist/web/schemas.d.ts +29 -0
- package/dist/web/schemas.d.ts.map +1 -1
- package/dist/web/schemas.js +65 -0
- package/dist/web/schemas.js.map +1 -1
- package/dist/web/server.d.ts +6 -1
- package/dist/web/server.d.ts.map +1 -1
- package/dist/web/server.js +62 -14
- package/dist/web/server.js.map +1 -1
- package/dist/web/sse-events.d.ts +5 -2
- package/dist/web/sse-events.d.ts.map +1 -1
- package/dist/web/sse-events.js +5 -2
- package/dist/web/sse-events.js.map +1 -1
- package/package.json +2 -1
- package/skills/codeman/SKILL.md +29 -12
- package/dist/web/public/app.6f8552e1.js +0 -43
- package/dist/web/public/app.6f8552e1.js.br +0 -0
- package/dist/web/public/app.6f8552e1.js.gz +0 -0
- package/dist/web/public/constants.bc9c1f63.js.br +0 -0
- package/dist/web/public/constants.bc9c1f63.js.gz +0 -0
- package/dist/web/public/i18n.a9840ea2.js +0 -2
- package/dist/web/public/i18n.a9840ea2.js.br +0 -0
- package/dist/web/public/i18n.a9840ea2.js.gz +0 -0
- package/dist/web/public/image-input.e00471c6.js.br +0 -0
- package/dist/web/public/image-input.e00471c6.js.gz +0 -0
- package/dist/web/public/keyboard-accessory.c1e7c7b7.js.br +0 -0
- package/dist/web/public/keyboard-accessory.c1e7c7b7.js.gz +0 -0
- package/dist/web/public/mobile.111b187d.css +0 -1
- package/dist/web/public/mobile.111b187d.css.br +0 -0
- package/dist/web/public/mobile.111b187d.css.gz +0 -0
- package/dist/web/public/panels-ui.71846fcb.js.br +0 -0
- package/dist/web/public/panels-ui.71846fcb.js.gz +0 -0
- package/dist/web/public/session-ui.f731602c.js.br +0 -0
- package/dist/web/public/session-ui.f731602c.js.gz +0 -0
- package/dist/web/public/settings-ui.4e33390f.js.br +0 -0
- package/dist/web/public/settings-ui.4e33390f.js.gz +0 -0
- package/dist/web/public/spreadsheet-preview.af82fa2f.js.br +0 -0
- package/dist/web/public/spreadsheet-preview.af82fa2f.js.gz +0 -0
- package/dist/web/public/styles.81a72906.css +0 -1
- package/dist/web/public/styles.81a72906.css.br +0 -0
- package/dist/web/public/styles.81a72906.css.gz +0 -0
- package/dist/web/public/tab-layout-browser.065fcba4.js +0 -1
- package/dist/web/public/tab-layout-browser.065fcba4.js.br +0 -0
- package/dist/web/public/tab-layout-browser.065fcba4.js.gz +0 -0
- package/dist/web/public/tab-rail-resize.a5867793.js.br +0 -0
- package/dist/web/public/tab-rail-resize.a5867793.js.gz +0 -0
- package/dist/web/public/terminal-ui.b7267005.js +0 -2
- package/dist/web/public/terminal-ui.b7267005.js.br +0 -0
- package/dist/web/public/terminal-ui.b7267005.js.gz +0 -0
|
@@ -8,12 +8,14 @@ import { homedir } from 'node:os';
|
|
|
8
8
|
import { existsSync, statSync, mkdirSync, writeFileSync } from 'node:fs';
|
|
9
9
|
import { execFile } from 'node:child_process';
|
|
10
10
|
import fs from 'node:fs/promises';
|
|
11
|
+
import { finished } from 'node:stream/promises';
|
|
11
12
|
import { randomBytes, randomUUID } from 'node:crypto';
|
|
12
13
|
import { performance } from 'node:perf_hooks';
|
|
13
14
|
import { ApiErrorCode, createErrorResponse, getErrorMessage, } from '../../types.js';
|
|
14
15
|
import { Session, cliTakesSessionModel, isAltScreenStripMode, isExternalCliMode, isMuxAltScreenOnlyStripMode, isMuxMouseStripMode, } from '../../session.js';
|
|
15
16
|
import { SseEvent } from '../sse-events.js';
|
|
16
17
|
import { webviewCapabilities } from '../../webview-capabilities.js';
|
|
18
|
+
import { htmlViewCapabilities } from '../../html-view-capabilities.js';
|
|
17
19
|
import { CreateSessionSchema, SessionNameSchema, SessionColorSchema, RunPromptSchema, SessionInputWithLimitSchema, ResizeSchema, AutoClearSchema, AutoCompactSchema, AutoResumeSchema, PinSessionSchema, ImageWatcherSchema, FlickerFilterSchema, QuickRunSchema, QuickStartSchema, InteractiveStartSchema, SessionOrderUpdateSchema, SessionWaitQuerySchema, SessionWaitOutputQuerySchema, CustomModelSelectionSchema, } from '../schemas.js';
|
|
18
20
|
import { readCustomModelHosts } from '../../custom-model-hosts.js';
|
|
19
21
|
import { applyCustomModelInjection, removeConfigDir } from '../../custom-model-injection-apply.js';
|
|
@@ -34,7 +36,7 @@ import { applyLaunchDefaults } from '../launch-defaults.js';
|
|
|
34
36
|
import { legacyConfigForMode } from '../../session-cli-registry-bridge.js';
|
|
35
37
|
import { isMultiUserMode } from '../../config/multiuser.js';
|
|
36
38
|
import { AUTH_COOKIE_NAME } from '../middleware/auth.js';
|
|
37
|
-
import { writeHooksConfig, updateCaseModel, stripCaseEnvKeys, applyAgentSkill, refreshUserAgentSkill, seedAgentSessionPreamble, applyWorkspaceHooks, refreshStaleCodemanHooks, } from '../../hooks-config.js';
|
|
39
|
+
import { writeHooksConfig, updateCaseModel, stripCaseEnvKeys, agentSkillDir, agentSkillDirFor, applyAgentSkill, refreshUserAgentSkill, seedAgentSessionPreamble, applyWorkspaceHooks, refreshStaleCodemanHooks, } from '../../hooks-config.js';
|
|
38
40
|
import { generateClaudeMd } from '../../templates/claude-md.js';
|
|
39
41
|
import { imageWatcher } from '../../image-watcher.js';
|
|
40
42
|
import { convertHeicToJpeg } from '../heic-jpeg-converter.js';
|
|
@@ -43,9 +45,11 @@ import { mergeUnifiedSessions, filterAndPaginate, } from '../../services/unified
|
|
|
43
45
|
import { buildHistorySessionIndexItems, setHistoryIndexRefresher, setHistorySessionIndex, } from '../session-history-index.js';
|
|
44
46
|
import { RunSummaryTracker } from '../../run-summary.js';
|
|
45
47
|
import { MAX_INPUT_LENGTH, MAX_SESSION_NAME_LENGTH } from '../../config/terminal-limits.js';
|
|
46
|
-
import { MAX_PASTE_IMAGE_BYTES } from '../../config/buffer-limits.js';
|
|
48
|
+
import { MAX_PASTE_IMAGE_BYTES, MAX_PASTE_VIDEO_BYTES } from '../../config/buffer-limits.js';
|
|
49
|
+
import { VIDEO_ATTACHMENT_EXTENSIONS } from '../../attachment-registry.js';
|
|
47
50
|
import { dataPath, getDataDir } from '../../config/instance.js';
|
|
48
51
|
import { checkRemoteTmuxAvailable, readRemoteCases, readRemoteHosts, rehydrateRemoteHostFields, toAttachedSessionRemote, toSessionRemote, } from '../../remote-hosts.js';
|
|
52
|
+
import { fetchRemoteScrollback } from '../../remote-pane-history.js';
|
|
49
53
|
import { checkDockerAvailable, checkDockerConfigDrift, probeAdoptableContainer, checkDockerTmuxAvailable, ensureAgentBaseImage, DEFAULT_AGENT_IMAGE, persistDockerCaseClaudeSessionId, readDockerCases, readDockerHosts, toSessionDocker, } from '../../docker-hosts.js';
|
|
50
54
|
import { LRUMap } from '../../utils/lru-map.js';
|
|
51
55
|
import { describeUnknownPath, probePathKind } from '../../utils/index.js';
|
|
@@ -219,6 +223,19 @@ export function imageMagicMatchesExt(data, ext) {
|
|
|
219
223
|
const brand = data.subarray(8, 12).toString('ascii');
|
|
220
224
|
return ['heic', 'heix', 'hevc', 'hevx', 'mif1', 'msf1'].includes(brand);
|
|
221
225
|
}
|
|
226
|
+
case '.mp4':
|
|
227
|
+
case '.m4v':
|
|
228
|
+
case '.mov':
|
|
229
|
+
// ISO Base Media File Format / QuickTime: size + a top-level atom type. Brands
|
|
230
|
+
// vary too widely to enumerate (qt, isom, mp42, avc1, hev1, ...), and an older
|
|
231
|
+
// QuickTime file can open on an atom other than ftyp.
|
|
232
|
+
return ['ftyp', 'moov', 'mdat', 'wide', 'free', 'skip'].includes(data.subarray(4, 8).toString('ascii'));
|
|
233
|
+
case '.webm':
|
|
234
|
+
// EBML header (Matroska's WebM subset)
|
|
235
|
+
return u32be(0) === 0x1a45dfa3;
|
|
236
|
+
case '.ogv':
|
|
237
|
+
// Ogg container ("OggS")
|
|
238
|
+
return u32be(0) === 0x4f676753;
|
|
222
239
|
default:
|
|
223
240
|
return false;
|
|
224
241
|
}
|
|
@@ -235,7 +252,9 @@ export function imageMagicMatchesExt(data, ext) {
|
|
|
235
252
|
* touched, and a file already there is theirs and stays as it is.
|
|
236
253
|
*/
|
|
237
254
|
async function ensureUploadDir(workingDir) {
|
|
238
|
-
// workingDir
|
|
255
|
+
// workingDir exists (live session) and the caller bounded-probed its mount, so
|
|
256
|
+
// the lstat/mkdir below normally start on a reachable path; a mount that dies
|
|
257
|
+
// after that probe is the same residual race POST /api/sessions accepts.
|
|
239
258
|
const uploadDir = join(workingDir, UPLOADS_DIR);
|
|
240
259
|
try {
|
|
241
260
|
const dirStat = await fs.lstat(uploadDir);
|
|
@@ -275,37 +294,59 @@ async function ensureUploadDir(workingDir) {
|
|
|
275
294
|
}
|
|
276
295
|
return uploadDir;
|
|
277
296
|
}
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
//
|
|
297
|
+
/** Open a fresh, uniquely named paste file for writing. */
|
|
298
|
+
async function openPasteFile(imageDir, ext) {
|
|
299
|
+
// Date.now() collides on same-ms uploads from two tabs (last-write wins
|
|
300
|
+
// silently). Append 8 hex chars so concurrent pastes get distinct names.
|
|
301
|
+
const filename = `paste-${Date.now()}-${randomBytes(4).toString('hex')}${ext}`;
|
|
302
|
+
const filepath = join(imageDir, filename);
|
|
303
|
+
// O_EXCL: refuse to overwrite (collision is impossible with random suffix,
|
|
304
|
+
// but defends against TOCTOU). O_NOFOLLOW: refuse if filepath is a symlink.
|
|
305
|
+
const fh = await fs.open(filepath, fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_NOFOLLOW);
|
|
306
|
+
return { filename, filepath, fh };
|
|
307
|
+
}
|
|
308
|
+
// Per-(IP, sessionId) token buckets for paste-image. Images: 30/min. Videos: 3/min,
|
|
309
|
+
// a much lower rate because one video can be up to MAX_PASTE_VIDEO_BYTES (1GB), so the
|
|
310
|
+
// 30/min cap would let a single authenticated client write 30GB of disk a minute; 3/min
|
|
311
|
+
// bounds that to 3GB. Bucket entries are pruned when they drift > 1h stale to bound
|
|
312
|
+
// memory against a flood of unique IP keys.
|
|
281
313
|
const PASTE_RATE_TOKENS = 30;
|
|
282
|
-
const
|
|
314
|
+
const VIDEO_RATE_TOKENS = 3;
|
|
283
315
|
const PASTE_BUCKET_TTL_MS = 60 * 60 * 1000;
|
|
284
316
|
const PASTE_BUCKET_GC_THRESHOLD = 1000;
|
|
285
317
|
const pasteRateBuckets = new Map();
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
318
|
+
const videoRateBuckets = new Map();
|
|
319
|
+
function takeToken(buckets, key, capacity, now) {
|
|
320
|
+
if (buckets.size > PASTE_BUCKET_GC_THRESHOLD) {
|
|
321
|
+
for (const [k, b] of buckets) {
|
|
289
322
|
if (now - b.lastRefill > PASTE_BUCKET_TTL_MS)
|
|
290
|
-
|
|
323
|
+
buckets.delete(k);
|
|
291
324
|
}
|
|
292
325
|
}
|
|
293
|
-
let b =
|
|
326
|
+
let b = buckets.get(key);
|
|
294
327
|
if (!b) {
|
|
295
|
-
b = { tokens:
|
|
296
|
-
|
|
328
|
+
b = { tokens: capacity, lastRefill: now };
|
|
329
|
+
buckets.set(key, b);
|
|
297
330
|
}
|
|
298
|
-
|
|
299
|
-
b.tokens = Math.min(PASTE_RATE_TOKENS, b.tokens + delta);
|
|
331
|
+
b.tokens = Math.min(capacity, b.tokens + (now - b.lastRefill) * (capacity / 60_000));
|
|
300
332
|
b.lastRefill = now;
|
|
301
333
|
if (b.tokens < 1)
|
|
302
334
|
return false;
|
|
303
335
|
b.tokens -= 1;
|
|
304
336
|
return true;
|
|
305
337
|
}
|
|
338
|
+
export function consumePasteToken(key, now = Date.now()) {
|
|
339
|
+
return takeToken(pasteRateBuckets, key, PASTE_RATE_TOKENS, now);
|
|
340
|
+
}
|
|
341
|
+
// Videos stream to disk under a far larger per-file cap, so they draw on their own,
|
|
342
|
+
// much lower bucket (see the comment above the constants).
|
|
343
|
+
export function consumeVideoToken(key, now = Date.now()) {
|
|
344
|
+
return takeToken(videoRateBuckets, key, VIDEO_RATE_TOKENS, now);
|
|
345
|
+
}
|
|
306
346
|
// Test hook: reset between runs.
|
|
307
347
|
export function _resetPasteRateBuckets() {
|
|
308
348
|
pasteRateBuckets.clear();
|
|
349
|
+
videoRateBuckets.clear();
|
|
309
350
|
}
|
|
310
351
|
/**
|
|
311
352
|
* Security (multi-user §6.3): the Claude-only permission-mode downgrade does not
|
|
@@ -626,8 +667,8 @@ function abortOnClientHangUp(reply) {
|
|
|
626
667
|
* Injection is best-effort and stays that way: neither a refusal nor a thrown error
|
|
627
668
|
* may fail the create.
|
|
628
669
|
*/
|
|
629
|
-
async function injectAgentSkill(casePath) {
|
|
630
|
-
const skillDir =
|
|
670
|
+
async function injectAgentSkill(casePath, dir) {
|
|
671
|
+
const skillDir = agentSkillDirFor(casePath, dir);
|
|
631
672
|
try {
|
|
632
673
|
// Claude Code loads a same-named USER-LEVEL skill (`~/.claude/skills/codeman`,
|
|
633
674
|
// written once by `codeman skill install`) over the case copy injected below, so a
|
|
@@ -636,7 +677,7 @@ async function injectAgentSkill(casePath) {
|
|
|
636
677
|
// current on the same trigger. Refresh-only + marker-guarded; quiet on refusal,
|
|
637
678
|
// since a foreign user copy is the user's own authored skill, not a config error.
|
|
638
679
|
await refreshUserAgentSkill();
|
|
639
|
-
const result = await applyAgentSkill(casePath, true);
|
|
680
|
+
const result = await applyAgentSkill(casePath, true, dir);
|
|
640
681
|
if (result === 'foreign') {
|
|
641
682
|
console.warn(`[agent-skill] not injected: ${skillDir} exists but is not Codeman-managed (no marker), refusing to touch it. Remove that copy if you want the packaged skill there.`);
|
|
642
683
|
}
|
|
@@ -759,6 +800,7 @@ options = {}) {
|
|
|
759
800
|
// logout has to retire them too or every dashboard URL opened during this
|
|
760
801
|
// login keeps relaying without one (WebviewCapabilityStore.revokeOwner).
|
|
761
802
|
webviewCapabilities.revokeOwner(ownerFor(req));
|
|
803
|
+
htmlViewCapabilities.revokeOwner(ownerFor(req));
|
|
762
804
|
reply.clearCookie(AUTH_COOKIE_NAME, { path: '/' });
|
|
763
805
|
return {};
|
|
764
806
|
});
|
|
@@ -920,13 +962,6 @@ options = {}) {
|
|
|
920
962
|
// materializing in ~/.claude/settings.local.json was never asked for.
|
|
921
963
|
if (!remote && body.workingDir && (body.mode ?? 'claude') === 'claude') {
|
|
922
964
|
await applyWorkspaceHooks(workingDir, await ctx.getWorkspaceHooksEnabled());
|
|
923
|
-
// Agent skill (docs/agent-control-plan.md §2): ADD-ONLY on create, same shared-
|
|
924
|
-
// .claude rationale as the statusLine above: a create must never remove the
|
|
925
|
-
// skill from under other live sessions in the repo. Marker-guarded, so a
|
|
926
|
-
// user's own skills/codeman is never touched.
|
|
927
|
-
if (await ctx.getAgentSkillEnabled()) {
|
|
928
|
-
await injectAgentSkill(workingDir);
|
|
929
|
-
}
|
|
930
965
|
}
|
|
931
966
|
// Refuse up front if the requested CLI cannot start, rather than spawning a pane that
|
|
932
967
|
// dies on `command not found`. The message comes from the resolver, so it names where
|
|
@@ -945,6 +980,17 @@ options = {}) {
|
|
|
945
980
|
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, cliLaunchError);
|
|
946
981
|
}
|
|
947
982
|
}
|
|
983
|
+
// Agent skill (docs/agent-control-plan.md §2): ADD-ONLY on create, same shared-
|
|
984
|
+
// workspace rationale as the statusLine above: a create must never remove the
|
|
985
|
+
// skill from under other live sessions in the repo. Marker-guarded, so a user's
|
|
986
|
+
// own copy is never touched. Every CLI that takes the skill gets it in the directory
|
|
987
|
+
// that CLI scans (`capabilities.agentSkillDir`); the same remote and named-workingDir
|
|
988
|
+
// conditions as the hooks above, and only once the launch pre-flight passed, so a
|
|
989
|
+
// CLI that cannot start leaves nothing in the workspace.
|
|
990
|
+
const createSkillDir = agentSkillDir(getCli(body.mode ?? 'claude')?.capabilities);
|
|
991
|
+
if (!remote && body.workingDir && createSkillDir && (await ctx.getAgentSkillEnabled())) {
|
|
992
|
+
await injectAgentSkill(workingDir, createSkillDir);
|
|
993
|
+
}
|
|
948
994
|
// Pre-validate resumeSessionId: check that the conversation file actually exists
|
|
949
995
|
// in Claude's projects directory. If not, skip resume to avoid confusing
|
|
950
996
|
// "No conversation found" errors from Claude CLI.
|
|
@@ -1033,13 +1079,16 @@ options = {}) {
|
|
|
1033
1079
|
owner,
|
|
1034
1080
|
parentSessionId: resolveParentSessionId(ctx, req, body.parentSessionId, owner),
|
|
1035
1081
|
});
|
|
1036
|
-
|
|
1082
|
+
const tabLayout = body.tabGroupId
|
|
1083
|
+
? await ctx.addSession(session, { tabGroupId: body.tabGroupId })
|
|
1084
|
+
: await ctx.addSession(session);
|
|
1037
1085
|
ctx.store.incrementSessionsCreated();
|
|
1038
1086
|
ctx.persistSessionState(session);
|
|
1039
1087
|
await ctx.setupSessionListeners(session);
|
|
1040
1088
|
// Pre-seed the agent skill's preamble cache so its §0 bootstrap is a two-line
|
|
1041
|
-
// loader (see seedAgentSessionPreamble). Local
|
|
1042
|
-
|
|
1089
|
+
// loader (see seedAgentSessionPreamble). Local sessions of a CLI that takes the
|
|
1090
|
+
// skill; best-effort.
|
|
1091
|
+
if (agentSkillDir(getCli(mode)?.capabilities) && !remote && (await ctx.getAgentSkillEnabled())) {
|
|
1043
1092
|
await seedAgentSessionPreamble(session.id).catch((err) => console.warn(`[agent-skill] preamble seed failed for ${session.id}: ${getErrorMessage(err)}`));
|
|
1044
1093
|
}
|
|
1045
1094
|
getLifecycleLog().log({ event: 'created', sessionId: session.id, name: session.name });
|
|
@@ -1047,7 +1096,9 @@ options = {}) {
|
|
|
1047
1096
|
// Avoids serializing 2-3MB of terminal+text buffers per session creation.
|
|
1048
1097
|
const lightState = ctx.getSessionStateWithRespawn(session);
|
|
1049
1098
|
ctx.broadcast(SseEvent.SessionCreated, lightState);
|
|
1050
|
-
|
|
1099
|
+
// A grouped creation also returns the layout that placed it, so the browser
|
|
1100
|
+
// draws the tab inside its group at once instead of after a re-read.
|
|
1101
|
+
return { session: lightState, ...(body.tabGroupId ? { tabLayout } : {}) };
|
|
1051
1102
|
});
|
|
1052
1103
|
// ========== Rename Session ==========
|
|
1053
1104
|
app.put('/api/sessions/:id/name', async (req) => {
|
|
@@ -2672,6 +2723,8 @@ options = {}) {
|
|
|
2672
2723
|
// Query params:
|
|
2673
2724
|
// tail=<bytes> - Only return last N bytes (faster initial load)
|
|
2674
2725
|
// full=1 - Explicitly request the entire tmux scrollback (COD-47)
|
|
2726
|
+
// alt=recording - With full=1: if the pane is in the alternate screen, load
|
|
2727
|
+
// the visible frame over the byte recording instead
|
|
2675
2728
|
app.get('/api/sessions/:id/terminal', async (req, reply) => {
|
|
2676
2729
|
const routeStartedAt = performance.now();
|
|
2677
2730
|
const { id } = req.params;
|
|
@@ -2682,7 +2735,7 @@ options = {}) {
|
|
|
2682
2735
|
// Requests WITHOUT it — shell selection/tab switches (`tail=`) and legacy
|
|
2683
2736
|
// no-param callers — keep the fast visible-frame capture.
|
|
2684
2737
|
const tailBytes = query.tail ? parseInt(query.tail, 10) : 0;
|
|
2685
|
-
|
|
2738
|
+
let isFullReload = query.full === '1' || query.full === 'true';
|
|
2686
2739
|
const { tmuxHistoryLimit, terminalBufferMaxBytes } = await ctx.getTerminalHistoryConfig();
|
|
2687
2740
|
// Prepend the live tmux pane buffer so tab-switch replay shows the current
|
|
2688
2741
|
// on-screen frame, not just the accumulated byte history. This matters for
|
|
@@ -2699,16 +2752,48 @@ options = {}) {
|
|
|
2699
2752
|
// for. See PaneCaptureOptions.capturedGeometry.
|
|
2700
2753
|
// `lines` bounds only the history a FULL capture reads; the visible-frame
|
|
2701
2754
|
// path reads no history and is untouched by it.
|
|
2702
|
-
|
|
2755
|
+
let captureOpts = isFullReload
|
|
2703
2756
|
? {
|
|
2704
2757
|
fullHistory: true,
|
|
2705
2758
|
historyLimitLines: captureHistoryLines(query.lines, tmuxHistoryLimit),
|
|
2706
2759
|
maxCaptureBytes: terminalBufferMaxBytes,
|
|
2707
2760
|
}
|
|
2708
2761
|
: {};
|
|
2709
|
-
|
|
2710
|
-
|
|
2762
|
+
// A remote session's local pane only sees the remote tmux client's repaints,
|
|
2763
|
+
// so its own scrollback is a fraction of the real one: read that from the
|
|
2764
|
+
// remote tmux and splice it above the local frame. Null (host unreachable,
|
|
2765
|
+
// no remote session, back-off) keeps the local capture as before.
|
|
2766
|
+
if (isFullReload && session.remote && muxName && captureOpts.historyLimitLines !== undefined) {
|
|
2767
|
+
const remoteScrollback = await fetchRemoteScrollback(session.remote, session.id, captureOpts.historyLimitLines, terminalBufferMaxBytes);
|
|
2768
|
+
if (remoteScrollback !== null) {
|
|
2769
|
+
captureOpts.scrollbackOverride = remoteScrollback.scrollback;
|
|
2770
|
+
captureOpts.scrollbackOverrideScreen = remoteScrollback.screen;
|
|
2771
|
+
}
|
|
2772
|
+
}
|
|
2773
|
+
const capture = (opts) => muxName && typeof ctx.mux.captureActivePaneBuffer === 'function'
|
|
2774
|
+
? ctx.mux.captureActivePaneBuffer(muxName, opts)
|
|
2711
2775
|
: null;
|
|
2776
|
+
let liveMuxBuffer = capture(captureOpts);
|
|
2777
|
+
// tmux adds no history while a pane is in the alternate screen, and a
|
|
2778
|
+
// nested tmux client (a remote SSH or Docker case, or `tmux` run by hand
|
|
2779
|
+
// in a shell) holds its pane there for good, so the full capture above is
|
|
2780
|
+
// one screen plus whatever came before. The byte recording still carries
|
|
2781
|
+
// the scrollback, so a caller that opts in (a shell's automatic load,
|
|
2782
|
+
// `alt=recording`) gets the old visible-frame load over it, tailed, rather
|
|
2783
|
+
// than losing everything above the screen. `alternate_on` cannot tell a
|
|
2784
|
+
// nested tmux from vim or less, so those take this path too while open:
|
|
2785
|
+
// the load the shell had before tmux-rendered loads existed. Not when the
|
|
2786
|
+
// history came from the remote tmux (`scrollbackOverride`): a remote pane is
|
|
2787
|
+
// in the alternate screen only because it hosts that tmux's client, and
|
|
2788
|
+
// the fallback would throw the remote scrollback away.
|
|
2789
|
+
if (isFullReload &&
|
|
2790
|
+
query.alt === 'recording' &&
|
|
2791
|
+
captureOpts.scrollbackOverride === undefined &&
|
|
2792
|
+
captureOpts.capturedAlternateOn === true) {
|
|
2793
|
+
isFullReload = false;
|
|
2794
|
+
captureOpts = {};
|
|
2795
|
+
liveMuxBuffer = capture(captureOpts);
|
|
2796
|
+
}
|
|
2712
2797
|
const captureFinishedAt = performance.now();
|
|
2713
2798
|
const hasLiveMuxBuffer = liveMuxBuffer !== null && liveMuxBuffer.length > 0;
|
|
2714
2799
|
const source = hasLiveMuxBuffer
|
|
@@ -2749,6 +2834,29 @@ options = {}) {
|
|
|
2749
2834
|
// why the UI could only ever say "truncated for performance".
|
|
2750
2835
|
let truncationReason = null;
|
|
2751
2836
|
let cleanBuffer;
|
|
2837
|
+
// A `lines=` bound shallower than tmux's history left the older lines in
|
|
2838
|
+
// tmux: an intentional partial read, so 'tail', and a later pull with a
|
|
2839
|
+
// deeper (or no) bound recovers them. Without this a bounded window looks
|
|
2840
|
+
// like the whole history to the client. The byte cap below still wins.
|
|
2841
|
+
// Only a bound the CLIENT set and below the configured ceiling counts:
|
|
2842
|
+
// without `lines=` the bound is the ceiling itself, and on tmux before 3.7
|
|
2843
|
+
// a live pane can hold more than a lowered ceiling, which no request can
|
|
2844
|
+
// read, so calling that a recoverable cut would offer a button that can
|
|
2845
|
+
// never return more.
|
|
2846
|
+
// `historyLinesCut` tells the client this happened, apart from any byte
|
|
2847
|
+
// cut below: `fullSize` counts only what tmux returned, so the bytes a
|
|
2848
|
+
// later `tail` cut are not all that is left, and the client must not name
|
|
2849
|
+
// them as the remainder.
|
|
2850
|
+
const capturedHistoryLines = captureOpts.capturedHistoryLines;
|
|
2851
|
+
const historyLinesCut = isFullCapture &&
|
|
2852
|
+
captureOpts.historyLimitLines !== undefined &&
|
|
2853
|
+
captureOpts.historyLimitLines < tmuxHistoryLimit &&
|
|
2854
|
+
capturedHistoryLines !== undefined &&
|
|
2855
|
+
capturedHistoryLines > captureOpts.historyLimitLines;
|
|
2856
|
+
if (historyLinesCut) {
|
|
2857
|
+
truncated = true;
|
|
2858
|
+
truncationReason = 'tail';
|
|
2859
|
+
}
|
|
2752
2860
|
// Cap the payload EARLY — before the regex normalization passes below run
|
|
2753
2861
|
// over it. A full-history tmux capture can be tens of MB of scrollback;
|
|
2754
2862
|
// normalizing all of it would stall the event loop only to discard most
|
|
@@ -2828,6 +2936,9 @@ options = {}) {
|
|
|
2828
2936
|
fullSize,
|
|
2829
2937
|
truncated,
|
|
2830
2938
|
truncationReason,
|
|
2939
|
+
// The `lines=` bound left older lines in tmux (see above): the remainder
|
|
2940
|
+
// is more than `fullSize - retainedBytes`, by an amount unknown here.
|
|
2941
|
+
historyLinesCut,
|
|
2831
2942
|
// `retainedBytes` is what this response actually carries; `fullSize` is
|
|
2832
2943
|
// what existed before the cut. The gap is what the indicator reports.
|
|
2833
2944
|
retainedBytes: cleanBuffer.length,
|
|
@@ -2851,6 +2962,15 @@ options = {}) {
|
|
|
2851
2962
|
// damage that does not exist.
|
|
2852
2963
|
captureCols: hasLiveMuxBuffer ? captureOpts.capturedGeometry?.cols : undefined,
|
|
2853
2964
|
captureRows: hasLiveMuxBuffer ? captureOpts.capturedGeometry?.rows : undefined,
|
|
2965
|
+
// Rows tmux holds above the visible frame, which is the most a `full=1`
|
|
2966
|
+
// pull can add. `truncated` measures the BYTE stream, and for a pane that
|
|
2967
|
+
// keeps no scrollback (a fullscreen CLI in the alternate screen) the bytes
|
|
2968
|
+
// a tail cut drops are old repaints that no request can bring back, so a
|
|
2969
|
+
// client must not offer to load them. Absent when the pane was not read,
|
|
2970
|
+
// and for a remote session: its local pane hosts the remote tmux client in
|
|
2971
|
+
// the alternate screen, so it always reports 0, while a `full=1` load reads
|
|
2972
|
+
// the real scrollback from the remote tmux (`fetchRemoteScrollback`).
|
|
2973
|
+
paneHistoryLines: hasLiveMuxBuffer && !session.remote ? captureOpts.capturedHistoryLines : undefined,
|
|
2854
2974
|
};
|
|
2855
2975
|
});
|
|
2856
2976
|
// ═══════════════════════════════════════════════════════════════
|
|
@@ -3053,7 +3173,7 @@ options = {}) {
|
|
|
3053
3173
|
const capMsg = sessionCapacityMessage(ctx.sessions, owner);
|
|
3054
3174
|
if (capMsg)
|
|
3055
3175
|
return createErrorResponse(ApiErrorCode.SESSION_BUSY, capMsg);
|
|
3056
|
-
const { caseName = 'testcase', sessionName, mode = 'claude', modelOverride, openCodeConfig, codexConfig, geminiConfig, antigravityConfig, piConfig, grokConfig, deepSeekConfig, ompConfig, envOverrides, effort, advisorModel, parentSessionId, agentOrigin, customModel, } = parseBody(QuickStartSchema, req.body);
|
|
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);
|
|
3057
3177
|
// Resolved ONCE here: the same value labels a case directory this request creates
|
|
3058
3178
|
// (agent-case-marker.ts) and draws the tab lineage line on the session below.
|
|
3059
3179
|
const qsParentSessionId = resolveParentSessionId(ctx, req, parentSessionId, owner);
|
|
@@ -3341,12 +3461,14 @@ options = {}) {
|
|
|
3341
3461
|
}
|
|
3342
3462
|
}
|
|
3343
3463
|
// Agent skill injection (docs/agent-control-plan.md §2): ADD-ONLY on create,
|
|
3344
|
-
// marker-guarded (a user's own
|
|
3345
|
-
//
|
|
3346
|
-
//
|
|
3347
|
-
//
|
|
3348
|
-
|
|
3349
|
-
|
|
3464
|
+
// marker-guarded (a user's own copy is never touched). Each CLI that takes the
|
|
3465
|
+
// skill gets it in the directory it scans (`capabilities.agentSkillDir`:
|
|
3466
|
+
// `.claude/skills` for claude, `.agents/skills` for most others); skipped for
|
|
3467
|
+
// remote cases, whose casePath lives on another host. Docker cases qualify:
|
|
3468
|
+
// hostWorkspacePath is a real host dir and the skill crosses the bind mount.
|
|
3469
|
+
const quickStartSkillDir = agentSkillDir(getCli(mode)?.capabilities);
|
|
3470
|
+
if (!remote && quickStartSkillDir && (await ctx.getAgentSkillEnabled())) {
|
|
3471
|
+
await injectAgentSkill(resolvedCasePath, quickStartSkillDir);
|
|
3350
3472
|
}
|
|
3351
3473
|
// Docker cases: the workspace is a REAL host dir bind-mounted into the container.
|
|
3352
3474
|
// Scaffold hooks (+ a CLAUDE.md) if MISSING so in-container permission prompts and
|
|
@@ -3600,13 +3722,14 @@ options = {}) {
|
|
|
3600
3722
|
session.ralphTracker.enableAutoEnable(); // Allow re-enabling on restart
|
|
3601
3723
|
}
|
|
3602
3724
|
}
|
|
3603
|
-
await ctx.addSession(session);
|
|
3725
|
+
const tabLayout = tabGroupId ? await ctx.addSession(session, { tabGroupId }) : await ctx.addSession(session);
|
|
3604
3726
|
ctx.store.incrementSessionsCreated();
|
|
3605
3727
|
ctx.persistSessionState(session);
|
|
3606
3728
|
await ctx.setupSessionListeners(session);
|
|
3607
3729
|
// Pre-seed the agent skill's preamble cache so its §0 bootstrap is a two-line
|
|
3608
|
-
// loader (see seedAgentSessionPreamble). Local
|
|
3609
|
-
|
|
3730
|
+
// loader (see seedAgentSessionPreamble). Local sessions of a CLI that takes the
|
|
3731
|
+
// skill; best-effort.
|
|
3732
|
+
if (agentSkillDir(getCli(mode)?.capabilities) && !remote && !docker && (await ctx.getAgentSkillEnabled())) {
|
|
3610
3733
|
await seedAgentSessionPreamble(session.id).catch((err) => console.warn(`[agent-skill] preamble seed failed for ${session.id}: ${getErrorMessage(err)}`));
|
|
3611
3734
|
}
|
|
3612
3735
|
getLifecycleLog().log({
|
|
@@ -3679,6 +3802,7 @@ options = {}) {
|
|
|
3679
3802
|
casePath: resolvedCasePath,
|
|
3680
3803
|
caseName,
|
|
3681
3804
|
...(customModel ? { modelSwapInProgress: qsCustomModelSwapInProgress } : {}),
|
|
3805
|
+
...(tabGroupId ? { tabLayout } : {}),
|
|
3682
3806
|
};
|
|
3683
3807
|
}
|
|
3684
3808
|
catch (err) {
|
|
@@ -4565,6 +4689,17 @@ options = {}) {
|
|
|
4565
4689
|
// Paste Image (clipboard / drag-drop upload)
|
|
4566
4690
|
// ═══════════════════════════════════════════════════════════════
|
|
4567
4691
|
const ALLOWED_IMAGE_EXTS = new Set(['.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp', '.heic', '.heif']);
|
|
4692
|
+
// Single-sourced from the attachment registry (what the file viewer can also play),
|
|
4693
|
+
// so an uploaded video is a type that previews. 3gp/mkv are deliberately out: they
|
|
4694
|
+
// would upload but not preview, and a phone's camera roll produces neither.
|
|
4695
|
+
const ALLOWED_VIDEO_EXTS = new Set([...VIDEO_ATTACHMENT_EXTENSIONS].map((e) => `.${e}`));
|
|
4696
|
+
const VIDEO_MIME_EXT = {
|
|
4697
|
+
mp4: '.mp4',
|
|
4698
|
+
quicktime: '.mov',
|
|
4699
|
+
'x-m4v': '.m4v',
|
|
4700
|
+
webm: '.webm',
|
|
4701
|
+
ogg: '.ogv',
|
|
4702
|
+
};
|
|
4568
4703
|
// The per-file size cap (MAX_PASTE_IMAGE_BYTES) is enforced by @fastify/multipart (registered in server.ts).
|
|
4569
4704
|
app.post('/api/sessions/:id/paste-image', async (req, reply) => {
|
|
4570
4705
|
// CSRF defense: state-changing routes must come from same origin.
|
|
@@ -4602,12 +4737,19 @@ options = {}) {
|
|
|
4602
4737
|
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'CSRF check failed');
|
|
4603
4738
|
}
|
|
4604
4739
|
const { id } = req.params;
|
|
4605
|
-
//
|
|
4606
|
-
//
|
|
4607
|
-
|
|
4740
|
+
// `?kind=video` selects the disk-streamed path (see below). It is read here so the
|
|
4741
|
+
// rate check draws on the right bucket before any work: videos get 3/min, images 30.
|
|
4742
|
+
const isVideo = req.query.kind === 'video';
|
|
4743
|
+
// Rate limit per (IP, sessionId). Defends against disk-fill DoS — even an
|
|
4744
|
+
// authenticated client can otherwise loop large uploads. Videos get their own,
|
|
4745
|
+
// far lower bucket because the per-file cap is 1GB (see the bucket constants).
|
|
4746
|
+
const rateKey = `${req.ip}:${id}`;
|
|
4747
|
+
if (!(isVideo ? consumeVideoToken(rateKey) : consumePasteToken(rateKey))) {
|
|
4608
4748
|
reply.code(429);
|
|
4609
4749
|
reply.header('Retry-After', '60');
|
|
4610
|
-
return createErrorResponse(ApiErrorCode.INVALID_INPUT,
|
|
4750
|
+
return createErrorResponse(ApiErrorCode.INVALID_INPUT, isVideo
|
|
4751
|
+
? 'Rate limit exceeded (3 video uploads/min per session)'
|
|
4752
|
+
: 'Rate limit exceeded (30 uploads/min per session)');
|
|
4611
4753
|
}
|
|
4612
4754
|
const session = findSessionOrFail(ctx, id, req);
|
|
4613
4755
|
// The file lands on THIS host under the session's working directory, which
|
|
@@ -4620,6 +4762,16 @@ options = {}) {
|
|
|
4620
4762
|
reply.code(400);
|
|
4621
4763
|
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Prompt uploads are not supported for remote (SSH) sessions');
|
|
4622
4764
|
}
|
|
4765
|
+
// The upload is written under session.workingDir (ensureUploadDir below), and
|
|
4766
|
+
// a live session's workspace can sit on a network mount that stopped answering.
|
|
4767
|
+
// Probe it bounded before the first disk touch, as #516 requires for a path the
|
|
4768
|
+
// user named, so one dead mount cannot freeze a libuv threadpool worker per
|
|
4769
|
+
// upload. Only "unknown" (the mount is not answering) is refused here, the way
|
|
4770
|
+
// the create route does; absent / not-a-directory keep ensureUploadDir's own
|
|
4771
|
+
// errors, since a live session's workspace should be neither.
|
|
4772
|
+
if ((await probePathKind(session.workingDir, { pastCap: true })) === 'unknown') {
|
|
4773
|
+
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, describeUnknownPath('Workspace', session.workingDir, { pastCap: true }));
|
|
4774
|
+
}
|
|
4623
4775
|
if (!req.isMultipart()) {
|
|
4624
4776
|
reply.code(400);
|
|
4625
4777
|
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Expected multipart/form-data');
|
|
@@ -4630,9 +4782,15 @@ options = {}) {
|
|
|
4630
4782
|
// boundary scanner with several bugs: literal boundary matches anywhere in
|
|
4631
4783
|
// body, LF-only clients silently corrupted the last byte (hard-coded \r\n
|
|
4632
4784
|
// offsets), no part-count cap.
|
|
4785
|
+
// `?kind=video` (read above for the rate check) is the ONLY way onto the video
|
|
4786
|
+
// path: it selects the larger, disk-streamed budget before the part is seen
|
|
4787
|
+
// (busboy takes its limits up front), and it admits video types alone, so an
|
|
4788
|
+
// image can never ride the video cap through memory. Without it the route is
|
|
4789
|
+
// byte-identical to the image-only version. Truncation is checked on the stream
|
|
4790
|
+
// below rather than thrown, since the file is being written as it arrives.
|
|
4633
4791
|
let part;
|
|
4634
4792
|
try {
|
|
4635
|
-
part = await req.file();
|
|
4793
|
+
part = await req.file(isVideo ? { limits: { fileSize: MAX_PASTE_VIDEO_BYTES }, throwFileSizeLimit: false } : undefined);
|
|
4636
4794
|
}
|
|
4637
4795
|
catch (err) {
|
|
4638
4796
|
reply.code(413);
|
|
@@ -4646,6 +4804,76 @@ options = {}) {
|
|
|
4646
4804
|
reply.code(400);
|
|
4647
4805
|
return createErrorResponse(ApiErrorCode.INVALID_INPUT, `Unexpected field "${part.fieldname}", expected "image"`);
|
|
4648
4806
|
}
|
|
4807
|
+
if (isVideo) {
|
|
4808
|
+
let ext = '';
|
|
4809
|
+
if (part.filename) {
|
|
4810
|
+
const origExt = extname(part.filename).toLowerCase();
|
|
4811
|
+
if (ALLOWED_VIDEO_EXTS.has(origExt))
|
|
4812
|
+
ext = origExt;
|
|
4813
|
+
}
|
|
4814
|
+
const videoMime = (part.mimetype || '').toLowerCase().match(/^video\/(mp4|quicktime|x-m4v|webm|ogg)$/);
|
|
4815
|
+
if (videoMime)
|
|
4816
|
+
ext = VIDEO_MIME_EXT[videoMime[1]] ?? ext;
|
|
4817
|
+
if (!ext) {
|
|
4818
|
+
await finished(part.file.resume()).catch(() => { });
|
|
4819
|
+
reply.code(400);
|
|
4820
|
+
return createErrorResponse(ApiErrorCode.INVALID_INPUT, `Unsupported video type. Allowed: ${[...ALLOWED_VIDEO_EXTS].join(', ')}`);
|
|
4821
|
+
}
|
|
4822
|
+
const imageDir = await ensureUploadDir(session.workingDir);
|
|
4823
|
+
if (!imageDir) {
|
|
4824
|
+
await finished(part.file.resume()).catch(() => { });
|
|
4825
|
+
reply.code(403);
|
|
4826
|
+
return createErrorResponse(ApiErrorCode.INVALID_INPUT, `${UPLOADS_DIR} is not a regular directory`);
|
|
4827
|
+
}
|
|
4828
|
+
const { filename, filepath, fh } = await openPasteFile(imageDir, ext);
|
|
4829
|
+
// Written as it arrives: a phone video is far too large to buffer the way an
|
|
4830
|
+
// image is. The container is judged on the first 12 bytes as soon as they are
|
|
4831
|
+
// in, so a mislabeled upload stops touching disk at its first chunk; the rest
|
|
4832
|
+
// of the body is drained unwritten (as busboy does past its own size limit),
|
|
4833
|
+
// which is what lets the answer reach the client. A transfer that dies midway
|
|
4834
|
+
// (the phone left the network) leaves no partial file behind.
|
|
4835
|
+
const head = Buffer.alloc(12);
|
|
4836
|
+
let headLen = 0;
|
|
4837
|
+
let mislabeled = false;
|
|
4838
|
+
try {
|
|
4839
|
+
for await (const chunk of part.file) {
|
|
4840
|
+
if (mislabeled)
|
|
4841
|
+
continue;
|
|
4842
|
+
if (headLen < head.length) {
|
|
4843
|
+
headLen += chunk.copy(head, headLen, 0, head.length - headLen);
|
|
4844
|
+
mislabeled = headLen === head.length && !imageMagicMatchesExt(head, ext);
|
|
4845
|
+
if (mislabeled)
|
|
4846
|
+
continue;
|
|
4847
|
+
}
|
|
4848
|
+
// writeFile loops until the whole chunk is on disk; a bare write() may be short.
|
|
4849
|
+
await fh.writeFile(chunk);
|
|
4850
|
+
}
|
|
4851
|
+
}
|
|
4852
|
+
catch (err) {
|
|
4853
|
+
await fs.unlink(filepath).catch(() => { });
|
|
4854
|
+
throw err;
|
|
4855
|
+
}
|
|
4856
|
+
finally {
|
|
4857
|
+
await fh.close();
|
|
4858
|
+
}
|
|
4859
|
+
let failure = null;
|
|
4860
|
+
if (headLen === 0)
|
|
4861
|
+
failure = { status: 400, message: 'Empty file' };
|
|
4862
|
+
else if (!imageMagicMatchesExt(head.subarray(0, headLen), ext)) {
|
|
4863
|
+
console.warn(`[paste-image] video magic mismatch: filename=${JSON.stringify(part.filename)} mime=${JSON.stringify(part.mimetype)} declaredExt=${ext} magic=${head.subarray(0, headLen).toString('hex')}`);
|
|
4864
|
+
failure = { status: 415, message: `Video bytes do not match declared type ${ext}` };
|
|
4865
|
+
}
|
|
4866
|
+
else if (part.file.truncated) {
|
|
4867
|
+
const maxMb = Math.round(MAX_PASTE_VIDEO_BYTES / (1024 * 1024));
|
|
4868
|
+
failure = { status: 413, message: `File too large (max ${maxMb}MB)` };
|
|
4869
|
+
}
|
|
4870
|
+
if (failure) {
|
|
4871
|
+
await fs.unlink(filepath).catch(() => { });
|
|
4872
|
+
reply.code(failure.status);
|
|
4873
|
+
return createErrorResponse(ApiErrorCode.INVALID_INPUT, failure.message);
|
|
4874
|
+
}
|
|
4875
|
+
return { path: filepath, filename };
|
|
4876
|
+
}
|
|
4649
4877
|
let imageBytes;
|
|
4650
4878
|
try {
|
|
4651
4879
|
imageBytes = await part.toBuffer();
|
|
@@ -4715,13 +4943,7 @@ options = {}) {
|
|
|
4715
4943
|
reply.code(403);
|
|
4716
4944
|
return createErrorResponse(ApiErrorCode.INVALID_INPUT, `${UPLOADS_DIR} is not a regular directory`);
|
|
4717
4945
|
}
|
|
4718
|
-
|
|
4719
|
-
// silently). Append 8 hex chars so concurrent pastes get distinct names.
|
|
4720
|
-
const filename = `paste-${Date.now()}-${randomBytes(4).toString('hex')}${ext}`;
|
|
4721
|
-
const filepath = join(imageDir, filename);
|
|
4722
|
-
// O_EXCL: refuse to overwrite (collision is impossible with random suffix,
|
|
4723
|
-
// but defends against TOCTOU). O_NOFOLLOW: refuse if filepath is a symlink.
|
|
4724
|
-
const fh = await fs.open(filepath, fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_NOFOLLOW);
|
|
4946
|
+
const { filename, filepath, fh } = await openPasteFile(imageDir, ext);
|
|
4725
4947
|
try {
|
|
4726
4948
|
await fh.writeFile(imageBytes);
|
|
4727
4949
|
}
|