aicodeman 1.41.0 → 1.42.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 +101 -21
- 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 +13 -9
- 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 +10 -0
- package/dist/config/cli-registry/schema.d.ts.map +1 -1
- package/dist/config/cli-registry/schema.js +26 -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 +89 -6
- package/dist/config/cli-registry/stock.js.map +1 -1
- package/dist/config/cli-registry/types.d.ts +61 -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/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 +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 +918 -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/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-submit-verifier.js +1 -1
- package/dist/session.d.ts +90 -2
- package/dist/session.d.ts.map +1 -1
- package/dist/session.js +247 -9
- 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 +130 -7
- package/dist/tmux-manager.d.ts.map +1 -1
- package/dist/tmux-manager.js +263 -36
- 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/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/web/approval-inbox.d.ts +24 -2
- package/dist/web/approval-inbox.d.ts.map +1 -1
- package/dist/web/approval-inbox.js +8 -2
- 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.f04bb2a5.js +43 -0
- package/dist/web/public/app.f04bb2a5.js.br +0 -0
- package/dist/web/public/app.f04bb2a5.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.12b0c247.js} +395 -7
- package/dist/web/public/constants.12b0c247.js.br +0 -0
- package/dist/web/public/constants.12b0c247.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.06659676.js +2 -0
- package/dist/web/public/i18n.06659676.js.br +0 -0
- package/dist/web/public/i18n.06659676.js.gz +0 -0
- package/dist/web/public/image-input.fcdcb46c.js +462 -0
- package/dist/web/public/image-input.fcdcb46c.js.br +0 -0
- package/dist/web/public/image-input.fcdcb46c.js.gz +0 -0
- package/dist/web/public/index.html +169 -24
- 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.b42f539b.js} +124 -44
- package/dist/web/public/keyboard-accessory.b42f539b.js.br +0 -0
- package/dist/web/public/keyboard-accessory.b42f539b.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 → 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/session-lineage.js.gz +0 -0
- package/dist/web/public/session-ui.d52ff6aa.js +100 -0
- package/dist/web/public/session-ui.d52ff6aa.js.br +0 -0
- package/dist/web/public/session-ui.d52ff6aa.js.gz +0 -0
- package/dist/web/public/settings-ui.3a22bd2a.js +97 -0
- package/dist/web/public/settings-ui.3a22bd2a.js.br +0 -0
- package/dist/web/public/settings-ui.3a22bd2a.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.ba6a9b84.css +1 -0
- package/dist/web/public/styles.ba6a9b84.css.br +0 -0
- package/dist/web/public/styles.ba6a9b84.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 +45 -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.0a7ccc29.js +2 -0
- package/dist/web/public/terminal-ui.0a7ccc29.js.br +0 -0
- package/dist/web/public/terminal-ui.0a7ccc29.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/public/whats-new.json +13 -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 +12 -10
- 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 +10 -3
- 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 +2 -0
- package/dist/web/routes/index.d.ts.map +1 -1
- package/dist/web/routes/index.js +2 -0
- package/dist/web/routes/index.js.map +1 -1
- 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/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 +8 -5
- package/dist/web/routes/session-routes.d.ts.map +1 -1
- package/dist/web/routes/session-routes.js +462 -109
- 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 +24 -6
- 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 +91 -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 +45 -0
- package/dist/web/schemas.d.ts.map +1 -1
- package/dist/web/schemas.js +105 -0
- 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 +6 -1
- package/dist/web/server.d.ts.map +1 -1
- package/dist/web/server.js +73 -14
- 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 +12 -4
- package/dist/web/sse-events.d.ts.map +1 -1
- package/dist/web/sse-events.js +12 -4
- package/dist/web/sse-events.js.map +1 -1
- package/package.json +3 -2
- package/skills/codeman/SKILL.md +47 -27
- package/skills/codeman/preamble.sh +14 -11
- package/skills/codeman/reference/endpoints.md +1 -1
- package/skills/codeman/reference/recipes.md +12 -9
- package/skills/codeman/reference/verbs.md +3 -1
- 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 +0 -283
- 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/respawn-ui.ff0dae4c.js.br +0 -0
- package/dist/web/public/respawn-ui.ff0dae4c.js.gz +0 -0
- package/dist/web/public/session-ui.f731602c.js +0 -100
- 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 +0 -97
- 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,15 @@ 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';
|
|
13
|
+
import { isUtf8 } from 'node:buffer';
|
|
12
14
|
import { performance } from 'node:perf_hooks';
|
|
13
15
|
import { ApiErrorCode, createErrorResponse, getErrorMessage, } from '../../types.js';
|
|
14
16
|
import { Session, cliTakesSessionModel, isAltScreenStripMode, isExternalCliMode, isMuxAltScreenOnlyStripMode, isMuxMouseStripMode, } from '../../session.js';
|
|
15
17
|
import { SseEvent } from '../sse-events.js';
|
|
16
18
|
import { webviewCapabilities } from '../../webview-capabilities.js';
|
|
19
|
+
import { htmlViewCapabilities } from '../../html-view-capabilities.js';
|
|
17
20
|
import { CreateSessionSchema, SessionNameSchema, SessionColorSchema, RunPromptSchema, SessionInputWithLimitSchema, ResizeSchema, AutoClearSchema, AutoCompactSchema, AutoResumeSchema, PinSessionSchema, ImageWatcherSchema, FlickerFilterSchema, QuickRunSchema, QuickStartSchema, InteractiveStartSchema, SessionOrderUpdateSchema, SessionWaitQuerySchema, SessionWaitOutputQuerySchema, CustomModelSelectionSchema, } from '../schemas.js';
|
|
18
21
|
import { readCustomModelHosts } from '../../custom-model-hosts.js';
|
|
19
22
|
import { applyCustomModelInjection, removeConfigDir } from '../../custom-model-injection-apply.js';
|
|
@@ -34,7 +37,7 @@ import { applyLaunchDefaults } from '../launch-defaults.js';
|
|
|
34
37
|
import { legacyConfigForMode } from '../../session-cli-registry-bridge.js';
|
|
35
38
|
import { isMultiUserMode } from '../../config/multiuser.js';
|
|
36
39
|
import { AUTH_COOKIE_NAME } from '../middleware/auth.js';
|
|
37
|
-
import { writeHooksConfig, updateCaseModel, stripCaseEnvKeys, applyAgentSkill, refreshUserAgentSkill, seedAgentSessionPreamble, applyWorkspaceHooks, refreshStaleCodemanHooks, } from '../../hooks-config.js';
|
|
40
|
+
import { writeHooksConfig, updateCaseModel, stripCaseEnvKeys, agentSkillDir, agentSkillDirFor, applyAgentSkill, refreshUserAgentSkill, seedAgentSessionPreamble, applyWorkspaceHooks, refreshStaleCodemanHooks, } from '../../hooks-config.js';
|
|
38
41
|
import { generateClaudeMd } from '../../templates/claude-md.js';
|
|
39
42
|
import { imageWatcher } from '../../image-watcher.js';
|
|
40
43
|
import { convertHeicToJpeg } from '../heic-jpeg-converter.js';
|
|
@@ -43,10 +46,13 @@ import { mergeUnifiedSessions, filterAndPaginate, } from '../../services/unified
|
|
|
43
46
|
import { buildHistorySessionIndexItems, setHistoryIndexRefresher, setHistorySessionIndex, } from '../session-history-index.js';
|
|
44
47
|
import { RunSummaryTracker } from '../../run-summary.js';
|
|
45
48
|
import { MAX_INPUT_LENGTH, MAX_SESSION_NAME_LENGTH } from '../../config/terminal-limits.js';
|
|
46
|
-
import { MAX_PASTE_IMAGE_BYTES } from '../../config/buffer-limits.js';
|
|
49
|
+
import { MAX_PASTE_IMAGE_BYTES, MAX_PASTE_VIDEO_BYTES } from '../../config/buffer-limits.js';
|
|
50
|
+
import { VIDEO_ATTACHMENT_EXTENSIONS } from '../../attachment-registry.js';
|
|
47
51
|
import { dataPath, getDataDir } from '../../config/instance.js';
|
|
48
52
|
import { checkRemoteTmuxAvailable, readRemoteCases, readRemoteHosts, rehydrateRemoteHostFields, toAttachedSessionRemote, toSessionRemote, } from '../../remote-hosts.js';
|
|
53
|
+
import { fetchRemoteScrollback } from '../../remote-pane-history.js';
|
|
49
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';
|
|
50
56
|
import { LRUMap } from '../../utils/lru-map.js';
|
|
51
57
|
import { describeUnknownPath, probePathKind } from '../../utils/index.js';
|
|
52
58
|
import { findLatestOmpSessionId } from '../../utils/omp-session-resolver.js';
|
|
@@ -179,19 +185,37 @@ export function stripInkRedrawBloat(buffer) {
|
|
|
179
185
|
return parts.join('');
|
|
180
186
|
}
|
|
181
187
|
/**
|
|
182
|
-
*
|
|
183
|
-
*
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
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
|
+
* Validate upload bytes against a declared extension. Sniffs the first ~12 bytes
|
|
194
|
+
* for a known magic-number signature (a text file is instead required to be
|
|
195
|
+
* UTF-8 without a NUL byte, the one check plain text allows). Defends against
|
|
196
|
+
* polyglots (e.g. HTML or SVG disguised under a `Content-Type: image/png`
|
|
197
|
+
* header) and against simple extension-only spoofing — both the multipart
|
|
198
|
+
* filename and the Content-Type are attacker-controlled, the raw bytes are not.
|
|
199
|
+
* Fail-closed: an extension this function does not know is never a match.
|
|
187
200
|
*
|
|
188
201
|
* Signatures: https://en.wikipedia.org/wiki/List_of_file_signatures
|
|
189
202
|
*/
|
|
190
203
|
export function imageMagicMatchesExt(data, ext) {
|
|
204
|
+
if (TEXT_UPLOAD_EXTS.has(ext))
|
|
205
|
+
return data.length > 0 && !data.includes(0) && isUtf8(data);
|
|
191
206
|
if (data.length < 12)
|
|
192
207
|
return false;
|
|
193
208
|
const u32be = (off) => data.readUInt32BE(off);
|
|
194
209
|
switch (ext) {
|
|
210
|
+
case '.pdf':
|
|
211
|
+
// "%PDF-"
|
|
212
|
+
return u32be(0) === 0x25504446 && data[4] === 0x2d;
|
|
213
|
+
case '.docx':
|
|
214
|
+
case '.pptx':
|
|
215
|
+
case '.xlsx':
|
|
216
|
+
case '.zip':
|
|
217
|
+
// PK\x03\x04, the local-file header every non-empty zip (OOXML included) opens with
|
|
218
|
+
return u32be(0) === 0x504b0304;
|
|
195
219
|
case '.png':
|
|
196
220
|
return u32be(0) === 0x89504e47 && u32be(4) === 0x0d0a1a0a;
|
|
197
221
|
case '.jpg':
|
|
@@ -219,6 +243,19 @@ export function imageMagicMatchesExt(data, ext) {
|
|
|
219
243
|
const brand = data.subarray(8, 12).toString('ascii');
|
|
220
244
|
return ['heic', 'heix', 'hevc', 'hevx', 'mif1', 'msf1'].includes(brand);
|
|
221
245
|
}
|
|
246
|
+
case '.mp4':
|
|
247
|
+
case '.m4v':
|
|
248
|
+
case '.mov':
|
|
249
|
+
// ISO Base Media File Format / QuickTime: size + a top-level atom type. Brands
|
|
250
|
+
// vary too widely to enumerate (qt, isom, mp42, avc1, hev1, ...), and an older
|
|
251
|
+
// QuickTime file can open on an atom other than ftyp.
|
|
252
|
+
return ['ftyp', 'moov', 'mdat', 'wide', 'free', 'skip'].includes(data.subarray(4, 8).toString('ascii'));
|
|
253
|
+
case '.webm':
|
|
254
|
+
// EBML header (Matroska's WebM subset)
|
|
255
|
+
return u32be(0) === 0x1a45dfa3;
|
|
256
|
+
case '.ogv':
|
|
257
|
+
// Ogg container ("OggS")
|
|
258
|
+
return u32be(0) === 0x4f676753;
|
|
222
259
|
default:
|
|
223
260
|
return false;
|
|
224
261
|
}
|
|
@@ -235,7 +272,9 @@ export function imageMagicMatchesExt(data, ext) {
|
|
|
235
272
|
* touched, and a file already there is theirs and stays as it is.
|
|
236
273
|
*/
|
|
237
274
|
async function ensureUploadDir(workingDir) {
|
|
238
|
-
// workingDir
|
|
275
|
+
// workingDir exists (live session) and the caller bounded-probed its mount, so
|
|
276
|
+
// the lstat/mkdir below normally start on a reachable path; a mount that dies
|
|
277
|
+
// after that probe is the same residual race POST /api/sessions accepts.
|
|
239
278
|
const uploadDir = join(workingDir, UPLOADS_DIR);
|
|
240
279
|
try {
|
|
241
280
|
const dirStat = await fs.lstat(uploadDir);
|
|
@@ -275,37 +314,74 @@ async function ensureUploadDir(workingDir) {
|
|
|
275
314
|
}
|
|
276
315
|
return uploadDir;
|
|
277
316
|
}
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
317
|
+
/**
|
|
318
|
+
* The part of an uploaded document's name that survives into the saved name,
|
|
319
|
+
* so `Q3 report (final).pdf` is findable as `paste-<ts>-<hex>-Q3-report-final.pdf`.
|
|
320
|
+
* Only `[A-Za-z0-9._-]` survive, runs of anything else become one `-`, and the
|
|
321
|
+
* result is capped at 40 chars; nothing in it can be a path separator or a
|
|
322
|
+
* second extension that matters (the allowlisted extension is appended after).
|
|
323
|
+
*/
|
|
324
|
+
function sanitizeUploadStem(filename) {
|
|
325
|
+
const stem = basename(filename).replace(/\.[^.]*$/, '');
|
|
326
|
+
return stem
|
|
327
|
+
.replace(/[^A-Za-z0-9._-]+/g, '-')
|
|
328
|
+
.replace(/^[-.]+|[-.]+$/g, '')
|
|
329
|
+
.slice(0, 40);
|
|
330
|
+
}
|
|
331
|
+
/** Open a fresh, uniquely named paste file for writing. */
|
|
332
|
+
async function openPasteFile(imageDir, ext, stem = '') {
|
|
333
|
+
// Date.now() collides on same-ms uploads from two tabs (last-write wins
|
|
334
|
+
// silently). Append 8 hex chars so concurrent pastes get distinct names.
|
|
335
|
+
// The `paste-` prefix is what the GC (paste-image-gc.ts) matches on.
|
|
336
|
+
const filename = `paste-${Date.now()}-${randomBytes(4).toString('hex')}${stem ? `-${stem}` : ''}${ext}`;
|
|
337
|
+
const filepath = join(imageDir, filename);
|
|
338
|
+
// O_EXCL: refuse to overwrite (collision is impossible with random suffix,
|
|
339
|
+
// but defends against TOCTOU). O_NOFOLLOW: refuse if filepath is a symlink.
|
|
340
|
+
const fh = await fs.open(filepath, fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_NOFOLLOW);
|
|
341
|
+
return { filename, filepath, fh };
|
|
342
|
+
}
|
|
343
|
+
// Per-(IP, sessionId) token buckets for paste-image. Images: 30/min. Videos: 3/min,
|
|
344
|
+
// a much lower rate because one video can be up to MAX_PASTE_VIDEO_BYTES (1GB), so the
|
|
345
|
+
// 30/min cap would let a single authenticated client write 30GB of disk a minute; 3/min
|
|
346
|
+
// bounds that to 3GB. Bucket entries are pruned when they drift > 1h stale to bound
|
|
347
|
+
// memory against a flood of unique IP keys.
|
|
281
348
|
const PASTE_RATE_TOKENS = 30;
|
|
282
|
-
const
|
|
349
|
+
const VIDEO_RATE_TOKENS = 3;
|
|
283
350
|
const PASTE_BUCKET_TTL_MS = 60 * 60 * 1000;
|
|
284
351
|
const PASTE_BUCKET_GC_THRESHOLD = 1000;
|
|
285
352
|
const pasteRateBuckets = new Map();
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
353
|
+
const videoRateBuckets = new Map();
|
|
354
|
+
function takeToken(buckets, key, capacity, now) {
|
|
355
|
+
if (buckets.size > PASTE_BUCKET_GC_THRESHOLD) {
|
|
356
|
+
for (const [k, b] of buckets) {
|
|
289
357
|
if (now - b.lastRefill > PASTE_BUCKET_TTL_MS)
|
|
290
|
-
|
|
358
|
+
buckets.delete(k);
|
|
291
359
|
}
|
|
292
360
|
}
|
|
293
|
-
let b =
|
|
361
|
+
let b = buckets.get(key);
|
|
294
362
|
if (!b) {
|
|
295
|
-
b = { tokens:
|
|
296
|
-
|
|
363
|
+
b = { tokens: capacity, lastRefill: now };
|
|
364
|
+
buckets.set(key, b);
|
|
297
365
|
}
|
|
298
|
-
|
|
299
|
-
b.tokens = Math.min(PASTE_RATE_TOKENS, b.tokens + delta);
|
|
366
|
+
b.tokens = Math.min(capacity, b.tokens + (now - b.lastRefill) * (capacity / 60_000));
|
|
300
367
|
b.lastRefill = now;
|
|
301
368
|
if (b.tokens < 1)
|
|
302
369
|
return false;
|
|
303
370
|
b.tokens -= 1;
|
|
304
371
|
return true;
|
|
305
372
|
}
|
|
373
|
+
export function consumePasteToken(key, now = Date.now()) {
|
|
374
|
+
return takeToken(pasteRateBuckets, key, PASTE_RATE_TOKENS, now);
|
|
375
|
+
}
|
|
376
|
+
// Videos stream to disk under a far larger per-file cap, so they draw on their own,
|
|
377
|
+
// much lower bucket (see the comment above the constants).
|
|
378
|
+
export function consumeVideoToken(key, now = Date.now()) {
|
|
379
|
+
return takeToken(videoRateBuckets, key, VIDEO_RATE_TOKENS, now);
|
|
380
|
+
}
|
|
306
381
|
// Test hook: reset between runs.
|
|
307
382
|
export function _resetPasteRateBuckets() {
|
|
308
383
|
pasteRateBuckets.clear();
|
|
384
|
+
videoRateBuckets.clear();
|
|
309
385
|
}
|
|
310
386
|
/**
|
|
311
387
|
* Security (multi-user §6.3): the Claude-only permission-mode downgrade does not
|
|
@@ -464,6 +540,23 @@ function currentSignalFor(session, dead) {
|
|
|
464
540
|
return 'exit';
|
|
465
541
|
return signalForStatus(session.status);
|
|
466
542
|
}
|
|
543
|
+
/**
|
|
544
|
+
* `currentSignalFor`, answering `blocked` for a session whose pane is showing a dialog
|
|
545
|
+
* its CLI is blocked on (`Session.screenBlocked`, read off the screen for a CLI with no
|
|
546
|
+
* hook: codex) when the caller asked for it. A hook-raised `blocked` is an edge with no
|
|
547
|
+
* history, but this one is a level the server is still looking at, so a caller that
|
|
548
|
+
* asks after the dialog appeared is answered at once instead of waiting out its timeout
|
|
549
|
+
* for an edge that already passed. Only when `blocked` was asked for: such a pane is also
|
|
550
|
+
* `idle`, and a caller that asked for `idle` alone must keep getting `idle`. Used by
|
|
551
|
+
* `GET .../wait` and by a DUPLICATE send-and-wait, every place a wait answers from the
|
|
552
|
+
* session's current state.
|
|
553
|
+
*/
|
|
554
|
+
function currentSignalForWait(session, dead, until) {
|
|
555
|
+
const signal = currentSignalFor(session, dead);
|
|
556
|
+
if (signal !== 'exit' && session.screenBlocked && until.includes('blocked'))
|
|
557
|
+
return 'blocked';
|
|
558
|
+
return signal;
|
|
559
|
+
}
|
|
467
560
|
// ── Worker liveness for tmux-backed sessions ────────────────────────────────
|
|
468
561
|
//
|
|
469
562
|
// `session.pid` is the LOCAL `tmux attach` client, not the worker. Codeman sets
|
|
@@ -626,8 +719,8 @@ function abortOnClientHangUp(reply) {
|
|
|
626
719
|
* Injection is best-effort and stays that way: neither a refusal nor a thrown error
|
|
627
720
|
* may fail the create.
|
|
628
721
|
*/
|
|
629
|
-
async function injectAgentSkill(casePath) {
|
|
630
|
-
const skillDir =
|
|
722
|
+
async function injectAgentSkill(casePath, dir) {
|
|
723
|
+
const skillDir = agentSkillDirFor(casePath, dir);
|
|
631
724
|
try {
|
|
632
725
|
// Claude Code loads a same-named USER-LEVEL skill (`~/.claude/skills/codeman`,
|
|
633
726
|
// written once by `codeman skill install`) over the case copy injected below, so a
|
|
@@ -636,7 +729,7 @@ async function injectAgentSkill(casePath) {
|
|
|
636
729
|
// current on the same trigger. Refresh-only + marker-guarded; quiet on refusal,
|
|
637
730
|
// since a foreign user copy is the user's own authored skill, not a config error.
|
|
638
731
|
await refreshUserAgentSkill();
|
|
639
|
-
const result = await applyAgentSkill(casePath, true);
|
|
732
|
+
const result = await applyAgentSkill(casePath, true, dir);
|
|
640
733
|
if (result === 'foreign') {
|
|
641
734
|
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
735
|
}
|
|
@@ -759,6 +852,7 @@ options = {}) {
|
|
|
759
852
|
// logout has to retire them too or every dashboard URL opened during this
|
|
760
853
|
// login keeps relaying without one (WebviewCapabilityStore.revokeOwner).
|
|
761
854
|
webviewCapabilities.revokeOwner(ownerFor(req));
|
|
855
|
+
htmlViewCapabilities.revokeOwner(ownerFor(req));
|
|
762
856
|
reply.clearCookie(AUTH_COOKIE_NAME, { path: '/' });
|
|
763
857
|
return {};
|
|
764
858
|
});
|
|
@@ -920,13 +1014,6 @@ options = {}) {
|
|
|
920
1014
|
// materializing in ~/.claude/settings.local.json was never asked for.
|
|
921
1015
|
if (!remote && body.workingDir && (body.mode ?? 'claude') === 'claude') {
|
|
922
1016
|
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
1017
|
}
|
|
931
1018
|
// Refuse up front if the requested CLI cannot start, rather than spawning a pane that
|
|
932
1019
|
// dies on `command not found`. The message comes from the resolver, so it names where
|
|
@@ -945,6 +1032,17 @@ options = {}) {
|
|
|
945
1032
|
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, cliLaunchError);
|
|
946
1033
|
}
|
|
947
1034
|
}
|
|
1035
|
+
// Agent skill (docs/agent-control-plan.md §2): ADD-ONLY on create, same shared-
|
|
1036
|
+
// workspace rationale as the statusLine above: a create must never remove the
|
|
1037
|
+
// skill from under other live sessions in the repo. Marker-guarded, so a user's
|
|
1038
|
+
// own copy is never touched. Every CLI that takes the skill gets it in the directory
|
|
1039
|
+
// that CLI scans (`capabilities.agentSkillDir`); the same remote and named-workingDir
|
|
1040
|
+
// conditions as the hooks above, and only once the launch pre-flight passed, so a
|
|
1041
|
+
// CLI that cannot start leaves nothing in the workspace.
|
|
1042
|
+
const createSkillDir = agentSkillDir(getCli(body.mode ?? 'claude')?.capabilities);
|
|
1043
|
+
if (!remote && body.workingDir && createSkillDir && (await ctx.getAgentSkillEnabled())) {
|
|
1044
|
+
await injectAgentSkill(workingDir, createSkillDir);
|
|
1045
|
+
}
|
|
948
1046
|
// Pre-validate resumeSessionId: check that the conversation file actually exists
|
|
949
1047
|
// in Claude's projects directory. If not, skip resume to avoid confusing
|
|
950
1048
|
// "No conversation found" errors from Claude CLI.
|
|
@@ -1033,13 +1131,16 @@ options = {}) {
|
|
|
1033
1131
|
owner,
|
|
1034
1132
|
parentSessionId: resolveParentSessionId(ctx, req, body.parentSessionId, owner),
|
|
1035
1133
|
});
|
|
1036
|
-
|
|
1134
|
+
const tabLayout = body.tabGroupId
|
|
1135
|
+
? await ctx.addSession(session, { tabGroupId: body.tabGroupId })
|
|
1136
|
+
: await ctx.addSession(session);
|
|
1037
1137
|
ctx.store.incrementSessionsCreated();
|
|
1038
1138
|
ctx.persistSessionState(session);
|
|
1039
1139
|
await ctx.setupSessionListeners(session);
|
|
1040
1140
|
// Pre-seed the agent skill's preamble cache so its §0 bootstrap is a two-line
|
|
1041
|
-
// loader (see seedAgentSessionPreamble). Local
|
|
1042
|
-
|
|
1141
|
+
// loader (see seedAgentSessionPreamble). Local sessions of a CLI that takes the
|
|
1142
|
+
// skill; best-effort.
|
|
1143
|
+
if (agentSkillDir(getCli(mode)?.capabilities) && !remote && (await ctx.getAgentSkillEnabled())) {
|
|
1043
1144
|
await seedAgentSessionPreamble(session.id).catch((err) => console.warn(`[agent-skill] preamble seed failed for ${session.id}: ${getErrorMessage(err)}`));
|
|
1044
1145
|
}
|
|
1045
1146
|
getLifecycleLog().log({ event: 'created', sessionId: session.id, name: session.name });
|
|
@@ -1047,7 +1148,9 @@ options = {}) {
|
|
|
1047
1148
|
// Avoids serializing 2-3MB of terminal+text buffers per session creation.
|
|
1048
1149
|
const lightState = ctx.getSessionStateWithRespawn(session);
|
|
1049
1150
|
ctx.broadcast(SseEvent.SessionCreated, lightState);
|
|
1050
|
-
|
|
1151
|
+
// A grouped creation also returns the layout that placed it, so the browser
|
|
1152
|
+
// draws the tab inside its group at once instead of after a re-read.
|
|
1153
|
+
return { session: lightState, ...(body.tabGroupId ? { tabLayout } : {}) };
|
|
1051
1154
|
});
|
|
1052
1155
|
// ========== Rename Session ==========
|
|
1053
1156
|
app.put('/api/sessions/:id/name', async (req) => {
|
|
@@ -1595,7 +1698,9 @@ options = {}) {
|
|
|
1595
1698
|
// A DUPLICATE has no new turn coming, so it answers from the current state
|
|
1596
1699
|
// instead of blocking for a transition that already happened.
|
|
1597
1700
|
requireTransition: !duplicate,
|
|
1598
|
-
|
|
1701
|
+
// The `...ForWait` variant, as on GET .../wait: a screen-read dialog the first
|
|
1702
|
+
// delivery raised is a level, and a retry asking for `blocked` must find it.
|
|
1703
|
+
currentSignal: duplicate ? currentSignalForWait(session, workerDead, until) : undefined,
|
|
1599
1704
|
});
|
|
1600
1705
|
}
|
|
1601
1706
|
catch (err) {
|
|
@@ -1612,7 +1717,7 @@ options = {}) {
|
|
|
1612
1717
|
}
|
|
1613
1718
|
const stopDeathWatch = wantsWait ? watchForDeadWorker(ctx.mux, session, id) : () => { };
|
|
1614
1719
|
// Write input to PTY. Direct write is synchronous; writeViaMux
|
|
1615
|
-
// (tmux
|
|
1720
|
+
// (tmux paste + Enter) is fire-and-forget to avoid blocking the HTTP response.
|
|
1616
1721
|
// Every write here is `fromUser`: this route carries a person's prompt, or an
|
|
1617
1722
|
// agent's on their behalf, so it may name the tab (Ralph, respawn, cron and
|
|
1618
1723
|
// approvals write through the session directly and never say so).
|
|
@@ -1634,7 +1739,7 @@ options = {}) {
|
|
|
1634
1739
|
// A plain prompt (`<text>\r`) goes through the mux even when the caller did not
|
|
1635
1740
|
// ask for it: written straight into the pane it arrives as one burst, and Claude
|
|
1636
1741
|
// Code takes a long burst as a paste whose `\r` becomes a newline, so the prompt
|
|
1637
|
-
// sat unsent (see isPlainPromptInput). The mux path
|
|
1742
|
+
// sat unsent (see isPlainPromptInput). The mux path pastes the text, presses Enter
|
|
1638
1743
|
// separately and arms the SubmitVerifier. An explicit `useMux: false` keeps the
|
|
1639
1744
|
// raw write for a caller that really wants it.
|
|
1640
1745
|
const autoMux = useMux === undefined && isPlainPromptInput(inputStr);
|
|
@@ -1782,7 +1887,7 @@ options = {}) {
|
|
|
1782
1887
|
abortSignal: abort.signal,
|
|
1783
1888
|
requireTransition: query.fresh === '1' || query.fresh === 'true',
|
|
1784
1889
|
// Read BEFORE awaiting: this is the state the caller is asking about.
|
|
1785
|
-
currentSignal:
|
|
1890
|
+
currentSignal: currentSignalForWait(session, workerIsDead(ctx.mux, session), until),
|
|
1786
1891
|
});
|
|
1787
1892
|
return {
|
|
1788
1893
|
success: true,
|
|
@@ -2672,6 +2777,8 @@ options = {}) {
|
|
|
2672
2777
|
// Query params:
|
|
2673
2778
|
// tail=<bytes> - Only return last N bytes (faster initial load)
|
|
2674
2779
|
// full=1 - Explicitly request the entire tmux scrollback (COD-47)
|
|
2780
|
+
// alt=recording - With full=1: if the pane is in the alternate screen, load
|
|
2781
|
+
// the visible frame over the byte recording instead
|
|
2675
2782
|
app.get('/api/sessions/:id/terminal', async (req, reply) => {
|
|
2676
2783
|
const routeStartedAt = performance.now();
|
|
2677
2784
|
const { id } = req.params;
|
|
@@ -2682,7 +2789,7 @@ options = {}) {
|
|
|
2682
2789
|
// Requests WITHOUT it — shell selection/tab switches (`tail=`) and legacy
|
|
2683
2790
|
// no-param callers — keep the fast visible-frame capture.
|
|
2684
2791
|
const tailBytes = query.tail ? parseInt(query.tail, 10) : 0;
|
|
2685
|
-
|
|
2792
|
+
let isFullReload = query.full === '1' || query.full === 'true';
|
|
2686
2793
|
const { tmuxHistoryLimit, terminalBufferMaxBytes } = await ctx.getTerminalHistoryConfig();
|
|
2687
2794
|
// Prepend the live tmux pane buffer so tab-switch replay shows the current
|
|
2688
2795
|
// on-screen frame, not just the accumulated byte history. This matters for
|
|
@@ -2699,16 +2806,48 @@ options = {}) {
|
|
|
2699
2806
|
// for. See PaneCaptureOptions.capturedGeometry.
|
|
2700
2807
|
// `lines` bounds only the history a FULL capture reads; the visible-frame
|
|
2701
2808
|
// path reads no history and is untouched by it.
|
|
2702
|
-
|
|
2809
|
+
let captureOpts = isFullReload
|
|
2703
2810
|
? {
|
|
2704
2811
|
fullHistory: true,
|
|
2705
2812
|
historyLimitLines: captureHistoryLines(query.lines, tmuxHistoryLimit),
|
|
2706
2813
|
maxCaptureBytes: terminalBufferMaxBytes,
|
|
2707
2814
|
}
|
|
2708
2815
|
: {};
|
|
2709
|
-
|
|
2710
|
-
|
|
2816
|
+
// A remote session's local pane only sees the remote tmux client's repaints,
|
|
2817
|
+
// so its own scrollback is a fraction of the real one: read that from the
|
|
2818
|
+
// remote tmux and splice it above the local frame. Null (host unreachable,
|
|
2819
|
+
// no remote session, back-off) keeps the local capture as before.
|
|
2820
|
+
if (isFullReload && session.remote && muxName && captureOpts.historyLimitLines !== undefined) {
|
|
2821
|
+
const remoteScrollback = await fetchRemoteScrollback(session.remote, session.id, captureOpts.historyLimitLines, terminalBufferMaxBytes);
|
|
2822
|
+
if (remoteScrollback !== null) {
|
|
2823
|
+
captureOpts.scrollbackOverride = remoteScrollback.scrollback;
|
|
2824
|
+
captureOpts.scrollbackOverrideScreen = remoteScrollback.screen;
|
|
2825
|
+
}
|
|
2826
|
+
}
|
|
2827
|
+
const capture = (opts) => muxName && typeof ctx.mux.captureActivePaneBuffer === 'function'
|
|
2828
|
+
? ctx.mux.captureActivePaneBuffer(muxName, opts)
|
|
2711
2829
|
: null;
|
|
2830
|
+
let liveMuxBuffer = capture(captureOpts);
|
|
2831
|
+
// tmux adds no history while a pane is in the alternate screen, and a
|
|
2832
|
+
// nested tmux client (a remote SSH or Docker case, or `tmux` run by hand
|
|
2833
|
+
// in a shell) holds its pane there for good, so the full capture above is
|
|
2834
|
+
// one screen plus whatever came before. The byte recording still carries
|
|
2835
|
+
// the scrollback, so a caller that opts in (a shell's automatic load,
|
|
2836
|
+
// `alt=recording`) gets the old visible-frame load over it, tailed, rather
|
|
2837
|
+
// than losing everything above the screen. `alternate_on` cannot tell a
|
|
2838
|
+
// nested tmux from vim or less, so those take this path too while open:
|
|
2839
|
+
// the load the shell had before tmux-rendered loads existed. Not when the
|
|
2840
|
+
// history came from the remote tmux (`scrollbackOverride`): a remote pane is
|
|
2841
|
+
// in the alternate screen only because it hosts that tmux's client, and
|
|
2842
|
+
// the fallback would throw the remote scrollback away.
|
|
2843
|
+
if (isFullReload &&
|
|
2844
|
+
query.alt === 'recording' &&
|
|
2845
|
+
captureOpts.scrollbackOverride === undefined &&
|
|
2846
|
+
captureOpts.capturedAlternateOn === true) {
|
|
2847
|
+
isFullReload = false;
|
|
2848
|
+
captureOpts = {};
|
|
2849
|
+
liveMuxBuffer = capture(captureOpts);
|
|
2850
|
+
}
|
|
2712
2851
|
const captureFinishedAt = performance.now();
|
|
2713
2852
|
const hasLiveMuxBuffer = liveMuxBuffer !== null && liveMuxBuffer.length > 0;
|
|
2714
2853
|
const source = hasLiveMuxBuffer
|
|
@@ -2749,6 +2888,29 @@ options = {}) {
|
|
|
2749
2888
|
// why the UI could only ever say "truncated for performance".
|
|
2750
2889
|
let truncationReason = null;
|
|
2751
2890
|
let cleanBuffer;
|
|
2891
|
+
// A `lines=` bound shallower than tmux's history left the older lines in
|
|
2892
|
+
// tmux: an intentional partial read, so 'tail', and a later pull with a
|
|
2893
|
+
// deeper (or no) bound recovers them. Without this a bounded window looks
|
|
2894
|
+
// like the whole history to the client. The byte cap below still wins.
|
|
2895
|
+
// Only a bound the CLIENT set and below the configured ceiling counts:
|
|
2896
|
+
// without `lines=` the bound is the ceiling itself, and on tmux before 3.7
|
|
2897
|
+
// a live pane can hold more than a lowered ceiling, which no request can
|
|
2898
|
+
// read, so calling that a recoverable cut would offer a button that can
|
|
2899
|
+
// never return more.
|
|
2900
|
+
// `historyLinesCut` tells the client this happened, apart from any byte
|
|
2901
|
+
// cut below: `fullSize` counts only what tmux returned, so the bytes a
|
|
2902
|
+
// later `tail` cut are not all that is left, and the client must not name
|
|
2903
|
+
// them as the remainder.
|
|
2904
|
+
const capturedHistoryLines = captureOpts.capturedHistoryLines;
|
|
2905
|
+
const historyLinesCut = isFullCapture &&
|
|
2906
|
+
captureOpts.historyLimitLines !== undefined &&
|
|
2907
|
+
captureOpts.historyLimitLines < tmuxHistoryLimit &&
|
|
2908
|
+
capturedHistoryLines !== undefined &&
|
|
2909
|
+
capturedHistoryLines > captureOpts.historyLimitLines;
|
|
2910
|
+
if (historyLinesCut) {
|
|
2911
|
+
truncated = true;
|
|
2912
|
+
truncationReason = 'tail';
|
|
2913
|
+
}
|
|
2752
2914
|
// Cap the payload EARLY — before the regex normalization passes below run
|
|
2753
2915
|
// over it. A full-history tmux capture can be tens of MB of scrollback;
|
|
2754
2916
|
// normalizing all of it would stall the event loop only to discard most
|
|
@@ -2828,6 +2990,9 @@ options = {}) {
|
|
|
2828
2990
|
fullSize,
|
|
2829
2991
|
truncated,
|
|
2830
2992
|
truncationReason,
|
|
2993
|
+
// The `lines=` bound left older lines in tmux (see above): the remainder
|
|
2994
|
+
// is more than `fullSize - retainedBytes`, by an amount unknown here.
|
|
2995
|
+
historyLinesCut,
|
|
2831
2996
|
// `retainedBytes` is what this response actually carries; `fullSize` is
|
|
2832
2997
|
// what existed before the cut. The gap is what the indicator reports.
|
|
2833
2998
|
retainedBytes: cleanBuffer.length,
|
|
@@ -2851,6 +3016,15 @@ options = {}) {
|
|
|
2851
3016
|
// damage that does not exist.
|
|
2852
3017
|
captureCols: hasLiveMuxBuffer ? captureOpts.capturedGeometry?.cols : undefined,
|
|
2853
3018
|
captureRows: hasLiveMuxBuffer ? captureOpts.capturedGeometry?.rows : undefined,
|
|
3019
|
+
// Rows tmux holds above the visible frame, which is the most a `full=1`
|
|
3020
|
+
// pull can add. `truncated` measures the BYTE stream, and for a pane that
|
|
3021
|
+
// keeps no scrollback (a fullscreen CLI in the alternate screen) the bytes
|
|
3022
|
+
// a tail cut drops are old repaints that no request can bring back, so a
|
|
3023
|
+
// client must not offer to load them. Absent when the pane was not read,
|
|
3024
|
+
// and for a remote session: its local pane hosts the remote tmux client in
|
|
3025
|
+
// the alternate screen, so it always reports 0, while a `full=1` load reads
|
|
3026
|
+
// the real scrollback from the remote tmux (`fetchRemoteScrollback`).
|
|
3027
|
+
paneHistoryLines: hasLiveMuxBuffer && !session.remote ? captureOpts.capturedHistoryLines : undefined,
|
|
2854
3028
|
};
|
|
2855
3029
|
});
|
|
2856
3030
|
// ═══════════════════════════════════════════════════════════════
|
|
@@ -3053,7 +3227,7 @@ options = {}) {
|
|
|
3053
3227
|
const capMsg = sessionCapacityMessage(ctx.sessions, owner);
|
|
3054
3228
|
if (capMsg)
|
|
3055
3229
|
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);
|
|
3230
|
+
const { caseName = 'testcase', sessionName, mode = 'claude', modelOverride, openCodeConfig, codexConfig, geminiConfig, antigravityConfig, piConfig, grokConfig, deepSeekConfig, ompConfig, envOverrides, effort, advisorModel, parentSessionId, agentOrigin, customModel, tabGroupId, worktree, } = parseBody(QuickStartSchema, req.body);
|
|
3057
3231
|
// Resolved ONCE here: the same value labels a case directory this request creates
|
|
3058
3232
|
// (agent-case-marker.ts) and draws the tab lineage line on the session below.
|
|
3059
3233
|
const qsParentSessionId = resolveParentSessionId(ctx, req, parentSessionId, owner);
|
|
@@ -3079,6 +3253,13 @@ options = {}) {
|
|
|
3079
3253
|
const dockerCase = remoteCase
|
|
3080
3254
|
? undefined
|
|
3081
3255
|
: (await readDockerCases(CODEMAN_CONFIG_DIR)).find((item) => item.name === caseName && canAccessOwned(getAuthUser(req), item.owner));
|
|
3256
|
+
// Worktrees are local only (src/git-worktrees.ts): refused BEFORE a remote host is woken or a
|
|
3257
|
+
// container is touched, since neither branch below could honour it.
|
|
3258
|
+
if (worktree && (remoteCase || dockerCase)) {
|
|
3259
|
+
return createErrorResponse(ApiErrorCode.INVALID_INPUT, remoteCase
|
|
3260
|
+
? 'worktree is not available for remote (SSH) cases: the repository is on another machine'
|
|
3261
|
+
: 'worktree is not available for Docker cases: git would run on this machine over a workspace the container can write');
|
|
3262
|
+
}
|
|
3082
3263
|
if (remoteCase) {
|
|
3083
3264
|
const host = (await readRemoteHosts(CODEMAN_CONFIG_DIR)).find((item) => item.id === remoteCase.hostId);
|
|
3084
3265
|
if (!host)
|
|
@@ -3283,6 +3464,28 @@ options = {}) {
|
|
|
3283
3464
|
if (localCaseState === 'unknown') {
|
|
3284
3465
|
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, describeUnknownPath('Case folder', resolvedCasePath, { pastCap: true }));
|
|
3285
3466
|
}
|
|
3467
|
+
// Where the session runs: the case folder, or one of its git worktrees. A worktree is only
|
|
3468
|
+
// ever USED here (created through POST /api/cases/:name/worktrees), so a case that does not
|
|
3469
|
+
// exist yet is a 404 rather than a scaffold, and the worktree must be a registered, managed
|
|
3470
|
+
// one, every path component a real directory (src/git-worktrees.ts).
|
|
3471
|
+
let workspacePath = resolvedCasePath;
|
|
3472
|
+
if (worktree) {
|
|
3473
|
+
if (localCaseState === 'absent') {
|
|
3474
|
+
return createErrorResponse(ApiErrorCode.NOT_FOUND, `Case "${caseName}" not found`);
|
|
3475
|
+
}
|
|
3476
|
+
if (localCaseState !== 'directory') {
|
|
3477
|
+
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Case path is not a folder');
|
|
3478
|
+
}
|
|
3479
|
+
const caseRepo = await resolveCaseRepository(resolvedCasePath, {
|
|
3480
|
+
dockerWorkspaces: (await readDockerCases(CODEMAN_CONFIG_DIR)).map((c) => c.hostWorkspacePath).filter(Boolean),
|
|
3481
|
+
});
|
|
3482
|
+
if (!caseRepo.ok)
|
|
3483
|
+
return createErrorResponse(ApiErrorCode[caseRepo.code], caseRepo.message);
|
|
3484
|
+
const managed = await resolveManagedWorktree(caseRepo.repo, worktree);
|
|
3485
|
+
if (!managed.ok)
|
|
3486
|
+
return createErrorResponse(ApiErrorCode[managed.code], managed.message);
|
|
3487
|
+
workspacePath = managed.path;
|
|
3488
|
+
}
|
|
3286
3489
|
// Create case folder and CLAUDE.md if it doesn't exist (only for non-linked, non-remote,
|
|
3287
3490
|
// non-docker cases — docker workspaces are scaffolded in their own block below)
|
|
3288
3491
|
if (localCaseState === 'absent') {
|
|
@@ -3334,19 +3537,21 @@ options = {}) {
|
|
|
3334
3537
|
// of its own. Skipped for remote cases — resolvedCasePath is a REMOTE path that
|
|
3335
3538
|
// doesn't exist on the local filesystem.
|
|
3336
3539
|
if (getCli(mode)?.capabilities.hooks === 'always') {
|
|
3337
|
-
await applyWorkspaceHooks(
|
|
3540
|
+
await applyWorkspaceHooks(workspacePath, await ctx.getWorkspaceHooksEnabled());
|
|
3338
3541
|
}
|
|
3339
3542
|
else {
|
|
3340
|
-
await refreshStaleCodemanHooks(
|
|
3543
|
+
await refreshStaleCodemanHooks(workspacePath).catch(() => { });
|
|
3341
3544
|
}
|
|
3342
3545
|
}
|
|
3343
3546
|
// 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
|
-
|
|
3547
|
+
// marker-guarded (a user's own copy is never touched). Each CLI that takes the
|
|
3548
|
+
// skill gets it in the directory it scans (`capabilities.agentSkillDir`:
|
|
3549
|
+
// `.claude/skills` for claude, `.agents/skills` for most others); skipped for
|
|
3550
|
+
// remote cases, whose casePath lives on another host. Docker cases qualify:
|
|
3551
|
+
// hostWorkspacePath is a real host dir and the skill crosses the bind mount.
|
|
3552
|
+
const quickStartSkillDir = agentSkillDir(getCli(mode)?.capabilities);
|
|
3553
|
+
if (!remote && quickStartSkillDir && (await ctx.getAgentSkillEnabled())) {
|
|
3554
|
+
await injectAgentSkill(workspacePath, quickStartSkillDir);
|
|
3350
3555
|
}
|
|
3351
3556
|
// Docker cases: the workspace is a REAL host dir bind-mounted into the container.
|
|
3352
3557
|
// Scaffold hooks (+ a CLAUDE.md) if MISSING so in-container permission prompts and
|
|
@@ -3379,13 +3584,13 @@ options = {}) {
|
|
|
3379
3584
|
// docker — the docker workspace is a real host dir, so the settings file crosses
|
|
3380
3585
|
// the bind mount and the in-container claude reads it). Remote was rejected above.
|
|
3381
3586
|
if (getCli(mode)?.capabilities.model.source === 'claude-settings-file' && modelOverride !== undefined) {
|
|
3382
|
-
await updateCaseModel(
|
|
3587
|
+
await updateCaseModel(workspacePath, modelOverride || null);
|
|
3383
3588
|
}
|
|
3384
3589
|
// Strip stale disk entries for keys this request is actively setting (Claude only —
|
|
3385
3590
|
// see POST /api/sessions for full rationale).
|
|
3386
3591
|
// Same chain, same replacement as the create path above: byte-identical, drift-proof.
|
|
3387
3592
|
if (!isExternalCliMode(mode) && !remote && envOverrides && Object.keys(envOverrides).length > 0) {
|
|
3388
|
-
await stripCaseEnvKeys(
|
|
3593
|
+
await stripCaseEnvKeys(workspacePath, Object.keys(envOverrides));
|
|
3389
3594
|
}
|
|
3390
3595
|
// Create a new session with the case as working directory
|
|
3391
3596
|
// Apply global Nice priority config and model config from settings
|
|
@@ -3421,7 +3626,7 @@ options = {}) {
|
|
|
3421
3626
|
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);
|
|
3422
3627
|
const qsTerminalHistoryConfig = await ctx.getTerminalHistoryConfig();
|
|
3423
3628
|
const qsGatedEnvOverrides = await clampEnvOverridesForOwner(owner, envOverrides);
|
|
3424
|
-
const qsResolvedOmpConfig = resolveOmpConfigForCreate(mode,
|
|
3629
|
+
const qsResolvedOmpConfig = resolveOmpConfigForCreate(mode, workspacePath, qsLaunchOmpConfig);
|
|
3425
3630
|
// Custom Model Endpoint Profiles, applied AT CREATE TIME (docs/custom-model-endpoints-plan.md)
|
|
3426
3631
|
// rather than via the dedicated restart-in-place route (POST /api/sessions/:id/custom-
|
|
3427
3632
|
// model, still what an ALREADY-RUNNING session uses to switch later): computing the
|
|
@@ -3505,7 +3710,7 @@ options = {}) {
|
|
|
3505
3710
|
// with, not a placeholder: `new Session({ id: ... })` accepts an explicit id for
|
|
3506
3711
|
// exactly this reason.
|
|
3507
3712
|
qsCustomModelSessionId = randomUUID();
|
|
3508
|
-
const cmApplied = applyCustomModelInjection(cmEntry, cmEndpoint, customModel.modelId, qsCustomModelSessionId, cmContextLength,
|
|
3713
|
+
const cmApplied = applyCustomModelInjection(cmEntry, cmEndpoint, customModel.modelId, qsCustomModelSessionId, cmContextLength, workspacePath);
|
|
3509
3714
|
if (!cmApplied) {
|
|
3510
3715
|
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, `${mode} has no known custom-model mechanism`);
|
|
3511
3716
|
}
|
|
@@ -3537,7 +3742,7 @@ options = {}) {
|
|
|
3537
3742
|
}
|
|
3538
3743
|
const qsSessionOptions = {
|
|
3539
3744
|
id: qsCustomModelSessionId,
|
|
3540
|
-
workingDir:
|
|
3745
|
+
workingDir: workspacePath,
|
|
3541
3746
|
name: sessionName ? sessionName.slice(0, MAX_SESSION_NAME_LENGTH) : '',
|
|
3542
3747
|
mux: ctx.mux,
|
|
3543
3748
|
useMux: true,
|
|
@@ -3594,19 +3799,20 @@ options = {}) {
|
|
|
3594
3799
|
// Auto-detect completion phrase from CLAUDE.md BEFORE broadcasting
|
|
3595
3800
|
// so the initial state already has the phrase configured (only if globally enabled)
|
|
3596
3801
|
if (getCli(mode)?.capabilities.ralph && !remote && !docker && ctx.store.getConfig().ralphEnabled) {
|
|
3597
|
-
autoConfigureRalph(session,
|
|
3802
|
+
autoConfigureRalph(session, workspacePath, ctx);
|
|
3598
3803
|
if (!session.ralphTracker.enabled) {
|
|
3599
3804
|
session.ralphTracker.enable();
|
|
3600
3805
|
session.ralphTracker.enableAutoEnable(); // Allow re-enabling on restart
|
|
3601
3806
|
}
|
|
3602
3807
|
}
|
|
3603
|
-
await ctx.addSession(session);
|
|
3808
|
+
const tabLayout = tabGroupId ? await ctx.addSession(session, { tabGroupId }) : await ctx.addSession(session);
|
|
3604
3809
|
ctx.store.incrementSessionsCreated();
|
|
3605
3810
|
ctx.persistSessionState(session);
|
|
3606
3811
|
await ctx.setupSessionListeners(session);
|
|
3607
3812
|
// Pre-seed the agent skill's preamble cache so its §0 bootstrap is a two-line
|
|
3608
|
-
// loader (see seedAgentSessionPreamble). Local
|
|
3609
|
-
|
|
3813
|
+
// loader (see seedAgentSessionPreamble). Local sessions of a CLI that takes the
|
|
3814
|
+
// skill; best-effort.
|
|
3815
|
+
if (agentSkillDir(getCli(mode)?.capabilities) && !remote && !docker && (await ctx.getAgentSkillEnabled())) {
|
|
3610
3816
|
await seedAgentSessionPreamble(session.id).catch((err) => console.warn(`[agent-skill] preamble seed failed for ${session.id}: ${getErrorMessage(err)}`));
|
|
3611
3817
|
}
|
|
3612
3818
|
getLifecycleLog().log({
|
|
@@ -3648,37 +3854,44 @@ options = {}) {
|
|
|
3648
3854
|
if (docker && mode === 'claude') {
|
|
3649
3855
|
void persistDockerCaseClaudeSessionId(CODEMAN_CONFIG_DIR, docker.containerName, session.claudeSessionId || session.id).catch(() => { });
|
|
3650
3856
|
}
|
|
3651
|
-
// Save lastUsedCase to settings for TUI/web sync
|
|
3652
|
-
|
|
3653
|
-
|
|
3654
|
-
|
|
3857
|
+
// Save lastUsedCase to settings for TUI/web sync. It is a key of the server-wide
|
|
3858
|
+
// settings.json, readable by every user through GET /api/settings, so a non-admin's
|
|
3859
|
+
// case name is not written there in multi-user mode (writes are admin-only, like
|
|
3860
|
+
// PUT /api/settings). isAdmin is always true in single-user mode.
|
|
3861
|
+
if (isAdmin(req)) {
|
|
3655
3862
|
try {
|
|
3656
|
-
|
|
3863
|
+
const settingsFilePath = SETTINGS_PATH;
|
|
3864
|
+
let settings = {};
|
|
3865
|
+
try {
|
|
3866
|
+
settings = JSON.parse(await fs.readFile(settingsFilePath, 'utf-8'));
|
|
3867
|
+
}
|
|
3868
|
+
catch (err) {
|
|
3869
|
+
if (err.code !== 'ENOENT')
|
|
3870
|
+
throw err;
|
|
3871
|
+
}
|
|
3872
|
+
settings.lastUsedCase = caseName;
|
|
3873
|
+
const dir = dirname(settingsFilePath);
|
|
3874
|
+
if (!existsSync(dir)) {
|
|
3875
|
+
mkdirSync(dir, { recursive: true });
|
|
3876
|
+
}
|
|
3877
|
+
// Use async write to avoid blocking event loop
|
|
3878
|
+
fs.writeFile(settingsFilePath, JSON.stringify(settings, null, 2)).catch((err) => {
|
|
3879
|
+
// Non-critical but log for debugging
|
|
3880
|
+
console.warn('[Server] Failed to save settings (lastUsedCase):', err);
|
|
3881
|
+
});
|
|
3657
3882
|
}
|
|
3658
3883
|
catch (err) {
|
|
3659
|
-
if (err.code !== 'ENOENT')
|
|
3660
|
-
throw err;
|
|
3661
|
-
}
|
|
3662
|
-
settings.lastUsedCase = caseName;
|
|
3663
|
-
const dir = dirname(settingsFilePath);
|
|
3664
|
-
if (!existsSync(dir)) {
|
|
3665
|
-
mkdirSync(dir, { recursive: true });
|
|
3666
|
-
}
|
|
3667
|
-
// Use async write to avoid blocking event loop
|
|
3668
|
-
fs.writeFile(settingsFilePath, JSON.stringify(settings, null, 2)).catch((err) => {
|
|
3669
3884
|
// Non-critical but log for debugging
|
|
3670
|
-
console.warn('[Server] Failed to
|
|
3671
|
-
}
|
|
3672
|
-
}
|
|
3673
|
-
catch (err) {
|
|
3674
|
-
// Non-critical but log for debugging
|
|
3675
|
-
console.warn('[Server] Failed to prepare settings update:', err);
|
|
3885
|
+
console.warn('[Server] Failed to prepare settings update:', err);
|
|
3886
|
+
}
|
|
3676
3887
|
}
|
|
3677
3888
|
return {
|
|
3678
3889
|
sessionId: session.id,
|
|
3679
3890
|
casePath: resolvedCasePath,
|
|
3680
3891
|
caseName,
|
|
3892
|
+
...(worktree ? { worktree, workingDir: workspacePath } : {}),
|
|
3681
3893
|
...(customModel ? { modelSwapInProgress: qsCustomModelSwapInProgress } : {}),
|
|
3894
|
+
...(tabGroupId ? { tabLayout } : {}),
|
|
3682
3895
|
};
|
|
3683
3896
|
}
|
|
3684
3897
|
catch (err) {
|
|
@@ -4565,7 +4778,38 @@ options = {}) {
|
|
|
4565
4778
|
// Paste Image (clipboard / drag-drop upload)
|
|
4566
4779
|
// ═══════════════════════════════════════════════════════════════
|
|
4567
4780
|
const ALLOWED_IMAGE_EXTS = new Set(['.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp', '.heic', '.heif']);
|
|
4781
|
+
// Single-sourced from the attachment registry (what the file viewer can also play),
|
|
4782
|
+
// so an uploaded video is a type that previews. 3gp/mkv are deliberately out: they
|
|
4783
|
+
// would upload but not preview, and a phone's camera roll produces neither.
|
|
4784
|
+
const ALLOWED_VIDEO_EXTS = new Set([...VIDEO_ATTACHMENT_EXTENSIONS].map((e) => `.${e}`));
|
|
4785
|
+
// Documents a user hands an agent to read: the office formats plus the text
|
|
4786
|
+
// family. Curated on purpose, never SUPPORTED_ATTACHMENT_EXTENSIONS or
|
|
4787
|
+
// EDITABLE_EXTENSIONS, which carry sh/py/js/html; a prompt upload is a thing
|
|
4788
|
+
// to read, not a thing to run or serve. Each is judged on its bytes by
|
|
4789
|
+
// imageMagicMatchesExt (signature, or UTF-8 without NUL for text).
|
|
4790
|
+
const ALLOWED_DOCUMENT_EXTS = new Set(['.pdf', '.docx', '.pptx', '.xlsx', '.zip', ...TEXT_UPLOAD_EXTS]);
|
|
4791
|
+
const VIDEO_MIME_EXT = {
|
|
4792
|
+
mp4: '.mp4',
|
|
4793
|
+
quicktime: '.mov',
|
|
4794
|
+
'x-m4v': '.m4v',
|
|
4795
|
+
webm: '.webm',
|
|
4796
|
+
ogg: '.ogv',
|
|
4797
|
+
};
|
|
4568
4798
|
// The per-file size cap (MAX_PASTE_IMAGE_BYTES) is enforced by @fastify/multipart (registered in server.ts).
|
|
4799
|
+
/** Write buffered upload bytes into the session's upload dir. Null: the dir is not a real directory. */
|
|
4800
|
+
async function writeBufferedUpload(workingDir, ext, bytes, stem = '') {
|
|
4801
|
+
const uploadDir = await ensureUploadDir(workingDir);
|
|
4802
|
+
if (!uploadDir)
|
|
4803
|
+
return null;
|
|
4804
|
+
const { filename, filepath, fh } = await openPasteFile(uploadDir, ext, stem);
|
|
4805
|
+
try {
|
|
4806
|
+
await fh.writeFile(bytes);
|
|
4807
|
+
}
|
|
4808
|
+
finally {
|
|
4809
|
+
await fh.close();
|
|
4810
|
+
}
|
|
4811
|
+
return { path: filepath, filename };
|
|
4812
|
+
}
|
|
4569
4813
|
app.post('/api/sessions/:id/paste-image', async (req, reply) => {
|
|
4570
4814
|
// CSRF defense: state-changing routes must come from same origin.
|
|
4571
4815
|
// Cookies are SameSite=lax, multipart/form-data is a "simple" CORS request
|
|
@@ -4602,12 +4846,19 @@ options = {}) {
|
|
|
4602
4846
|
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'CSRF check failed');
|
|
4603
4847
|
}
|
|
4604
4848
|
const { id } = req.params;
|
|
4605
|
-
//
|
|
4606
|
-
//
|
|
4607
|
-
|
|
4849
|
+
// `?kind=video` selects the disk-streamed path (see below). It is read here so the
|
|
4850
|
+
// rate check draws on the right bucket before any work: videos get 3/min, images 30.
|
|
4851
|
+
const isVideo = req.query.kind === 'video';
|
|
4852
|
+
// Rate limit per (IP, sessionId). Defends against disk-fill DoS — even an
|
|
4853
|
+
// authenticated client can otherwise loop large uploads. Videos get their own,
|
|
4854
|
+
// far lower bucket because the per-file cap is 1GB (see the bucket constants).
|
|
4855
|
+
const rateKey = `${req.ip}:${id}`;
|
|
4856
|
+
if (!(isVideo ? consumeVideoToken(rateKey) : consumePasteToken(rateKey))) {
|
|
4608
4857
|
reply.code(429);
|
|
4609
4858
|
reply.header('Retry-After', '60');
|
|
4610
|
-
return createErrorResponse(ApiErrorCode.INVALID_INPUT,
|
|
4859
|
+
return createErrorResponse(ApiErrorCode.INVALID_INPUT, isVideo
|
|
4860
|
+
? 'Rate limit exceeded (3 video uploads/min per session)'
|
|
4861
|
+
: 'Rate limit exceeded (30 uploads/min per session)');
|
|
4611
4862
|
}
|
|
4612
4863
|
const session = findSessionOrFail(ctx, id, req);
|
|
4613
4864
|
// The file lands on THIS host under the session's working directory, which
|
|
@@ -4620,6 +4871,16 @@ options = {}) {
|
|
|
4620
4871
|
reply.code(400);
|
|
4621
4872
|
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Prompt uploads are not supported for remote (SSH) sessions');
|
|
4622
4873
|
}
|
|
4874
|
+
// The upload is written under session.workingDir (ensureUploadDir below), and
|
|
4875
|
+
// a live session's workspace can sit on a network mount that stopped answering.
|
|
4876
|
+
// Probe it bounded before the first disk touch, as #516 requires for a path the
|
|
4877
|
+
// user named, so one dead mount cannot freeze a libuv threadpool worker per
|
|
4878
|
+
// upload. Only "unknown" (the mount is not answering) is refused here, the way
|
|
4879
|
+
// the create route does; absent / not-a-directory keep ensureUploadDir's own
|
|
4880
|
+
// errors, since a live session's workspace should be neither.
|
|
4881
|
+
if ((await probePathKind(session.workingDir, { pastCap: true })) === 'unknown') {
|
|
4882
|
+
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, describeUnknownPath('Workspace', session.workingDir, { pastCap: true }));
|
|
4883
|
+
}
|
|
4623
4884
|
if (!req.isMultipart()) {
|
|
4624
4885
|
reply.code(400);
|
|
4625
4886
|
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Expected multipart/form-data');
|
|
@@ -4630,9 +4891,15 @@ options = {}) {
|
|
|
4630
4891
|
// boundary scanner with several bugs: literal boundary matches anywhere in
|
|
4631
4892
|
// body, LF-only clients silently corrupted the last byte (hard-coded \r\n
|
|
4632
4893
|
// offsets), no part-count cap.
|
|
4894
|
+
// `?kind=video` (read above for the rate check) is the ONLY way onto the video
|
|
4895
|
+
// path: it selects the larger, disk-streamed budget before the part is seen
|
|
4896
|
+
// (busboy takes its limits up front), and it admits video types alone, so an
|
|
4897
|
+
// image can never ride the video cap through memory. Without it the route is
|
|
4898
|
+
// byte-identical to the image-only version. Truncation is checked on the stream
|
|
4899
|
+
// below rather than thrown, since the file is being written as it arrives.
|
|
4633
4900
|
let part;
|
|
4634
4901
|
try {
|
|
4635
|
-
part = await req.file();
|
|
4902
|
+
part = await req.file(isVideo ? { limits: { fileSize: MAX_PASTE_VIDEO_BYTES }, throwFileSizeLimit: false } : undefined);
|
|
4636
4903
|
}
|
|
4637
4904
|
catch (err) {
|
|
4638
4905
|
reply.code(413);
|
|
@@ -4646,6 +4913,76 @@ options = {}) {
|
|
|
4646
4913
|
reply.code(400);
|
|
4647
4914
|
return createErrorResponse(ApiErrorCode.INVALID_INPUT, `Unexpected field "${part.fieldname}", expected "image"`);
|
|
4648
4915
|
}
|
|
4916
|
+
if (isVideo) {
|
|
4917
|
+
let ext = '';
|
|
4918
|
+
if (part.filename) {
|
|
4919
|
+
const origExt = extname(part.filename).toLowerCase();
|
|
4920
|
+
if (ALLOWED_VIDEO_EXTS.has(origExt))
|
|
4921
|
+
ext = origExt;
|
|
4922
|
+
}
|
|
4923
|
+
const videoMime = (part.mimetype || '').toLowerCase().match(/^video\/(mp4|quicktime|x-m4v|webm|ogg)$/);
|
|
4924
|
+
if (videoMime)
|
|
4925
|
+
ext = VIDEO_MIME_EXT[videoMime[1]] ?? ext;
|
|
4926
|
+
if (!ext) {
|
|
4927
|
+
await finished(part.file.resume()).catch(() => { });
|
|
4928
|
+
reply.code(400);
|
|
4929
|
+
return createErrorResponse(ApiErrorCode.INVALID_INPUT, `Unsupported video type. Allowed: ${[...ALLOWED_VIDEO_EXTS].join(', ')}`);
|
|
4930
|
+
}
|
|
4931
|
+
const uploadDir = await ensureUploadDir(session.workingDir);
|
|
4932
|
+
if (!uploadDir) {
|
|
4933
|
+
await finished(part.file.resume()).catch(() => { });
|
|
4934
|
+
reply.code(403);
|
|
4935
|
+
return createErrorResponse(ApiErrorCode.INVALID_INPUT, `${UPLOADS_DIR} is not a regular directory`);
|
|
4936
|
+
}
|
|
4937
|
+
const { filename, filepath, fh } = await openPasteFile(uploadDir, ext);
|
|
4938
|
+
// Written as it arrives: a phone video is far too large to buffer the way an
|
|
4939
|
+
// image is. The container is judged on the first 12 bytes as soon as they are
|
|
4940
|
+
// in, so a mislabeled upload stops touching disk at its first chunk; the rest
|
|
4941
|
+
// of the body is drained unwritten (as busboy does past its own size limit),
|
|
4942
|
+
// which is what lets the answer reach the client. A transfer that dies midway
|
|
4943
|
+
// (the phone left the network) leaves no partial file behind.
|
|
4944
|
+
const head = Buffer.alloc(12);
|
|
4945
|
+
let headLen = 0;
|
|
4946
|
+
let mislabeled = false;
|
|
4947
|
+
try {
|
|
4948
|
+
for await (const chunk of part.file) {
|
|
4949
|
+
if (mislabeled)
|
|
4950
|
+
continue;
|
|
4951
|
+
if (headLen < head.length) {
|
|
4952
|
+
headLen += chunk.copy(head, headLen, 0, head.length - headLen);
|
|
4953
|
+
mislabeled = headLen === head.length && !imageMagicMatchesExt(head, ext);
|
|
4954
|
+
if (mislabeled)
|
|
4955
|
+
continue;
|
|
4956
|
+
}
|
|
4957
|
+
// writeFile loops until the whole chunk is on disk; a bare write() may be short.
|
|
4958
|
+
await fh.writeFile(chunk);
|
|
4959
|
+
}
|
|
4960
|
+
}
|
|
4961
|
+
catch (err) {
|
|
4962
|
+
await fs.unlink(filepath).catch(() => { });
|
|
4963
|
+
throw err;
|
|
4964
|
+
}
|
|
4965
|
+
finally {
|
|
4966
|
+
await fh.close();
|
|
4967
|
+
}
|
|
4968
|
+
let failure = null;
|
|
4969
|
+
if (headLen === 0)
|
|
4970
|
+
failure = { status: 400, message: 'Empty file' };
|
|
4971
|
+
else if (!imageMagicMatchesExt(head.subarray(0, headLen), ext)) {
|
|
4972
|
+
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')}`);
|
|
4973
|
+
failure = { status: 415, message: `Video bytes do not match declared type ${ext}` };
|
|
4974
|
+
}
|
|
4975
|
+
else if (part.file.truncated) {
|
|
4976
|
+
const maxMb = Math.round(MAX_PASTE_VIDEO_BYTES / (1024 * 1024));
|
|
4977
|
+
failure = { status: 413, message: `File too large (max ${maxMb}MB)` };
|
|
4978
|
+
}
|
|
4979
|
+
if (failure) {
|
|
4980
|
+
await fs.unlink(filepath).catch(() => { });
|
|
4981
|
+
reply.code(failure.status);
|
|
4982
|
+
return createErrorResponse(ApiErrorCode.INVALID_INPUT, failure.message);
|
|
4983
|
+
}
|
|
4984
|
+
return { path: filepath, filename };
|
|
4985
|
+
}
|
|
4649
4986
|
let imageBytes;
|
|
4650
4987
|
try {
|
|
4651
4988
|
imageBytes = await part.toBuffer();
|
|
@@ -4659,13 +4996,34 @@ options = {}) {
|
|
|
4659
4996
|
reply.code(400);
|
|
4660
4997
|
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Empty file');
|
|
4661
4998
|
}
|
|
4999
|
+
// Dispatch mirrors the client's _promptAttachKind: an image MIME type takes
|
|
5000
|
+
// the image path whatever the name says, a document extension (the MIME a
|
|
5001
|
+
// phone gives a .md is anyone's guess) takes the document branch, anything
|
|
5002
|
+
// else the image path exactly as before, HEIC sniff included. See CLAUDE.md.
|
|
5003
|
+
const origExt = part.filename ? extname(part.filename).toLowerCase() : '';
|
|
5004
|
+
const imageMime = /^image\//i.test(part.mimetype || '');
|
|
5005
|
+
if (!imageMime && ALLOWED_DOCUMENT_EXTS.has(origExt)) {
|
|
5006
|
+
if (!imageMagicMatchesExt(imageBytes, origExt)) {
|
|
5007
|
+
// The first bytes of a text file are user content, so for text the log
|
|
5008
|
+
// names the failure and never the bytes; a signature is safe to print.
|
|
5009
|
+
const detail = TEXT_UPLOAD_EXTS.has(origExt)
|
|
5010
|
+
? `reason=${imageBytes.includes(0) ? 'nul-byte' : 'not-utf8'}`
|
|
5011
|
+
: `magic=${imageBytes.subarray(0, 12).toString('hex')}`;
|
|
5012
|
+
console.warn(`[paste-image] document mismatch: filename=${JSON.stringify(part.filename)} mime=${JSON.stringify(part.mimetype)} declaredExt=${origExt} size=${imageBytes.length} ${detail}`);
|
|
5013
|
+
reply.code(415);
|
|
5014
|
+
return createErrorResponse(ApiErrorCode.INVALID_INPUT, `File bytes do not match declared type ${origExt}`);
|
|
5015
|
+
}
|
|
5016
|
+
const saved = await writeBufferedUpload(session.workingDir, origExt, imageBytes, sanitizeUploadStem(part.filename));
|
|
5017
|
+
if (!saved) {
|
|
5018
|
+
reply.code(403);
|
|
5019
|
+
return createErrorResponse(ApiErrorCode.INVALID_INPUT, `${UPLOADS_DIR} is not a regular directory`);
|
|
5020
|
+
}
|
|
5021
|
+
return saved;
|
|
5022
|
+
}
|
|
4662
5023
|
// Determine extension from filename or Content-Type.
|
|
4663
5024
|
let ext = '.png';
|
|
4664
|
-
if (
|
|
4665
|
-
|
|
4666
|
-
if (ALLOWED_IMAGE_EXTS.has(origExt))
|
|
4667
|
-
ext = origExt;
|
|
4668
|
-
}
|
|
5025
|
+
if (ALLOWED_IMAGE_EXTS.has(origExt))
|
|
5026
|
+
ext = origExt;
|
|
4669
5027
|
const mimeMatch = (part.mimetype || '').toLowerCase().match(/^image\/(png|jpeg|jpg|webp|gif|bmp|heic|heif)$/);
|
|
4670
5028
|
if (mimeMatch) {
|
|
4671
5029
|
const map = {
|
|
@@ -4701,6 +5059,15 @@ options = {}) {
|
|
|
4701
5059
|
}
|
|
4702
5060
|
}
|
|
4703
5061
|
else if (!imageMagicMatchesExt(imageBytes, ext)) {
|
|
5062
|
+
// A foreign extension with no image MIME (a script, an HTML page, a video
|
|
5063
|
+
// without its flag) was never going to be an image: say so, rather than
|
|
5064
|
+
// the ".png" the default extension would name. Decided only HERE, after
|
|
5065
|
+
// the sniff, so anything the image path ever accepted by its bytes (a
|
|
5066
|
+
// HEIC under a generic name) still is.
|
|
5067
|
+
if (origExt && !ALLOWED_IMAGE_EXTS.has(origExt) && !imageMime) {
|
|
5068
|
+
reply.code(400);
|
|
5069
|
+
return createErrorResponse(ApiErrorCode.INVALID_INPUT, `Unsupported file type: ${origExt}`);
|
|
5070
|
+
}
|
|
4704
5071
|
// Sniff actual bytes — a polyglot HTML/PNG would otherwise pass and
|
|
4705
5072
|
// serve back with image/png MIME. Log the real header so format
|
|
4706
5073
|
// mismatches can be pinned down without a reproduce-and-guess loop. The
|
|
@@ -4709,26 +5076,12 @@ options = {}) {
|
|
|
4709
5076
|
reply.code(415);
|
|
4710
5077
|
return createErrorResponse(ApiErrorCode.INVALID_INPUT, `Image bytes do not match declared type ${ext}`);
|
|
4711
5078
|
}
|
|
4712
|
-
|
|
4713
|
-
|
|
4714
|
-
if (!imageDir) {
|
|
5079
|
+
const saved = await writeBufferedUpload(session.workingDir, ext, imageBytes);
|
|
5080
|
+
if (!saved) {
|
|
4715
5081
|
reply.code(403);
|
|
4716
5082
|
return createErrorResponse(ApiErrorCode.INVALID_INPUT, `${UPLOADS_DIR} is not a regular directory`);
|
|
4717
5083
|
}
|
|
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);
|
|
4725
|
-
try {
|
|
4726
|
-
await fh.writeFile(imageBytes);
|
|
4727
|
-
}
|
|
4728
|
-
finally {
|
|
4729
|
-
await fh.close();
|
|
4730
|
-
}
|
|
4731
|
-
return { path: filepath, filename };
|
|
5084
|
+
return saved;
|
|
4732
5085
|
});
|
|
4733
5086
|
// Returned so the server can own the registry's LIFETIME (drop state when a session is
|
|
4734
5087
|
// cleaned up on any of its paths, resolve in-flight wakes on shutdown). The wake-CAPABLE
|