@thaut/claude-ide 0.0.0-stage → 1.0.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/bin/claude-ide.js +23 -0
- package/package.json +75 -4
- package/server/add-dirs.ts +126 -0
- package/server/auth.test.ts +148 -0
- package/server/auth.ts +235 -0
- package/server/bang.ts +54 -0
- package/server/btw-instructions.ts +19 -0
- package/server/btw-policy.ts +17 -0
- package/server/btw.ts +143 -0
- package/server/child-env.ts +29 -0
- package/server/cli-control.ts +255 -0
- package/server/cli-files.ts +55 -0
- package/server/cli-info.ts +73 -0
- package/server/config/account.ts +65 -0
- package/server/config/agents.ts +186 -0
- package/server/config/mcp.ts +86 -0
- package/server/config/memory.ts +193 -0
- package/server/config/paths.ts +156 -0
- package/server/config/plugins.ts +204 -0
- package/server/config/routes.ts +95 -0
- package/server/config/settings.ts +246 -0
- package/server/config.ts +46 -0
- package/server/db.ts +121 -0
- package/server/directories.test.ts +180 -0
- package/server/directories.ts +87 -0
- package/server/dist-version.ts +69 -0
- package/server/durable-tasks.ts +61 -0
- package/server/file-transfers.ts +79 -0
- package/server/files.test.ts +127 -0
- package/server/files.ts +386 -0
- package/server/fs-queue.ts +14 -0
- package/server/fsutil.ts +144 -0
- package/server/git.ts +86 -0
- package/server/goal-state.test.ts +64 -0
- package/server/goal-state.ts +78 -0
- package/server/history-pages.test.ts +619 -0
- package/server/history-pages.ts +513 -0
- package/server/http-channel-manager.ts +468 -0
- package/server/http-channel-pull.test.ts +314 -0
- package/server/http-channel-push.test.ts +364 -0
- package/server/http-channel-registration.test.ts +291 -0
- package/server/http-channel-routes.ts +73 -0
- package/server/http-channel-store.ts +142 -0
- package/server/http-channel-test-fixture.ts +103 -0
- package/server/ide-messages-mcp.test.ts +82 -0
- package/server/ide-messages-mcp.ts +47 -0
- package/server/idecron-mcp.ts +65 -0
- package/server/idecron.test.ts +79 -0
- package/server/idecron.ts +271 -0
- package/server/index.ts +336 -0
- package/server/live-session.test.ts +1126 -0
- package/server/live-session.ts +1165 -0
- package/server/project-routes.ts +46 -0
- package/server/projects.test.ts +28 -0
- package/server/push.ts +315 -0
- package/server/report-cache.ts +28 -0
- package/server/session-aliases.test.ts +57 -0
- package/server/session-aliases.ts +50 -0
- package/server/session-branch.ts +71 -0
- package/server/session-dirs.ts +189 -0
- package/server/session-file.test.ts +134 -0
- package/server/session-file.ts +113 -0
- package/server/session-messaging.test.ts +297 -0
- package/server/session-messaging.ts +165 -0
- package/server/sessions.ts +638 -0
- package/server/subagents.test.ts +43 -0
- package/server/subagents.ts +58 -0
- package/server/tool-images.test.ts +76 -0
- package/server/tool-images.ts +45 -0
- package/server/turn-changes.ts +308 -0
- package/server/worktree-git.ts +87 -0
- package/server/worktrees.ts +138 -0
- package/server/ws.test.ts +195 -0
- package/server/ws.ts +197 -0
- package/shared/agent-model.ts +15 -0
- package/shared/agent-tasks.ts +12 -0
- package/shared/config.ts +175 -0
- package/shared/directories.ts +12 -0
- package/shared/files.ts +51 -0
- package/shared/http-channels.ts +63 -0
- package/shared/message-window.ts +189 -0
- package/shared/project.ts +77 -0
- package/shared/protocol.ts +203 -0
- package/shared/task-types.ts +4 -0
- package/shared/todos.ts +40 -0
- package/shared/write-policy.ts +9 -0
- package/web/dist/assets/AgentView-wrTKgcJ3.js +1 -0
- package/web/dist/assets/AnnotationSelection-BZmNmwVW.js +7 -0
- package/web/dist/assets/ChangesView-G1WTzh4q.js +2 -0
- package/web/dist/assets/FilesView-DPgAbYt_.js +6 -0
- package/web/dist/assets/Form-BKTwiYyw.js +1 -0
- package/web/dist/assets/HomePage-Dq2apjBe.js +1 -0
- package/web/dist/assets/Markdown-IwP-xF29.js +67 -0
- package/web/dist/assets/MessageList-DD9GypSy.js +15 -0
- package/web/dist/assets/Modal-g2rv8I_n.js +1 -0
- package/web/dist/assets/NotificationSettings-DpuLO_Bv.js +1 -0
- package/web/dist/assets/OverviewPage-Dx6miabE.js +1 -0
- package/web/dist/assets/ProjectHome-CousyTLL.js +1 -0
- package/web/dist/assets/SessionView-CPVL7_HO.js +2 -0
- package/web/dist/assets/SettingsPage-D_oYSnW2.js +7 -0
- package/web/dist/assets/WorktreesView-C7e0HV__.js +1 -0
- package/web/dist/assets/abap-CLvhMVsD.js +1 -0
- package/web/dist/assets/actionscript-3-Cs6JJXT5.js +1 -0
- package/web/dist/assets/ada-X3Y2mlq2.js +1 -0
- package/web/dist/assets/ahk-DwADkxyd.js +1 -0
- package/web/dist/assets/ahk2-_6rzW9BS.js +1 -0
- package/web/dist/assets/andromeeda-vGVdxbeo.js +1 -0
- package/web/dist/assets/angular-html-Cw29lun8.js +1 -0
- package/web/dist/assets/angular-ts-BXz-O3SJ.js +1 -0
- package/web/dist/assets/apache-U0d_L8uA.js +1 -0
- package/web/dist/assets/apex-KFGFyKwG.js +1 -0
- package/web/dist/assets/apl-xBchdRhy.js +1 -0
- package/web/dist/assets/applescript-CCn79oCD.js +1 -0
- package/web/dist/assets/ara-4CJ0cIlV.js +1 -0
- package/web/dist/assets/asciidoc-SCjQUq34.js +1 -0
- package/web/dist/assets/asm-Cmm7eHzH.js +1 -0
- package/web/dist/assets/astro-1q6DGuN3.js +1 -0
- package/web/dist/assets/aurora-x-CDeNXAV0.js +1 -0
- package/web/dist/assets/awk-BWXHIvNe.js +1 -0
- package/web/dist/assets/ayu-dark-DluEY0Gj.js +1 -0
- package/web/dist/assets/ayu-light-C3h-C4tm.js +1 -0
- package/web/dist/assets/ayu-mirage-Bqwy1Gya.js +1 -0
- package/web/dist/assets/ballerina-B7ZEbQpA.js +1 -0
- package/web/dist/assets/bat-qdxAdBWQ.js +1 -0
- package/web/dist/assets/beancount-C2IN4Vk8.js +1 -0
- package/web/dist/assets/berry-DKpUyyne.js +1 -0
- package/web/dist/assets/bibtex-Ci_nEsc7.js +1 -0
- package/web/dist/assets/bicep-CUHmPFLl.js +1 -0
- package/web/dist/assets/bird2-CYIUgYqB.js +1 -0
- package/web/dist/assets/blade-Cqdv4oZN.js +1 -0
- package/web/dist/assets/bsl-CVED6lJ1.js +1 -0
- package/web/dist/assets/c-D9gIxSfx.js +1 -0
- package/web/dist/assets/c3-D0apG41V.js +1 -0
- package/web/dist/assets/cadence-CQ2zXKGN.js +1 -0
- package/web/dist/assets/cairo-DRAZz75c.js +1 -0
- package/web/dist/assets/catppuccin-frappe-C9Q5zVZY.js +1 -0
- package/web/dist/assets/catppuccin-latte-CtsyrtPq.js +1 -0
- package/web/dist/assets/catppuccin-macchiato-R8nAf1vR.js +1 -0
- package/web/dist/assets/catppuccin-mocha-DjuaAJKi.js +1 -0
- package/web/dist/assets/chapel-CuseGurG.js +1 -0
- package/web/dist/assets/clarity-CtfBBspg.js +1 -0
- package/web/dist/assets/clojure-DqKBuwfJ.js +1 -0
- package/web/dist/assets/cmake-Bj61d0ZC.js +1 -0
- package/web/dist/assets/cobol-Bdpj3Qoh.js +1 -0
- package/web/dist/assets/codeowners-C8r90Shi.js +1 -0
- package/web/dist/assets/codeql-oeQT6MSM.js +1 -0
- package/web/dist/assets/coffee-Sk8pHFjs.js +1 -0
- package/web/dist/assets/common-lisp-Cv5bFMCO.js +1 -0
- package/web/dist/assets/coq-CGaSfhwR.js +1 -0
- package/web/dist/assets/core-BxI4V4-T.js +12 -0
- package/web/dist/assets/cpp-BQfBYKx3.js +1 -0
- package/web/dist/assets/crystal-B3j7DoOZ.js +1 -0
- package/web/dist/assets/csharp-dmMxLVLW.js +1 -0
- package/web/dist/assets/css-DpFbYmRh.js +1 -0
- package/web/dist/assets/csv-Dx-8-gkx.js +1 -0
- package/web/dist/assets/cue-CE9AQfxI.js +1 -0
- package/web/dist/assets/cypher-ClKdZ_lG.js +1 -0
- package/web/dist/assets/d-qD-0Kul2.js +1 -0
- package/web/dist/assets/dark-plus-Cs2F2srj.js +1 -0
- package/web/dist/assets/dart-CHZY9Xhz.js +1 -0
- package/web/dist/assets/dax-BkyTk9wS.js +1 -0
- package/web/dist/assets/desktop-Dlh5hvp9.js +1 -0
- package/web/dist/assets/diff-woXpYk--.js +1 -0
- package/web/dist/assets/docker-IyjqRm3v.js +1 -0
- package/web/dist/assets/dotenv-CuH5my32.js +1 -0
- package/web/dist/assets/dracula-BHWKrbxM.js +1 -0
- package/web/dist/assets/dracula-soft-5eyTD99u.js +1 -0
- package/web/dist/assets/dream-maker-DW3nJb8Q.js +1 -0
- package/web/dist/assets/edge-DXQWmXTB.js +1 -0
- package/web/dist/assets/elixir-CrDIY2Vo.js +1 -0
- package/web/dist/assets/elm-BslFEg8K.js +1 -0
- package/web/dist/assets/emacs-lisp-DR2vtWaj.js +1 -0
- package/web/dist/assets/engine-javascript-DJg3-Vu2.js +141 -0
- package/web/dist/assets/erb-CUYhg0Ws.js +1 -0
- package/web/dist/assets/erlang-BXdgHVfi.js +1 -0
- package/web/dist/assets/everforest-dark-sB-x3p7T.js +1 -0
- package/web/dist/assets/everforest-light-Df2xbC6M.js +1 -0
- package/web/dist/assets/fennel-DQxkIbk2.js +1 -0
- package/web/dist/assets/fish-BJitypiv.js +1 -0
- package/web/dist/assets/fluent-C03EYrpw.js +1 -0
- package/web/dist/assets/format-DrBC7w0A.js +1 -0
- package/web/dist/assets/fortran-fixed-form-D6mN0kjy.js +1 -0
- package/web/dist/assets/fortran-free-form-CYNrtFtB.js +1 -0
- package/web/dist/assets/fsharp-CwoEF39o.js +1 -0
- package/web/dist/assets/gdresource-CZkFGo3B.js +1 -0
- package/web/dist/assets/gdscript-DYvRD3hg.js +1 -0
- package/web/dist/assets/gdshader-CBce3t8t.js +1 -0
- package/web/dist/assets/genie-CV2tkWYe.js +1 -0
- package/web/dist/assets/gherkin-DExj1W_8.js +1 -0
- package/web/dist/assets/git-commit-dUGrvm6Q.js +1 -0
- package/web/dist/assets/git-rebase-4zCRqF28.js +1 -0
- package/web/dist/assets/github-dark-C-LZuMrd.js +1 -0
- package/web/dist/assets/github-dark-default-DXG-b-1a.js +1 -0
- package/web/dist/assets/github-dark-dimmed-Bx1FflLF.js +1 -0
- package/web/dist/assets/github-dark-high-contrast-B_tTalzw.js +1 -0
- package/web/dist/assets/github-light-EUqPIrTm.js +1 -0
- package/web/dist/assets/github-light-default-BXViO-2h.js +1 -0
- package/web/dist/assets/github-light-high-contrast-B68TUdTA.js +1 -0
- package/web/dist/assets/gleam-CSRkHgEL.js +1 -0
- package/web/dist/assets/glimmer-js-7adTRvH4.js +1 -0
- package/web/dist/assets/glimmer-ts-IdSskDYw.js +1 -0
- package/web/dist/assets/glsl-Qzy53KDt.js +1 -0
- package/web/dist/assets/gn-CAXYAgV-.js +1 -0
- package/web/dist/assets/gnuplot-7GGW24-e.js +1 -0
- package/web/dist/assets/go-rLFTqkRN.js +1 -0
- package/web/dist/assets/graphql-CV_puxbB.js +1 -0
- package/web/dist/assets/groovy-CacY0gHj.js +1 -0
- package/web/dist/assets/gruvbox-dark-hard-C820rvS2.js +1 -0
- package/web/dist/assets/gruvbox-dark-medium-BPjhmG05.js +1 -0
- package/web/dist/assets/gruvbox-dark-soft-MrdJrrXF.js +1 -0
- package/web/dist/assets/gruvbox-light-hard-BC_s9l72.js +1 -0
- package/web/dist/assets/gruvbox-light-medium-BAWPOn9u.js +1 -0
- package/web/dist/assets/gruvbox-light-soft-BSMLrYjP.js +1 -0
- package/web/dist/assets/hack-C5GPjiLV.js +1 -0
- package/web/dist/assets/haml-BQ5YR1DQ.js +1 -0
- package/web/dist/assets/handlebars-DnhRK9A1.js +1 -0
- package/web/dist/assets/haskell-D8IpX4py.js +1 -0
- package/web/dist/assets/haxe-C8oCnwJJ.js +1 -0
- package/web/dist/assets/hcl-Dh228itO.js +1 -0
- package/web/dist/assets/highlight.worker-CY-ZeDAl.js +1 -0
- package/web/dist/assets/hjson-CxZEssPk.js +1 -0
- package/web/dist/assets/hlsl-Cvrh5tZx.js +1 -0
- package/web/dist/assets/horizon-CE9ld1lL.js +1 -0
- package/web/dist/assets/horizon-bright-DSNQnXHK.js +1 -0
- package/web/dist/assets/houston-CsvMBhTu.js +1 -0
- package/web/dist/assets/html-CmHiEKV9.js +1 -0
- package/web/dist/assets/html-derivative-BXE-rYIK.js +1 -0
- package/web/dist/assets/http-DPjjqtcL.js +1 -0
- package/web/dist/assets/hurl-AMBpSloO.js +1 -0
- package/web/dist/assets/hxml-D7Y24wKF.js +1 -0
- package/web/dist/assets/hy-CZbG8q4J.js +1 -0
- package/web/dist/assets/imba-DsUTQ-LC.js +1 -0
- package/web/dist/assets/index-DNPnl4sh.css +2 -0
- package/web/dist/assets/index-DQh72lEx.js +14 -0
- package/web/dist/assets/ini-B5eOa1yu.js +1 -0
- package/web/dist/assets/java-CEM7Tdw_.js +1 -0
- package/web/dist/assets/javascript-CU3PLowd.js +1 -0
- package/web/dist/assets/jinja-fR0x_9Xy.js +1 -0
- package/web/dist/assets/jison-BoKsxs5M.js +1 -0
- package/web/dist/assets/json-BM2FWsRB.js +1 -0
- package/web/dist/assets/json5-BR5RXkoi.js +1 -0
- package/web/dist/assets/jsonc-CYpm1nAK.js +1 -0
- package/web/dist/assets/jsonl-CmCQp5Yx.js +1 -0
- package/web/dist/assets/jsonnet-CJTPZ8u_.js +1 -0
- package/web/dist/assets/jssm-DXw9l8Rf.js +1 -0
- package/web/dist/assets/jsx-B5fTecQP.js +1 -0
- package/web/dist/assets/julia-DGyDG39_.js +1 -0
- package/web/dist/assets/just-B-RyLcSR.js +1 -0
- package/web/dist/assets/kanagawa-dragon-Bozd7klA.js +1 -0
- package/web/dist/assets/kanagawa-lotus-BmuOG9ZW.js +1 -0
- package/web/dist/assets/kanagawa-wave-BML5mF2J.js +1 -0
- package/web/dist/assets/kdl-CsD5j6eV.js +1 -0
- package/web/dist/assets/kotlin-DhhofPvG.js +1 -0
- package/web/dist/assets/kusto-C7mF5XQf.js +1 -0
- package/web/dist/assets/langs-CciPKqaF.js +1 -0
- package/web/dist/assets/laserwave-C_8bwKvT.js +1 -0
- package/web/dist/assets/latex-CV1pQelm.js +1 -0
- package/web/dist/assets/lean-CewbzKMR.js +1 -0
- package/web/dist/assets/less-DVTAwKKz.js +1 -0
- package/web/dist/assets/lib-DlcVMUqT.js +1 -0
- package/web/dist/assets/light-plus-DVQuIRkW.js +1 -0
- package/web/dist/assets/liquid-9p05RSzQ.js +1 -0
- package/web/dist/assets/llvm-DqHOMS-s.js +1 -0
- package/web/dist/assets/log-BNLmms1o.js +1 -0
- package/web/dist/assets/logo-Cluzi2Zq.js +1 -0
- package/web/dist/assets/lua-CIPVh0S-.js +1 -0
- package/web/dist/assets/luau-VGWVLoqX.js +1 -0
- package/web/dist/assets/make-Dixweg8N.js +1 -0
- package/web/dist/assets/markdown-BYOwaDjH.js +1 -0
- package/web/dist/assets/marko-h2h6YUMS.js +1 -0
- package/web/dist/assets/material-theme-Bm3Qr25_.js +1 -0
- package/web/dist/assets/material-theme-darker-2IIEA8gg.js +1 -0
- package/web/dist/assets/material-theme-lighter-uhdI0v04.js +1 -0
- package/web/dist/assets/material-theme-ocean-CHQ94UKr.js +1 -0
- package/web/dist/assets/material-theme-palenight-B5W6OYN7.js +1 -0
- package/web/dist/assets/matlab-D7qyCx1q.js +1 -0
- package/web/dist/assets/mdc-yAyZMdoC.js +1 -0
- package/web/dist/assets/mdx-DQZ5AkYe.js +1 -0
- package/web/dist/assets/mermaid-BxpfTIzt.js +1 -0
- package/web/dist/assets/message-font-size-DoaZfwkT.js +1 -0
- package/web/dist/assets/min-dark-BSWPekZh.js +1 -0
- package/web/dist/assets/min-light-DDpmG2fV.js +1 -0
- package/web/dist/assets/mipsasm-BMqwQI7S.js +1 -0
- package/web/dist/assets/mojo-fg40_ntQ.js +1 -0
- package/web/dist/assets/monokai-BscAjiQZ.js +1 -0
- package/web/dist/assets/moonbit-D9W784E_.js +1 -0
- package/web/dist/assets/move-BYnGhZoM.js +1 -0
- package/web/dist/assets/narrat-_X_XdTYD.js +1 -0
- package/web/dist/assets/nextflow-BoTnSBQ_.js +1 -0
- package/web/dist/assets/nextflow-groovy-D7IRt6Pp.js +1 -0
- package/web/dist/assets/nginx-jWINucNT.js +1 -0
- package/web/dist/assets/night-owl-DhmEMT88.js +1 -0
- package/web/dist/assets/night-owl-light-eJ-hLW7d.js +1 -0
- package/web/dist/assets/nim-Dtt9DZ0t.js +1 -0
- package/web/dist/assets/nix-Cl_7024t.js +1 -0
- package/web/dist/assets/nord-Cb4Vim4T.js +1 -0
- package/web/dist/assets/nsis-CjNLYCLg.js +1 -0
- package/web/dist/assets/nushell--0fT3PI1.js +1 -0
- package/web/dist/assets/objective-c-D1A_Heim.js +1 -0
- package/web/dist/assets/objective-cpp-BsSzOQcm.js +1 -0
- package/web/dist/assets/ocaml-O90oeIOV.js +1 -0
- package/web/dist/assets/odin-B1RWQWA5.js +1 -0
- package/web/dist/assets/one-dark-pro-C6YyR8OD.js +1 -0
- package/web/dist/assets/one-light-D7Lr4KcI.js +1 -0
- package/web/dist/assets/openscad-BUDT5pXO.js +1 -0
- package/web/dist/assets/org-ClD5huOa.js +1 -0
- package/web/dist/assets/pascal-4ZHwLPI5.js +1 -0
- package/web/dist/assets/perl-BFegxyeJ.js +1 -0
- package/web/dist/assets/php-B-qOA3Y4.js +1 -0
- package/web/dist/assets/pkl-ot-7Btpt.js +1 -0
- package/web/dist/assets/plastic-DQwYfKfQ.js +1 -0
- package/web/dist/assets/plsql-DGHpHOYJ.js +1 -0
- package/web/dist/assets/po-BiJDBrnU.js +1 -0
- package/web/dist/assets/poimandres-DRFjx7u4.js +1 -0
- package/web/dist/assets/polar-C7UOKdEL.js +1 -0
- package/web/dist/assets/postcss-BXeXVLqQ.js +1 -0
- package/web/dist/assets/powerquery-DNMTfnFr.js +1 -0
- package/web/dist/assets/powershell-QtSnmXcu.js +1 -0
- package/web/dist/assets/prisma-DMgNhnYS.js +1 -0
- package/web/dist/assets/prolog-iXnhIJG7.js +1 -0
- package/web/dist/assets/proto-DB4EqR-F.js +1 -0
- package/web/dist/assets/pug-Cq0NBPu-.js +1 -0
- package/web/dist/assets/puppet-CDv2pdJW.js +1 -0
- package/web/dist/assets/purescript-9MfHhQsQ.js +1 -0
- package/web/dist/assets/python-gzcpVVnB.js +1 -0
- package/web/dist/assets/qml-CYUsa87k.js +1 -0
- package/web/dist/assets/qmldir-DCQb3MpD.js +1 -0
- package/web/dist/assets/qss-Fe1Jh2GI.js +1 -0
- package/web/dist/assets/r-4xJhgbOf.js +1 -0
- package/web/dist/assets/racket-DcIDlBhZ.js +1 -0
- package/web/dist/assets/raku-B3gFvitq.js +1 -0
- package/web/dist/assets/razor-BlHaOHx8.js +1 -0
- package/web/dist/assets/rbs-C0MpOrj4.js +1 -0
- package/web/dist/assets/react-B4UGpcWC.js +1 -0
- package/web/dist/assets/red-CYlAq2b0.js +1 -0
- package/web/dist/assets/reg-CRGYupPL.js +1 -0
- package/web/dist/assets/regexp-BUSxjv2o.js +1 -0
- package/web/dist/assets/rel-BtDbiS_P.js +1 -0
- package/web/dist/assets/riscv-Ckw8ddFX.js +1 -0
- package/web/dist/assets/ron-VUp2lXgN.js +1 -0
- package/web/dist/assets/rose-pine-aMsaMD1D.js +1 -0
- package/web/dist/assets/rose-pine-dawn-8PwUyIlP.js +1 -0
- package/web/dist/assets/rose-pine-moon-DO-qXg-I.js +1 -0
- package/web/dist/assets/rosmsg-CAekHB0j.js +1 -0
- package/web/dist/assets/rst-zjfguEh7.js +1 -0
- package/web/dist/assets/ruby-4oR9waBK.js +1 -0
- package/web/dist/assets/rust-DpiIf5JO.js +1 -0
- package/web/dist/assets/sas-Do4V_2i9.js +1 -0
- package/web/dist/assets/sass-DXrisJhu.js +1 -0
- package/web/dist/assets/scala-BiFt_7mu.js +1 -0
- package/web/dist/assets/scheme-DQCgrYNe.js +1 -0
- package/web/dist/assets/scss-CW6JPtag.js +1 -0
- package/web/dist/assets/sdbl-bTVj8UrX.js +1 -0
- package/web/dist/assets/shaderlab-C4rnI7x0.js +1 -0
- package/web/dist/assets/shellscript-zBmOMFJh.js +1 -0
- package/web/dist/assets/shellsession-DxGUTWaf.js +1 -0
- package/web/dist/assets/slack-dark-DnToyrRv.js +1 -0
- package/web/dist/assets/slack-ochin-B2OO5cIa.js +1 -0
- package/web/dist/assets/smalltalk-BHpQb5nM.js +1 -0
- package/web/dist/assets/smithy-Dgaz7aur.js +1 -0
- package/web/dist/assets/snazzy-light-4G7pJPwS.js +1 -0
- package/web/dist/assets/solarized-dark-b97czJOk.js +1 -0
- package/web/dist/assets/solarized-light-DFPxpIYQ.js +1 -0
- package/web/dist/assets/solidity-Bj7E1mRn.js +1 -0
- package/web/dist/assets/soy-DVtw20Yn.js +1 -0
- package/web/dist/assets/sparql-C0ia5K60.js +1 -0
- package/web/dist/assets/splunk-BC2Px7Mm.js +1 -0
- package/web/dist/assets/sql-DGCM-jag.js +1 -0
- package/web/dist/assets/ssh-config-BgfXC-Er.js +1 -0
- package/web/dist/assets/stata-DfuMOXna.js +1 -0
- package/web/dist/assets/stylus-B6D30XZt.js +1 -0
- package/web/dist/assets/surrealql-CBoRWxfR.js +1 -0
- package/web/dist/assets/svelte-g_uCUem5.js +1 -0
- package/web/dist/assets/swift-CyEgAFGc.js +1 -0
- package/web/dist/assets/synthwave-84-nFMaYfgc.js +1 -0
- package/web/dist/assets/system-verilog-Dh5ihWbf.js +1 -0
- package/web/dist/assets/systemd-BxMlprV5.js +1 -0
- package/web/dist/assets/talonscript-CohzipZa.js +1 -0
- package/web/dist/assets/tasl-DMoTqEGO.js +1 -0
- package/web/dist/assets/tcl-CZd0xW_V.js +1 -0
- package/web/dist/assets/templ-N5C1hJ4Y.js +1 -0
- package/web/dist/assets/terraform-DswuEJGm.js +1 -0
- package/web/dist/assets/tex-mx6xDwuK.js +1 -0
- package/web/dist/assets/themes-CNElGaXw.js +1 -0
- package/web/dist/assets/tokyo-night-oM2G3aXe.js +1 -0
- package/web/dist/assets/toml-CcmNWLt0.js +1 -0
- package/web/dist/assets/ts-tags-C-j71FRb.js +1 -0
- package/web/dist/assets/tsv-sltzmVWM.js +1 -0
- package/web/dist/assets/tsx-z04y7qR9.js +1 -0
- package/web/dist/assets/turtle-ByJddavk.js +1 -0
- package/web/dist/assets/twig-Bz57G_nl.js +1 -0
- package/web/dist/assets/typescript-RMqVTmzR.js +1 -0
- package/web/dist/assets/typespec-BGhR2SqG.js +1 -0
- package/web/dist/assets/typst-BgkqA2Pg.js +1 -0
- package/web/dist/assets/v-DBj4K9d_.js +1 -0
- package/web/dist/assets/vala-zf12oZj6.js +1 -0
- package/web/dist/assets/vb-DOwzaHZ0.js +1 -0
- package/web/dist/assets/verilog-B2gwz-sJ.js +1 -0
- package/web/dist/assets/vesper-DdrHHSXu.js +1 -0
- package/web/dist/assets/vhdl-BroJfC0k.js +1 -0
- package/web/dist/assets/viml-DvXPmvsu.js +1 -0
- package/web/dist/assets/vitesse-black-fwtXNY1n.js +1 -0
- package/web/dist/assets/vitesse-dark-BZCL-v6S.js +1 -0
- package/web/dist/assets/vitesse-light-VbXTXTou.js +1 -0
- package/web/dist/assets/vue-CQgvQytb.js +1 -0
- package/web/dist/assets/vue-html-Dhl6fh9l.js +1 -0
- package/web/dist/assets/vue-vine-Dw_uU59K.js +1 -0
- package/web/dist/assets/vyper-CgoNMtux.js +1 -0
- package/web/dist/assets/wasm-ByWQv1Qj.js +1 -0
- package/web/dist/assets/wenyan-C8pVoKbM.js +1 -0
- package/web/dist/assets/wgsl-BsKzXJz4.js +1 -0
- package/web/dist/assets/wikitext-ClFFjSW2.js +1 -0
- package/web/dist/assets/wit-DdvCle-K.js +1 -0
- package/web/dist/assets/wolfram-DLL8P-h_.js +1 -0
- package/web/dist/assets/xml-B49klYQ3.js +1 -0
- package/web/dist/assets/xsl-D7tnD3qq.js +1 -0
- package/web/dist/assets/yaml-DNQ5_xkL.js +1 -0
- package/web/dist/assets/zenscript-BnlCZFoB.js +1 -0
- package/web/dist/assets/zig-CMLA9XwU.js +1 -0
- package/web/dist/icons/apple-touch-icon.png +0 -0
- package/web/dist/icons/badge-96.png +0 -0
- package/web/dist/icons/icon-192.png +0 -0
- package/web/dist/icons/icon-512.png +0 -0
- package/web/dist/icons/icon.svg +4 -0
- package/web/dist/icons/maskable-192.png +0 -0
- package/web/dist/icons/maskable-512.png +0 -0
- package/web/dist/index.html +30 -0
- package/web/dist/manifest.webmanifest +20 -0
- package/web/dist/sw.js +176 -0
- package/web/dist/version.json +1 -0
- package/web/src/App.tsx +97 -0
- package/web/src/agent-history-flow.test.ts +191 -0
- package/web/src/agent-history-flow.ts +58 -0
- package/web/src/agent-owners.ts +30 -0
- package/web/src/annotation-selection.test.ts +36 -0
- package/web/src/annotation-selection.ts +123 -0
- package/web/src/annotations.test.ts +279 -0
- package/web/src/annotations.ts +191 -0
- package/web/src/api.ts +27 -0
- package/web/src/auth-events.ts +11 -0
- package/web/src/auth-lifecycle.test.ts +105 -0
- package/web/src/clipboard.ts +32 -0
- package/web/src/components/ActivityNotifier.tsx +22 -0
- package/web/src/components/AnnotationEditor.tsx +135 -0
- package/web/src/components/AnnotationSelection.tsx +122 -0
- package/web/src/components/Automation.tsx +390 -0
- package/web/src/components/CodeView.tsx +121 -0
- package/web/src/components/Composer.tsx +552 -0
- package/web/src/components/ComposerAnnotations.tsx +45 -0
- package/web/src/components/Confirmation.tsx +91 -0
- package/web/src/components/CopyButton.tsx +23 -0
- package/web/src/components/DiffView.tsx +94 -0
- package/web/src/components/DirectoryPicker.tsx +230 -0
- package/web/src/components/FileCopyMenu.tsx +58 -0
- package/web/src/components/FileEntryActions.tsx +95 -0
- package/web/src/components/ImagePreview.tsx +68 -0
- package/web/src/components/Items.tsx +608 -0
- package/web/src/components/Markdown.tsx +73 -0
- package/web/src/components/MessageList.tsx +458 -0
- package/web/src/components/Modal.tsx +72 -0
- package/web/src/components/NotificationEntry.tsx +31 -0
- package/web/src/components/NotificationSettings.tsx +226 -0
- package/web/src/components/NotificationToasts.tsx +59 -0
- package/web/src/components/OpenTabs.tsx +112 -0
- package/web/src/components/Pending.tsx +175 -0
- package/web/src/components/ProjectExtras.tsx +299 -0
- package/web/src/components/ProjectNav.tsx +47 -0
- package/web/src/components/SessionActions.tsx +271 -0
- package/web/src/components/SessionChannels.tsx +288 -0
- package/web/src/components/SessionExtras.tsx +263 -0
- package/web/src/components/SettingsLink.tsx +21 -0
- package/web/src/components/Shell.tsx +320 -0
- package/web/src/components/SvgPreview.tsx +18 -0
- package/web/src/components/TodoPanel.tsx +37 -0
- package/web/src/components/VersionNotice.tsx +40 -0
- package/web/src/components/icons.tsx +43 -0
- package/web/src/diff.ts +62 -0
- package/web/src/drafts.test.ts +80 -0
- package/web/src/drafts.ts +64 -0
- package/web/src/format.ts +5 -0
- package/web/src/highlight.worker.ts +49 -0
- package/web/src/hooks.ts +59 -0
- package/web/src/item-view-state.test.ts +51 -0
- package/web/src/item-view-state.ts +43 -0
- package/web/src/live-history-flow.test.ts +77 -0
- package/web/src/live-merge.test.ts +64 -0
- package/web/src/live-merge.ts +50 -0
- package/web/src/live-message-hydration.test.ts +253 -0
- package/web/src/live.ts +404 -0
- package/web/src/main.tsx +27 -0
- package/web/src/markdown-assets.test.ts +70 -0
- package/web/src/markdown-assets.ts +78 -0
- package/web/src/markdown-html.test.ts +84 -0
- package/web/src/markdown-html.ts +60 -0
- package/web/src/markdown-svg.test.ts +37 -0
- package/web/src/markdown-svg.ts +42 -0
- package/web/src/markdown-tables.test.ts +34 -0
- package/web/src/markdown-tables.ts +17 -0
- package/web/src/message-bodies.ts +117 -0
- package/web/src/message-cache.test.ts +208 -0
- package/web/src/message-cache.ts +158 -0
- package/web/src/message-font-size.test.ts +41 -0
- package/web/src/message-font-size.ts +57 -0
- package/web/src/message-time.test.ts +32 -0
- package/web/src/message-time.ts +26 -0
- package/web/src/notify.ts +132 -0
- package/web/src/open-tabs.test.ts +69 -0
- package/web/src/open-tabs.ts +110 -0
- package/web/src/pages/AgentView.tsx +138 -0
- package/web/src/pages/ChangesView.tsx +193 -0
- package/web/src/pages/FilesView.tsx +406 -0
- package/web/src/pages/HomePage.tsx +96 -0
- package/web/src/pages/LoginPage.tsx +56 -0
- package/web/src/pages/OverviewPage.tsx +124 -0
- package/web/src/pages/ProjectHome.tsx +136 -0
- package/web/src/pages/SessionView.tsx +238 -0
- package/web/src/pages/WorktreesView.tsx +197 -0
- package/web/src/pages/settings/AccountPage.tsx +149 -0
- package/web/src/pages/settings/AgentsPage.tsx +182 -0
- package/web/src/pages/settings/ConfigPage.tsx +181 -0
- package/web/src/pages/settings/GeneralPage.tsx +27 -0
- package/web/src/pages/settings/HooksPage.tsx +244 -0
- package/web/src/pages/settings/McpPage.tsx +310 -0
- package/web/src/pages/settings/MemoryPage.tsx +309 -0
- package/web/src/pages/settings/PermissionsPage.tsx +160 -0
- package/web/src/pages/settings/PluginsPage.tsx +251 -0
- package/web/src/pages/settings/SettingsPage.tsx +141 -0
- package/web/src/pages/settings/SkillsPage.tsx +142 -0
- package/web/src/pages/settings/ui.tsx +165 -0
- package/web/src/preference-document.ts +133 -0
- package/web/src/project-actions.ts +18 -0
- package/web/src/project-files-state.test.ts +49 -0
- package/web/src/project-files-state.ts +64 -0
- package/web/src/project-images.ts +13 -0
- package/web/src/pwa.ts +33 -0
- package/web/src/queryClient.ts +5 -0
- package/web/src/reasoning-preference.test.ts +39 -0
- package/web/src/reasoning-preference.ts +42 -0
- package/web/src/session-confirmations.ts +36 -0
- package/web/src/stream-replay.test.ts +163 -0
- package/web/src/styles.css +231 -0
- package/web/src/tool-result-body.test.ts +115 -0
- package/web/src/transcript.test.ts +186 -0
- package/web/src/transcript.ts +583 -0
- package/web/src/turns.test.ts +338 -0
- package/web/src/turns.ts +178 -0
- package/web/src/ui/Button.tsx +60 -0
- package/web/src/ui/Controls.tsx +227 -0
- package/web/src/ui/Form.tsx +203 -0
- package/web/src/ui/MenuPopover.tsx +144 -0
- package/web/src/ui/Popover.tsx +147 -0
- package/web/src/ui/ResizablePane.tsx +110 -0
- package/web/src/ui/Select.tsx +150 -0
- package/web/src/ui/icons.tsx +270 -0
- package/web/src/unsaved.tsx +99 -0
- package/web/src/user-text.test.ts +60 -0
- package/web/src/user-text.ts +35 -0
- package/web/src/uuid.ts +12 -0
- package/README.md +0 -3
|
@@ -0,0 +1,1165 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import crypto from "node:crypto";
|
|
3
|
+
import {
|
|
4
|
+
query,
|
|
5
|
+
getSessionInfo,
|
|
6
|
+
type Query,
|
|
7
|
+
type SDKMessage,
|
|
8
|
+
type SDKUserMessage,
|
|
9
|
+
type CanUseTool,
|
|
10
|
+
type PermissionResult,
|
|
11
|
+
} from "@anthropic-ai/claude-agent-sdk";
|
|
12
|
+
import type {
|
|
13
|
+
LiveEvent,
|
|
14
|
+
LiveSnapshot,
|
|
15
|
+
PendingAnswer,
|
|
16
|
+
PendingRequest,
|
|
17
|
+
SeqEvent,
|
|
18
|
+
SessionState,
|
|
19
|
+
ImageInput,
|
|
20
|
+
CronInfo,
|
|
21
|
+
BgTask,
|
|
22
|
+
SdkMessageLike,
|
|
23
|
+
AgentTask,
|
|
24
|
+
} from "../shared/protocol.ts";
|
|
25
|
+
import { StreamingReplay } from "../shared/message-window.ts";
|
|
26
|
+
import { responseModel } from "../shared/agent-model.ts";
|
|
27
|
+
import { isSubagentTaskType } from "../shared/task-types.ts";
|
|
28
|
+
import { currentAgentTask } from "../shared/agent-tasks.ts";
|
|
29
|
+
import { config } from "./config.ts";
|
|
30
|
+
import { db } from "./db.ts";
|
|
31
|
+
import { noteTerminalCommands } from "./cli-info.ts";
|
|
32
|
+
import { runBang, bangMessage } from "./bang.ts";
|
|
33
|
+
import { lastCostState } from "./session-file.ts";
|
|
34
|
+
import { CronExpressionParser } from "cron-parser";
|
|
35
|
+
import { sessionDirs } from "./session-dirs.ts";
|
|
36
|
+
import { childEnv } from "./child-env.ts";
|
|
37
|
+
import { durableTasks, durableTasksForSession, type DurableTask } from "./durable-tasks.ts";
|
|
38
|
+
import { idecron, type IdeCronMode } from "./idecron.ts";
|
|
39
|
+
import { ideCronServer } from "./idecron-mcp.ts";
|
|
40
|
+
import { ideMessagesServer } from "./ide-messages-mcp.ts";
|
|
41
|
+
import { needsInheritedGoalCleanup, markGoalIsolated } from "./btw-policy.ts";
|
|
42
|
+
import { WRITES_DISABLED, READ_ONLY_TOOLS, SIDE_AGENT_TOOLS } from "../shared/write-policy.ts";
|
|
43
|
+
import { conversationInstructions } from "./btw-instructions.ts";
|
|
44
|
+
|
|
45
|
+
/** Push-based async iterable that never ends until closed — keeps the CLI process alive. */
|
|
46
|
+
class InputQueue implements AsyncIterable<SDKUserMessage> {
|
|
47
|
+
private items: SDKUserMessage[] = [];
|
|
48
|
+
private waiters: ((r: IteratorResult<SDKUserMessage>) => void)[] = [];
|
|
49
|
+
private closed = false;
|
|
50
|
+
|
|
51
|
+
push(item: SDKUserMessage) {
|
|
52
|
+
if (this.closed) throw new Error("会话输入已关闭,请重试。");
|
|
53
|
+
const w = this.waiters.shift();
|
|
54
|
+
if (w) w({ value: item, done: false });
|
|
55
|
+
else this.items.push(item);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
close() {
|
|
59
|
+
this.closed = true;
|
|
60
|
+
this.items.length = 0;
|
|
61
|
+
for (const w of this.waiters.splice(0)) w({ value: undefined, done: true });
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
[Symbol.asyncIterator](): AsyncIterator<SDKUserMessage> {
|
|
65
|
+
return {
|
|
66
|
+
next: () => {
|
|
67
|
+
const item = this.items.shift();
|
|
68
|
+
if (item) return Promise.resolve({ value: item, done: false });
|
|
69
|
+
if (this.closed) return Promise.resolve({ value: undefined, done: true });
|
|
70
|
+
return new Promise((resolve) => this.waiters.push(resolve));
|
|
71
|
+
},
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function isLocalCommandOutput(content: unknown): boolean {
|
|
77
|
+
const text = typeof content === "string" ? content : Array.isArray(content) ? String((content[0] as { text?: unknown })?.text ?? "") : "";
|
|
78
|
+
return /^\s*<local-command-std(out|err)>/.test(text);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** The SDK's query(); unit tests swap in a scripted fake to control event order and timing. */
|
|
82
|
+
let queryImpl: typeof query = query;
|
|
83
|
+
export function setQueryImplForTests(fn: typeof query) {
|
|
84
|
+
queryImpl = fn;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const EVENT_BUFFER_LIMIT = 5000;
|
|
88
|
+
const DELTA_FLUSH_MS = 50;
|
|
89
|
+
const QUESTIONS_DISABLED = "本会话已禁用 AskUserQuestion。不要再次调用提问工具;请根据已有信息作出合理判断并继续任务。缺少必要信息、无法继续时,请直接在回复中说明。";
|
|
90
|
+
|
|
91
|
+
/** System subtypes that are pure noise for the UI. */
|
|
92
|
+
const DROP_SYSTEM = new Set(["init", "thinking_tokens", "hook_started", "hook_progress", "hook_response", "files_persisted"]);
|
|
93
|
+
|
|
94
|
+
type Pending = { req: PendingRequest; resolve: (r: PermissionResult) => void; input: Record<string, unknown> };
|
|
95
|
+
|
|
96
|
+
export type LiveSessionListener = (seq: number, ev: LiveEvent) => void;
|
|
97
|
+
export type LiveTextPart = { id: string; sessionId: string; text: string };
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* One CLI session held by the server. The query stays open between turns so that
|
|
101
|
+
* queued messages, background tasks and scheduled tasks keep working without a browser.
|
|
102
|
+
*/
|
|
103
|
+
export class LiveSession {
|
|
104
|
+
readonly events: SeqEvent[] = [];
|
|
105
|
+
seq = 0;
|
|
106
|
+
state: SessionState = "idle";
|
|
107
|
+
model?: string;
|
|
108
|
+
effort?: string;
|
|
109
|
+
planMode = false;
|
|
110
|
+
askQuestionsEnabled = true;
|
|
111
|
+
writeEnabled = true;
|
|
112
|
+
costUsd?: number;
|
|
113
|
+
/** Cumulative cost at the previous result; each turn's cost is the difference. */
|
|
114
|
+
private prevCost?: number;
|
|
115
|
+
lastActivity = Date.now();
|
|
116
|
+
|
|
117
|
+
private q?: Query;
|
|
118
|
+
private pumpDone?: Promise<void>;
|
|
119
|
+
private input?: InputQueue;
|
|
120
|
+
private inheritedGoalCommand?: string;
|
|
121
|
+
private contextAppendCommand?: string;
|
|
122
|
+
private pending = new Map<string, Pending>();
|
|
123
|
+
private listeners = new Set<LiveSessionListener>();
|
|
124
|
+
private deltaBuf = new Map<string, Extract<LiveEvent, { k: "delta" }>>();
|
|
125
|
+
private deltaTimer?: NodeJS.Timeout;
|
|
126
|
+
private currentMsgId = new Map<string | null, string>();
|
|
127
|
+
private blockKinds = new Map<string, "text" | "thinking" | "other">();
|
|
128
|
+
/** Extra directories last handed to the current CLI process (undefined = none yet). */
|
|
129
|
+
private appliedDirs?: string[];
|
|
130
|
+
|
|
131
|
+
/** Scheduled tasks and background tasks, as last reported by the CLI. */
|
|
132
|
+
crons: CronInfo[] = [];
|
|
133
|
+
/** A persisted list exists, but the current CLI process has not confirmed it yet. */
|
|
134
|
+
cronsStale = false;
|
|
135
|
+
/** The stale marker came from a durable-only row, so it can be cleared when the file disappears. */
|
|
136
|
+
private staleFromDurable = false;
|
|
137
|
+
/** The next idle transition is an internal refresh, not a user-visible turn. */
|
|
138
|
+
private silentTurn = false;
|
|
139
|
+
tasks: BgTask[] = [];
|
|
140
|
+
private agentTasks = new Map<string, AgentTask>();
|
|
141
|
+
private streamingReplay = new StreamingReplay();
|
|
142
|
+
|
|
143
|
+
/** Changes only when this session view is invalidated, independently of replay clipping. */
|
|
144
|
+
historyRevision: string = crypto.randomUUID();
|
|
145
|
+
|
|
146
|
+
get streamingCheckpoints(): SeqEvent[] { return this.streamingReplay.checkpoints; }
|
|
147
|
+
|
|
148
|
+
constructor(
|
|
149
|
+
readonly sessionId: string,
|
|
150
|
+
public cwd: string,
|
|
151
|
+
private readonly onChange: () => void,
|
|
152
|
+
/** Turn finished, or the session started waiting for the user. */
|
|
153
|
+
private readonly onTransition: (from: SessionState, to: SessionState, turn: { failed: boolean; silent: boolean }) => void = () => {},
|
|
154
|
+
private readonly onTextPart: (part: LiveTextPart) => void = () => {},
|
|
155
|
+
) {
|
|
156
|
+
const view = db.prepare("SELECT ask_questions_enabled, write_enabled FROM session_view WHERE session_id=?").get(sessionId) as { ask_questions_enabled: number; write_enabled: number } | undefined;
|
|
157
|
+
this.askQuestionsEnabled = view?.ask_questions_enabled !== 0;
|
|
158
|
+
this.writeEnabled = view?.write_enabled !== 0;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
subscribe(fn: LiveSessionListener): () => void {
|
|
162
|
+
this.listeners.add(fn);
|
|
163
|
+
return () => this.listeners.delete(fn);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
snapshot(): LiveSnapshot {
|
|
167
|
+
return {
|
|
168
|
+
state: this.state,
|
|
169
|
+
pending: [...this.pending.values()].map((p) => p.req),
|
|
170
|
+
model: this.model,
|
|
171
|
+
effort: this.effort,
|
|
172
|
+
planMode: this.planMode,
|
|
173
|
+
askQuestionsEnabled: this.askQuestionsEnabled,
|
|
174
|
+
writeEnabled: this.writeEnabled,
|
|
175
|
+
costUsd: this.costUsd,
|
|
176
|
+
crons: this.allCrons(),
|
|
177
|
+
cronsStale: this.cronsStale,
|
|
178
|
+
tasks: this.tasks,
|
|
179
|
+
agentTasks: [...this.agentTasks.values()],
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** Events after `seq`, or undefined if they are no longer buffered. */
|
|
184
|
+
eventsSince(seq: number): SeqEvent[] | undefined {
|
|
185
|
+
if (seq === this.seq) return [];
|
|
186
|
+
const first = this.events[0];
|
|
187
|
+
if (!first || seq < first.seq - 1 || seq > this.seq) return undefined;
|
|
188
|
+
return this.events.filter((e) => e.seq > seq);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
get isStarted() {
|
|
192
|
+
return !!this.q;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
private emit(ev: LiveEvent) {
|
|
196
|
+
if (ev.k !== "delta") this.flushDeltas();
|
|
197
|
+
// Attribution must outlive the capped task status map, even for a browser
|
|
198
|
+
// first connecting after the invocation's lifecycle frames left replay.
|
|
199
|
+
const parent = ev.k === "sdk" ? ev.m.parent_tool_use_id : ev.k === "delta" ? ev.parent : undefined;
|
|
200
|
+
const agentId = parent ? this.agentTasks.get(parent)?.taskId : undefined;
|
|
201
|
+
if (agentId && (ev.k === "sdk" || ev.k === "delta")) ev = { ...ev, agentId };
|
|
202
|
+
this.lastActivity = Date.now();
|
|
203
|
+
const seq = ++this.seq;
|
|
204
|
+
const settled = this.streamingReplay.push({ seq, ev });
|
|
205
|
+
if (ev.k === "sdk" && settled.indexes.length) ev = { ...ev, m: { ...ev.m, _streamBlocks: settled.indexes,
|
|
206
|
+
...(Object.keys(settled.thinking).length ? { _streamThinking: settled.thinking } : {}) } };
|
|
207
|
+
this.events.push({ seq, ev });
|
|
208
|
+
if (this.events.length > EVENT_BUFFER_LIMIT) this.events.splice(0, this.events.length - EVENT_BUFFER_LIMIT);
|
|
209
|
+
for (const l of this.listeners) l(seq, ev);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
private setState(state: SessionState) {
|
|
213
|
+
if (this.state === state) return;
|
|
214
|
+
const from = this.state;
|
|
215
|
+
this.state = state;
|
|
216
|
+
// A stop we initiated (rewind, config reload, delete) is not a finished turn: no notification.
|
|
217
|
+
// An internal cron refresh is also silent: it only exists to let the Stop hook report state.
|
|
218
|
+
const silent = this.stopping !== undefined || this.silentTurn;
|
|
219
|
+
if (state === "running") this.fireDeferredIdle(); // a previous turn's notification goes out first
|
|
220
|
+
if (state === "idle" && from !== "idle" && !silent && !this.resultThisTurn) {
|
|
221
|
+
// The turn's result (success or failure) may still be on its way: decide when it arrives.
|
|
222
|
+
this.deferredIdleFrom = from;
|
|
223
|
+
this.deferredIdleTimer = setTimeout(() => this.fireDeferredIdle(), 3000);
|
|
224
|
+
} else {
|
|
225
|
+
this.onTransition(from, state, { failed: this.lastTurnFailed, silent });
|
|
226
|
+
}
|
|
227
|
+
if (state === "running") {
|
|
228
|
+
this.lastTurnFailed = false;
|
|
229
|
+
this.userInterrupted = false;
|
|
230
|
+
this.resultThisTurn = false;
|
|
231
|
+
}
|
|
232
|
+
if (state === "idle") {
|
|
233
|
+
this.silentTurn = false;
|
|
234
|
+
this.maybeReloadConfig();
|
|
235
|
+
}
|
|
236
|
+
this.emit({ k: "state", state });
|
|
237
|
+
this.onChange();
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** Conversation rewind waiting for the next message: keep the transcript up to this record. */
|
|
241
|
+
get rewindAnchor(): string | undefined {
|
|
242
|
+
const row = db.prepare("SELECT rewind_anchor FROM session_view WHERE session_id = ?").get(this.sessionId) as { rewind_anchor: string | null } | undefined;
|
|
243
|
+
return row?.rewind_anchor ?? undefined;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// Serialize management operations, not the model turn: later prompts can still queue immediately.
|
|
247
|
+
private operations: Promise<void> = Promise.resolve();
|
|
248
|
+
private awaitingCommands = new Set<string>();
|
|
249
|
+
|
|
250
|
+
private operate<T>(fn: () => Promise<T>): Promise<T> {
|
|
251
|
+
const result = this.operations.then(fn);
|
|
252
|
+
this.operations = result.then(() => {}, () => {});
|
|
253
|
+
return result;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
private maybeReloadConfig() {
|
|
257
|
+
if (this.configStale && this.state === "idle" && this.awaitingCommands.size === 0) {
|
|
258
|
+
void this.reloadConfig().catch((err) => this.emit({ k: "error", message: String(err) }));
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
private starting?: Promise<void>;
|
|
263
|
+
|
|
264
|
+
/** Start the CLI once; concurrent callers (two devices sending at once) share the same start. */
|
|
265
|
+
private ensureStarted(): Promise<void> {
|
|
266
|
+
if (this.closed) return Promise.reject(new Error("这个会话已被删除。"));
|
|
267
|
+
// While a start is in progress (q may already exist, extra dirs not yet applied), wait for it.
|
|
268
|
+
if (this.starting) return this.starting;
|
|
269
|
+
if (this.q && !this.stopping) return Promise.resolve();
|
|
270
|
+
this.starting ??= this.start().finally(() => (this.starting = undefined));
|
|
271
|
+
return this.starting;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/** Bring the CLI process up without sending anything (e.g. to restore scheduled tasks). */
|
|
275
|
+
async wake() {
|
|
276
|
+
return this.operate(() => this.ensureStarted());
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Ask the CLI to report restored scheduled tasks. The Stop hook is the only public source,
|
|
281
|
+
* so this deliberately runs one tiny internal turn. The turn is user-visible (the CLI only
|
|
282
|
+
* reports Stop state for real turns) but its completion notification is suppressed.
|
|
283
|
+
*/
|
|
284
|
+
async refreshCrons() {
|
|
285
|
+
if (this.closed) throw new Error("这个会话已被删除。");
|
|
286
|
+
if (this.state !== "idle" || this.awaitingCommands.size) throw new Error("会话正在运行,稍后再刷新定时任务。");
|
|
287
|
+
if (this.rewindAnchor) throw new Error("会话正在等待回退,先发送消息或取消回退再刷新定时任务。");
|
|
288
|
+
return this.operate(async () => {
|
|
289
|
+
await this.ensureStarted();
|
|
290
|
+
if (this.closed || !this.q || !this.input) throw new Error("会话进程已停止,请重试。");
|
|
291
|
+
if (this.state !== "idle" || this.awaitingCommands.size) throw new Error("会话正在运行,稍后再刷新定时任务。");
|
|
292
|
+
const uuid = crypto.randomUUID();
|
|
293
|
+
const text = "系统恢复,请调用一次 CronList 工具,列出当前定时任务。";
|
|
294
|
+
this.silentTurn = true;
|
|
295
|
+
try {
|
|
296
|
+
this.input.push({
|
|
297
|
+
type: "user",
|
|
298
|
+
uuid: uuid as never,
|
|
299
|
+
session_id: this.sessionId,
|
|
300
|
+
parent_tool_use_id: null,
|
|
301
|
+
message: { role: "user", content: text },
|
|
302
|
+
});
|
|
303
|
+
} catch (err) {
|
|
304
|
+
this.silentTurn = false;
|
|
305
|
+
throw err;
|
|
306
|
+
}
|
|
307
|
+
this.awaitingCommands.add(uuid);
|
|
308
|
+
this.emit({ k: "user", uuid, text, ts: Date.now() });
|
|
309
|
+
this.setState("running");
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
private async start() {
|
|
314
|
+
if (this.stopping) await this.stopping;
|
|
315
|
+
if (this.closed) throw new Error("这个会话已被删除。");
|
|
316
|
+
if (this.q) return;
|
|
317
|
+
// e.g. a session of a worktree that has been cleaned up: its history stays readable, but the CLI can't run there.
|
|
318
|
+
try {
|
|
319
|
+
await fs.promises.access(this.cwd);
|
|
320
|
+
} catch {
|
|
321
|
+
throw new Error(`会话的工作目录已不存在(${this.cwd}),无法继续这个会话;它的历史仍可查看。`);
|
|
322
|
+
}
|
|
323
|
+
const existing = await getSessionInfo(this.sessionId, { dir: this.cwd }).catch(() => undefined);
|
|
324
|
+
if (this.closed) throw new Error("这个会话已被删除。");
|
|
325
|
+
this.input = new InputQueue();
|
|
326
|
+
// total_cost_usd stays cumulative across a resume (restored from the jsonl's cost-state).
|
|
327
|
+
this.prevCost = existing ? ((await lastCostState(this.cwd, this.sessionId)) ?? 0) : 0;
|
|
328
|
+
this.costUsd ??= existing ? this.prevCost : undefined;
|
|
329
|
+
const model = config.modelOverride ?? this.model;
|
|
330
|
+
const clearInheritedGoal = await needsInheritedGoalCleanup(this.cwd, this.sessionId);
|
|
331
|
+
this.q = queryImpl({
|
|
332
|
+
prompt: this.input,
|
|
333
|
+
options: {
|
|
334
|
+
cwd: this.cwd,
|
|
335
|
+
...(existing ? { resume: this.sessionId } : { sessionId: this.sessionId }),
|
|
336
|
+
// A pending conversation rewind continues from the kept part of the transcript.
|
|
337
|
+
...(existing && this.rewindAnchor ? { resumeSessionAt: this.rewindAnchor } : {}),
|
|
338
|
+
...(model ? { model } : {}),
|
|
339
|
+
...(this.effort ? { effort: this.effort as never } : {}),
|
|
340
|
+
permissionMode: this.planMode ? "plan" : "bypassPermissions",
|
|
341
|
+
allowDangerouslySkipPermissions: true,
|
|
342
|
+
includePartialMessages: true,
|
|
343
|
+
settingSources: ["user", "project", "local"],
|
|
344
|
+
enableFileCheckpointing: true,
|
|
345
|
+
perTaskStopAffordance: true,
|
|
346
|
+
mcpServers: { IDECron: ideCronServer(this.sessionId, () => this.cwd), IDEMessages: ideMessagesServer(this.sessionId) },
|
|
347
|
+
canUseTool: this.canUseTool,
|
|
348
|
+
// The Stop hook is where the CLI reports scheduled tasks (/loop, CronCreate) and background tasks.
|
|
349
|
+
hooks: {
|
|
350
|
+
// Re-evaluate at prompt time, including after resume or promotion.
|
|
351
|
+
// A fixed startup system prompt would retain the old role in a live CLI.
|
|
352
|
+
UserPromptSubmit: [{ hooks: [async () => {
|
|
353
|
+
const row = db.prepare("SELECT btw_parent, btw_prompt_uuid, btw_question FROM session_view WHERE session_id=?").get(this.sessionId) as { btw_parent: string | null; btw_prompt_uuid: string | null; btw_question: string | null } | undefined;
|
|
354
|
+
const context = conversationInstructions(!!row?.btw_parent, !!(row?.btw_prompt_uuid || row?.btw_question), this.writeEnabled);
|
|
355
|
+
return context ? { hookSpecificOutput: { hookEventName: "UserPromptSubmit" as const, additionalContext: context } } : {};
|
|
356
|
+
}] }],
|
|
357
|
+
// Runs for main and child agent tools, including bypassPermissions.
|
|
358
|
+
// Read the live flag on each check; never capture its startup value.
|
|
359
|
+
PreToolUse: [{ hooks: [async (input) => {
|
|
360
|
+
const denied = input.hook_event_name === "PreToolUse" ? this.toolDenial(input.tool_name) : undefined;
|
|
361
|
+
return denied ? { hookSpecificOutput: { hookEventName: "PreToolUse" as const, permissionDecision: "deny" as const, permissionDecisionReason: denied } } : {};
|
|
362
|
+
}] }],
|
|
363
|
+
Stop: [
|
|
364
|
+
{
|
|
365
|
+
hooks: [
|
|
366
|
+
async (input) => {
|
|
367
|
+
const i = input as { session_crons?: { id: string; schedule: string; recurring: boolean; prompt: string }[]; background_tasks?: { id: string; type: string; status: string; description: string; command?: string }[] };
|
|
368
|
+
if (i.session_crons) this.setCrons(i.session_crons);
|
|
369
|
+
if (i.background_tasks) {
|
|
370
|
+
const byId = new Map(this.tasks.map((t) => [t.id, t]));
|
|
371
|
+
for (const t of i.background_tasks) byId.set(t.id, { id: t.id, type: t.type, description: t.description, status: t.status, command: t.command });
|
|
372
|
+
this.setTasks([...byId.values()].slice(-30));
|
|
373
|
+
}
|
|
374
|
+
return {};
|
|
375
|
+
},
|
|
376
|
+
],
|
|
377
|
+
},
|
|
378
|
+
],
|
|
379
|
+
},
|
|
380
|
+
// A non-SDK entrypoint keeps web sessions in the CLI's /resume list (see NOTES.md).
|
|
381
|
+
env: childEnv({
|
|
382
|
+
CLAUDE_CODE_ENTRYPOINT: "claude-ide",
|
|
383
|
+
// Task tools (the todo list) are off in SDK mode unless asked for.
|
|
384
|
+
CLAUDE_CODE_ENABLE_TODO_TOOLS: "1",
|
|
385
|
+
CLAUDE_CODE_EMIT_SESSION_STATE_EVENTS: "1",
|
|
386
|
+
}),
|
|
387
|
+
},
|
|
388
|
+
});
|
|
389
|
+
this.pumpDone = this.pump(this.q);
|
|
390
|
+
// Extra directories (/add-dir) go through the flag layer so they can also be removed live.
|
|
391
|
+
await this.setAdditionalDirectories(sessionDirs(this.sessionId));
|
|
392
|
+
if (clearInheritedGoal) {
|
|
393
|
+
// forkSession copies goal_status attachments. Clear the CLI's restored
|
|
394
|
+
// Stop goal before the question, without editing the parent's transcript
|
|
395
|
+
// or involving the model. A durable marker survives promotion/resume.
|
|
396
|
+
const uuid = crypto.randomUUID();
|
|
397
|
+
this.inheritedGoalCommand = uuid;
|
|
398
|
+
this.input.push({ type: "user", uuid: uuid as never, session_id: this.sessionId, parent_tool_use_id: null,
|
|
399
|
+
message: { role: "user", content: "/goal clear" }, shouldQuery: false });
|
|
400
|
+
this.awaitingCommands.add(uuid);
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/** /add-dir: the session's extra directories, applied to the running CLI process (see server/add-dirs.ts). */
|
|
405
|
+
async setAdditionalDirectories(dirs: string[]) {
|
|
406
|
+
if (!this.q) return; // applied when the process starts
|
|
407
|
+
const applied = this.appliedDirs;
|
|
408
|
+
this.appliedDirs = dirs;
|
|
409
|
+
if ((applied === undefined && dirs.length === 0) || JSON.stringify(applied) === JSON.stringify(dirs)) return;
|
|
410
|
+
await this.q.applyFlagSettings({ permissions: { additionalDirectories: dirs } } as never).catch((err: unknown) => {
|
|
411
|
+
this.emit({ k: "error", message: `额外目录没能生效:${err instanceof Error ? err.message : String(err)}` });
|
|
412
|
+
});
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
private async pump(q: Query) {
|
|
416
|
+
try {
|
|
417
|
+
for await (const m of q) {
|
|
418
|
+
if (this.q === q) this.handle(m);
|
|
419
|
+
}
|
|
420
|
+
} catch (err) {
|
|
421
|
+
this.emit({ k: "error", message: err instanceof Error ? err.message : String(err) });
|
|
422
|
+
} finally {
|
|
423
|
+
if (this.q === q) {
|
|
424
|
+
// The CLI went away by itself mid-turn (crash, killed): that turn failed.
|
|
425
|
+
if (this.state !== "idle") this.lastTurnFailed = true;
|
|
426
|
+
this.q = undefined;
|
|
427
|
+
this.appliedDirs = undefined;
|
|
428
|
+
this.input = undefined;
|
|
429
|
+
this.awaitingCommands.clear();
|
|
430
|
+
this.inheritedGoalCommand = undefined;
|
|
431
|
+
this.contextAppendCommand = undefined;
|
|
432
|
+
this.endAgentTasks();
|
|
433
|
+
this.dropPending();
|
|
434
|
+
this.setState("idle");
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
private handle(m: SDKMessage) {
|
|
440
|
+
const initializationCommand = this.inheritedGoalCommand ?? this.contextAppendCommand;
|
|
441
|
+
if (initializationCommand) {
|
|
442
|
+
// Initialization runs ahead of the question without querying the model.
|
|
443
|
+
// Its local result must not finish the question or produce a toast.
|
|
444
|
+
if (m.type === "result" && m.num_turns === 0 && (m.user_message_uuids?.includes(initializationCommand) || !m.user_message_uuids?.length)) {
|
|
445
|
+
const commandUuid = initializationCommand;
|
|
446
|
+
const clearingGoal = commandUuid === this.inheritedGoalCommand;
|
|
447
|
+
this.awaitingCommands.delete(commandUuid);
|
|
448
|
+
if (clearingGoal) this.inheritedGoalCommand = undefined;
|
|
449
|
+
else this.contextAppendCommand = undefined;
|
|
450
|
+
if (m.is_error || m.subtype !== "success") {
|
|
451
|
+
this.emit({ k: "error", message: clearingGoal ? "旁支会话的继承目标未能清除,请重试。" : "旁支会话的历史边界未能写入,请重试。" });
|
|
452
|
+
this.input?.close();
|
|
453
|
+
this.q?.close();
|
|
454
|
+
} else if (clearingGoal) markGoalIsolated(this.sessionId, commandUuid);
|
|
455
|
+
return;
|
|
456
|
+
}
|
|
457
|
+
if ((m.type === "system" && m.subtype === "session_state_changed")
|
|
458
|
+
|| (m.type === "assistant" && m.message.model === "<synthetic>")) return;
|
|
459
|
+
}
|
|
460
|
+
// The SDK omits timestamp on some live emitters; give every displayable frame one
|
|
461
|
+
// receive-time stamp so the transcript and its frozen history agree.
|
|
462
|
+
if (m.type === "user" || m.type === "assistant" || m.type === "result" || m.type === "system") {
|
|
463
|
+
const stamped = m as unknown as { timestamp?: unknown };
|
|
464
|
+
if (typeof stamped.timestamp !== "string") stamped.timestamp = new Date().toISOString();
|
|
465
|
+
}
|
|
466
|
+
this.trackAgentTasks(m as SdkMessageLike);
|
|
467
|
+
// Complete text blocks only: partial deltas and the later result must not be pushed again.
|
|
468
|
+
// SDK frame uuid (not message.id) distinguishes consecutive blocks of one API message.
|
|
469
|
+
if (m.type === "assistant" && !m.parent_tool_use_id && !m.error && !m.aborted && m.message.model !== "<synthetic>") {
|
|
470
|
+
m.message.content.forEach((block, index) => {
|
|
471
|
+
if (block.type === "text" && block.text) this.onTextPart({ id: `${m.uuid}:${index}`, sessionId: this.sessionId, text: block.text });
|
|
472
|
+
});
|
|
473
|
+
}
|
|
474
|
+
// Terminal lifecycle frames also cover no-model commands (!bash and slash commands).
|
|
475
|
+
const lifecycle = m as unknown as { type: string; state?: string; command_uuid?: string };
|
|
476
|
+
if (lifecycle.type === "command_lifecycle" && ["completed", "cancelled"].includes(lifecycle.state ?? "") && lifecycle.command_uuid) {
|
|
477
|
+
this.awaitingCommands.delete(lifecycle.command_uuid);
|
|
478
|
+
this.maybeReloadConfig();
|
|
479
|
+
}
|
|
480
|
+
if (m.type === "stream_event") return this.handleStreamEvent(m);
|
|
481
|
+
if (m.type === "system") {
|
|
482
|
+
if (m.subtype === "session_state_changed") return this.setState(m.state);
|
|
483
|
+
if (m.subtype === "init") noteTerminalCommands(m.terminal_slash_commands);
|
|
484
|
+
if (m.subtype === "background_tasks_changed") {
|
|
485
|
+
// The full set of live tasks each time. Tasks that left it are kept (finished) so their output stays reachable.
|
|
486
|
+
const live = new Map(m.tasks.map((t) => [t.task_id, t]));
|
|
487
|
+
const merged: BgTask[] = this.tasks.map((t) => (live.has(t.id) || t.status !== "running" ? t : { ...t, status: "completed" }));
|
|
488
|
+
for (const t of m.tasks) {
|
|
489
|
+
if (!merged.some((x) => x.id === t.task_id)) merged.push({ id: t.task_id, type: t.task_type, description: t.description, status: "running" });
|
|
490
|
+
}
|
|
491
|
+
this.setTasks(merged.slice(-30));
|
|
492
|
+
}
|
|
493
|
+
if ((m.subtype === "task_updated" || m.subtype === "task_notification") && this.tasks.some((t) => t.id === m.task_id)) {
|
|
494
|
+
const status = m.subtype === "task_updated" ? m.patch?.status : m.status;
|
|
495
|
+
if (status) this.setTasks(this.tasks.map((t) => (t.id === m.task_id ? { ...t, status } : t)));
|
|
496
|
+
}
|
|
497
|
+
if (DROP_SYSTEM.has(m.subtype)) return;
|
|
498
|
+
}
|
|
499
|
+
if (m.type === "result") {
|
|
500
|
+
for (const uuid of m.user_message_uuids ?? []) this.awaitingCommands.delete(uuid);
|
|
501
|
+
this.maybeReloadConfig();
|
|
502
|
+
// A failed turn (API error, max turns…) notifies as an error; a turn the user interrupted does not.
|
|
503
|
+
this.lastTurnFailed = (m.is_error || m.subtype !== "success") && !this.userInterrupted;
|
|
504
|
+
this.userInterrupted = false;
|
|
505
|
+
this.resultThisTurn = true;
|
|
506
|
+
this.fireDeferredIdle();
|
|
507
|
+
let turnCost: number | undefined;
|
|
508
|
+
if (typeof m.total_cost_usd === "number") {
|
|
509
|
+
this.costUsd = m.total_cost_usd;
|
|
510
|
+
if (this.prevCost !== undefined) turnCost = Math.max(0, m.total_cost_usd - this.prevCost);
|
|
511
|
+
this.prevCost = m.total_cost_usd;
|
|
512
|
+
}
|
|
513
|
+
this.onChange();
|
|
514
|
+
// The CLI writes the session title about a second after the turn ends.
|
|
515
|
+
setTimeout(this.onChange, 1500);
|
|
516
|
+
this.emit({ k: "sdk", m: { ...(m as SdkMessageLike), turn_cost_usd: turnCost } });
|
|
517
|
+
return;
|
|
518
|
+
}
|
|
519
|
+
// Our own prompts are echoed (isReplay) — clients already show them.
|
|
520
|
+
// Except CLI-generated output in that echo, e.g. a skill whose `!command` preamble failed
|
|
521
|
+
// (/security-review without origin/HEAD) — the only explanation the user gets [实测].
|
|
522
|
+
if (m.type === "user" && "isReplay" in m && m.isReplay && !isLocalCommandOutput(m.message?.content)) return;
|
|
523
|
+
this.emit({ k: "sdk", m: m as never });
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
private trackAgentTasks(m: SdkMessageLike) {
|
|
527
|
+
let changed = false;
|
|
528
|
+
const put = (task: AgentTask) => {
|
|
529
|
+
if (JSON.stringify(this.agentTasks.get(task.toolUseId)) === JSON.stringify(task)) return;
|
|
530
|
+
this.agentTasks.delete(task.toolUseId);
|
|
531
|
+
this.agentTasks.set(task.toolUseId, task);
|
|
532
|
+
changed = true;
|
|
533
|
+
};
|
|
534
|
+
// message_start is not forwarded as a transcript event. Keep its actual
|
|
535
|
+
// model in bounded task metadata so it also survives replay eviction.
|
|
536
|
+
const model = responseModel(m);
|
|
537
|
+
const owner = m.parent_tool_use_id ? this.agentTasks.get(m.parent_tool_use_id) : undefined;
|
|
538
|
+
if (model && owner) put({ ...owner, model });
|
|
539
|
+
const blocks = (m.message as { content?: unknown })?.content;
|
|
540
|
+
if (m.type === "assistant" && Array.isArray(blocks)) {
|
|
541
|
+
for (const block of blocks as { type: string; id: string; name?: string; input?: Record<string, unknown> }[]) {
|
|
542
|
+
if (block.type !== "tool_use" || !["Agent", "Task"].includes(block.name ?? "")) continue;
|
|
543
|
+
if (this.agentTasks.has(block.id)) continue;
|
|
544
|
+
const input = block.input ?? {};
|
|
545
|
+
put({ toolUseId: block.id, ...(m.parent_tool_use_id ? { parentToolUseId: m.parent_tool_use_id } : {}), status: "running", description: String(input.description ?? "").slice(0, 512), agentType: String(input.subagent_type ?? "").slice(0, 128), background: !!input.run_in_background });
|
|
546
|
+
}
|
|
547
|
+
} else if (m.type === "user" && Array.isArray(blocks)) {
|
|
548
|
+
for (const block of blocks as { type: string; tool_use_id: string; is_error?: boolean }[]) {
|
|
549
|
+
const task = this.agentTasks.get(block.tool_use_id);
|
|
550
|
+
if (block.type === "tool_result" && task && (!task.background || block.is_error)) put({ ...task, status: block.is_error ? "failed" : "completed" });
|
|
551
|
+
}
|
|
552
|
+
} else if (m.type === "result") {
|
|
553
|
+
for (const task of this.agentTasks.values()) {
|
|
554
|
+
const parent = task.parentToolUseId ?? (task.parentTaskId
|
|
555
|
+
? [...this.agentTasks.values()].find((candidate) => candidate.taskId === task.parentTaskId)?.toolUseId
|
|
556
|
+
: undefined);
|
|
557
|
+
// A main result cannot settle work owned by an independently running agent.
|
|
558
|
+
// An unresolved parent task is still nested, even if its tool call was evicted.
|
|
559
|
+
if (task.background || (parent ?? (task.parentTaskId ? "unknown-parent" : null)) !== (m.parent_tool_use_id ?? null) || !["pending", "running", "paused"].includes(task.status)) continue;
|
|
560
|
+
put({ ...task, status: this.userInterrupted ? "stopped" : m.is_error || m.subtype !== "success" ? "failed" : "completed" });
|
|
561
|
+
}
|
|
562
|
+
} else if (m.type === "system" && m.subtype?.startsWith("task_")) {
|
|
563
|
+
const existing = typeof m.tool_use_id === "string" ? this.agentTasks.get(m.tool_use_id)
|
|
564
|
+
: typeof m.task_id === "string" ? currentAgentTask(this.agentTasks.values(), m.task_id) : undefined;
|
|
565
|
+
const isAgent = existing || isSubagentTaskType(m.task_type) || m.subagent_type !== undefined;
|
|
566
|
+
const toolUseId = existing?.toolUseId ?? (typeof m.tool_use_id === "string" ? m.tool_use_id : undefined);
|
|
567
|
+
if (isAgent && toolUseId) {
|
|
568
|
+
const runId = typeof m.run_id === "string" ? m.run_id : undefined;
|
|
569
|
+
if (existing?.runId && runId && runId < existing.runId) return;
|
|
570
|
+
const patch = m.patch as Record<string, unknown> | undefined;
|
|
571
|
+
const status = m.status ?? patch?.status ?? (m.subtype === "task_started" ? "running" : existing?.status ?? "running");
|
|
572
|
+
const description = m.description ?? patch?.description;
|
|
573
|
+
put({ ...existing, toolUseId, taskId: typeof m.task_id === "string" ? m.task_id : existing?.taskId,
|
|
574
|
+
runId: runId ?? existing?.runId, status: status as AgentTask["status"],
|
|
575
|
+
...(typeof m.parent_task_id === "string" ? { parentTaskId: m.parent_task_id } : {}),
|
|
576
|
+
...(m.parent_tool_use_id ? { parentToolUseId: m.parent_tool_use_id } : {}),
|
|
577
|
+
...(description !== undefined ? { description: String(description).slice(0, 512) } : {}),
|
|
578
|
+
...(m.subagent_type !== undefined ? { agentType: String(m.subagent_type).slice(0, 128) } : {}),
|
|
579
|
+
...(typeof (m.is_backgrounded ?? patch?.is_backgrounded) === "boolean" ? { background: (m.is_backgrounded ?? patch?.is_backgrounded) as boolean } : {}) });
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
while (this.agentTasks.size > 128) {
|
|
583
|
+
const terminal = [...this.agentTasks.values()].find((task) => !["pending", "running", "paused"].includes(task.status));
|
|
584
|
+
this.agentTasks.delete(terminal?.toolUseId ?? this.agentTasks.keys().next().value!);
|
|
585
|
+
}
|
|
586
|
+
if (changed) this.emit({ k: "agent_tasks", tasks: [...this.agentTasks.values()] });
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
private endAgentTasks() {
|
|
590
|
+
this.flushDeltas();
|
|
591
|
+
let changed = false;
|
|
592
|
+
for (const [id, task] of this.agentTasks) {
|
|
593
|
+
if (!["pending", "running", "paused"].includes(task.status)) continue;
|
|
594
|
+
this.agentTasks.set(id, { ...task, status: "stopped" });
|
|
595
|
+
changed = true;
|
|
596
|
+
}
|
|
597
|
+
this.streamingReplay.clear();
|
|
598
|
+
if (changed) this.emit({ k: "agent_tasks", tasks: [...this.agentTasks.values()] });
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
private handleStreamEvent(m: Extract<SDKMessage, { type: "stream_event" }>) {
|
|
602
|
+
const e = m.event;
|
|
603
|
+
const parent = m.parent_tool_use_id;
|
|
604
|
+
if (e.type === "message_start") {
|
|
605
|
+
this.currentMsgId.set(parent, e.message.id);
|
|
606
|
+
return;
|
|
607
|
+
}
|
|
608
|
+
const msgId = this.currentMsgId.get(parent);
|
|
609
|
+
if (!msgId) return;
|
|
610
|
+
if (e.type === "content_block_start") {
|
|
611
|
+
const t = e.content_block.type;
|
|
612
|
+
this.blockKinds.set(`${msgId}:${e.index}`, t === "text" ? "text" : t === "thinking" ? "thinking" : "other");
|
|
613
|
+
return;
|
|
614
|
+
}
|
|
615
|
+
if (e.type !== "content_block_delta") return;
|
|
616
|
+
let kind: "text" | "thinking";
|
|
617
|
+
let text: string;
|
|
618
|
+
if (e.delta.type === "text_delta") {
|
|
619
|
+
kind = "text";
|
|
620
|
+
text = e.delta.text;
|
|
621
|
+
} else if (e.delta.type === "thinking_delta") {
|
|
622
|
+
kind = "thinking";
|
|
623
|
+
text = e.delta.thinking;
|
|
624
|
+
} else return;
|
|
625
|
+
const key = `${msgId}:${e.index}`;
|
|
626
|
+
const buf = this.deltaBuf.get(key);
|
|
627
|
+
if (buf) buf.text += text;
|
|
628
|
+
else this.deltaBuf.set(key, { k: "delta", msgId, index: e.index, kind, text, parent });
|
|
629
|
+
this.deltaTimer ??= setTimeout(() => this.flushDeltas(), DELTA_FLUSH_MS);
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
private flushDeltas() {
|
|
633
|
+
if (this.deltaTimer) clearTimeout(this.deltaTimer);
|
|
634
|
+
this.deltaTimer = undefined;
|
|
635
|
+
if (this.deltaBuf.size === 0) return;
|
|
636
|
+
const bufs = [...this.deltaBuf.values()];
|
|
637
|
+
this.deltaBuf.clear();
|
|
638
|
+
for (const d of bufs) this.emit(d);
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
private toolDenial(toolName: string): string | undefined {
|
|
642
|
+
if (!this.writeEnabled && !READ_ONLY_TOOLS.has(toolName)) return WRITES_DISABLED;
|
|
643
|
+
if (SIDE_AGENT_TOOLS.has(toolName) && db.prepare("SELECT 1 FROM session_view WHERE session_id=? AND btw_parent IS NOT NULL").get(this.sessionId)) {
|
|
644
|
+
return "btw 旁支只用于问答和轻量探索,不能创建或操作子代理,也不能向其他会话发送消息。需要执行任务时,请先转为普通会话。";
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
private canUseTool: CanUseTool = (toolName, input, { signal, toolUseID }) => {
|
|
649
|
+
const denied = this.toolDenial(toolName);
|
|
650
|
+
if (denied) return Promise.resolve({ behavior: "deny", message: denied });
|
|
651
|
+
let req: PendingRequest;
|
|
652
|
+
const id = crypto.randomUUID();
|
|
653
|
+
if (toolName === "AskUserQuestion") {
|
|
654
|
+
if (!this.askQuestionsEnabled) return Promise.resolve({ behavior: "deny", message: QUESTIONS_DISABLED });
|
|
655
|
+
req = { id, kind: "ask", toolUseId: toolUseID, input: input as never };
|
|
656
|
+
} else if (toolName === "ExitPlanMode") {
|
|
657
|
+
req = { id, kind: "plan", toolUseId: toolUseID, input: { plan: String(input.plan ?? "") } };
|
|
658
|
+
} else {
|
|
659
|
+
// Only bypassPermissions/plan are offered; never prompt for ordinary tools.
|
|
660
|
+
return Promise.resolve({ behavior: "allow", updatedInput: input });
|
|
661
|
+
}
|
|
662
|
+
return new Promise<PermissionResult>((resolve) => {
|
|
663
|
+
this.pending.set(id, { req, resolve, input });
|
|
664
|
+
signal.addEventListener("abort", () => this.resolvePending(id, { kind: "cancel", message: "Aborted" }));
|
|
665
|
+
this.emit({ k: "pending", req });
|
|
666
|
+
this.onChange();
|
|
667
|
+
});
|
|
668
|
+
};
|
|
669
|
+
|
|
670
|
+
resolvePending(id: string, answer: PendingAnswer): boolean {
|
|
671
|
+
const p = this.pending.get(id);
|
|
672
|
+
if (!p) return false;
|
|
673
|
+
this.pending.delete(id);
|
|
674
|
+
if (answer.kind === "ask") {
|
|
675
|
+
p.resolve({ behavior: "allow", updatedInput: { ...p.input, answers: answer.answers } });
|
|
676
|
+
} else if (answer.kind === "plan" && answer.approve) {
|
|
677
|
+
p.resolve({ behavior: "allow", updatedInput: p.input });
|
|
678
|
+
this.planMode = false;
|
|
679
|
+
void this.q?.setPermissionMode("bypassPermissions").catch(() => {});
|
|
680
|
+
this.emitSettings();
|
|
681
|
+
} else if (answer.kind === "plan") {
|
|
682
|
+
p.resolve({ behavior: "deny", message: answer.feedback || "The user rejected the plan." });
|
|
683
|
+
} else {
|
|
684
|
+
p.resolve({ behavior: "deny", message: answer.message ?? "The user dismissed this request." });
|
|
685
|
+
}
|
|
686
|
+
this.emit({ k: "resolved", id });
|
|
687
|
+
this.onChange();
|
|
688
|
+
return true;
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
private emitSettings() {
|
|
692
|
+
this.emit({ k: "settings", model: this.model, effort: this.effort, planMode: this.planMode, askQuestionsEnabled: this.askQuestionsEnabled, writeEnabled: this.writeEnabled });
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
/** Optional context is a visible user message appended before this prompt, without its own model turn. */
|
|
696
|
+
async send(uuid: string, text: string, images: ImageInput[] = [], precedingText?: string) {
|
|
697
|
+
return this.operate(() => this.sendLocked(uuid, text, images, undefined, precedingText));
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
/** Deliver an IDECron task. `next_turn` leaves the row due until the session is idle. */
|
|
701
|
+
async sendScheduled(uuid: string, text: string, mode: IdeCronMode): Promise<boolean> {
|
|
702
|
+
return this.operate(async () => {
|
|
703
|
+
if (this.closed) throw new Error("这个会话已被删除。");
|
|
704
|
+
if (this.rewindAnchor) return false;
|
|
705
|
+
if (mode === "next_turn" && (this.state !== "idle" || this.awaitingCommands.size || this.pending.size)) return false;
|
|
706
|
+
if (mode === "interrupt" && this.state !== "idle") await this.q?.interrupt().catch(() => {});
|
|
707
|
+
const priority = mode === "steer" && this.state !== "idle" ? "next" as const : undefined;
|
|
708
|
+
return this.sendLocked(uuid, text, [], undefined, undefined, priority, true);
|
|
709
|
+
});
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
get queuedMessageCount() { return this.awaitingCommands.size; }
|
|
713
|
+
|
|
714
|
+
/** HTTP subscription messages share the live query but are never CLI slash commands. */
|
|
715
|
+
async sendExternalText(uuid: string, text: string, stillActive: () => boolean) {
|
|
716
|
+
return this.operate(() => this.sendLocked(uuid, text, [], stillActive));
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
/** Peer messages cannot consume a pending user rewind or grow an unbounded queue.
|
|
720
|
+
* Keep these checks inside the operation lock and repeat after asynchronous start. */
|
|
721
|
+
async sendPeerText(uuid: string, text: string, stillActive: () => boolean): Promise<boolean> {
|
|
722
|
+
return this.operate(() => this.sendLocked(uuid, text, [], () => {
|
|
723
|
+
if (!stillActive()) return false;
|
|
724
|
+
if (this.rewindAnchor) throw new Error("目标会话有待处理的回退,请先在该会话确认输入。");
|
|
725
|
+
if (this.awaitingCommands.size >= 32) throw new Error("目标会话的消息队列已满,请稍后重试。");
|
|
726
|
+
return true;
|
|
727
|
+
}));
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
private async sendLocked(uuid: string, text: string, images: ImageInput[], stillActive?: () => boolean, precedingText?: string, priority?: "next", scheduled?: boolean) {
|
|
731
|
+
if (stillActive && !stillActive()) return false;
|
|
732
|
+
const content: SDKUserMessage["message"]["content"] = [
|
|
733
|
+
...images.map((img) => ({
|
|
734
|
+
type: "image" as const,
|
|
735
|
+
source: { type: "base64" as const, media_type: img.mediaType as "image/png", data: img.data },
|
|
736
|
+
})),
|
|
737
|
+
...(text ? [{ type: "text" as const, text }] : []),
|
|
738
|
+
];
|
|
739
|
+
let input: InputQueue;
|
|
740
|
+
try {
|
|
741
|
+
await this.ensureStarted();
|
|
742
|
+
if (this.closed || !this.q || !this.input) throw new Error("会话进程已停止,请重试。");
|
|
743
|
+
input = this.input;
|
|
744
|
+
} catch (err) {
|
|
745
|
+
if (stillActive) throw err;
|
|
746
|
+
this.emit({ k: "error", message: err instanceof Error ? err.message : String(err) });
|
|
747
|
+
return false;
|
|
748
|
+
}
|
|
749
|
+
if (stillActive && !stillActive()) return false;
|
|
750
|
+
if (precedingText) {
|
|
751
|
+
const contextUuid = crypto.randomUUID();
|
|
752
|
+
this.contextAppendCommand = contextUuid;
|
|
753
|
+
input.push({ type: "user", uuid: contextUuid as never, session_id: this.sessionId, parent_tool_use_id: null,
|
|
754
|
+
message: { role: "user", content: precedingText }, shouldQuery: false });
|
|
755
|
+
this.awaitingCommands.add(contextUuid);
|
|
756
|
+
this.emit({ k: "user", uuid: contextUuid, text: precedingText, ts: Date.now() });
|
|
757
|
+
}
|
|
758
|
+
input.push({
|
|
759
|
+
type: "user",
|
|
760
|
+
uuid: uuid as never,
|
|
761
|
+
session_id: this.sessionId,
|
|
762
|
+
parent_tool_use_id: null,
|
|
763
|
+
message: { role: "user", content },
|
|
764
|
+
...(priority ? { client_composed: true as const, priority } : stillActive ? { client_composed: true as const, priority: "next" as const } : scheduled ? { client_composed: true as const } : {}),
|
|
765
|
+
});
|
|
766
|
+
this.awaitingCommands.add(uuid);
|
|
767
|
+
this.emit({ k: "user", uuid, text, images: images.length || undefined, ts: Date.now(), imageData: images.length ? images : undefined, ...(scheduled ? { scheduled: true } : {}) });
|
|
768
|
+
this.setState("running");
|
|
769
|
+
// The rewound branch now exists in the transcript; the anchor has done its job.
|
|
770
|
+
if (this.rewindAnchor) {
|
|
771
|
+
db.prepare("UPDATE session_view SET rewind_anchor = NULL, rewind_text = NULL, rewind_id = NULL WHERE session_id = ?").run(this.sessionId);
|
|
772
|
+
}
|
|
773
|
+
return true;
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
private stopping?: Promise<void>;
|
|
777
|
+
/** Deleted: never start a CLI process for it again. */
|
|
778
|
+
private closed = false;
|
|
779
|
+
|
|
780
|
+
/** Stop for good (session deleted): pending and future starts are refused. */
|
|
781
|
+
async shutdown() {
|
|
782
|
+
this.closed = true;
|
|
783
|
+
clearTimeout(this.deferredIdleTimer);
|
|
784
|
+
this.deferredIdleFrom = undefined;
|
|
785
|
+
await this.stop();
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
/** Stop the CLI process (if any) and wait until its output stream has ended. */
|
|
789
|
+
async stop() {
|
|
790
|
+
return this.operate(() => this.stopLocked());
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
private async stopLocked() {
|
|
794
|
+
if (this.stopping) return this.stopping;
|
|
795
|
+
this.stopping = this.doStop().finally(() => (this.stopping = undefined));
|
|
796
|
+
return this.stopping;
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
private async doStop() {
|
|
800
|
+
// A start in progress would otherwise create its CLI process after we "stopped".
|
|
801
|
+
if (this.starting) await this.starting.catch(() => {});
|
|
802
|
+
const q = this.q;
|
|
803
|
+
if (!q) return;
|
|
804
|
+
const done = this.pumpDone;
|
|
805
|
+
if (this.state !== "idle") await q.interrupt().catch(() => {});
|
|
806
|
+
this.input?.close();
|
|
807
|
+
q.close();
|
|
808
|
+
this.q = undefined;
|
|
809
|
+
this.input = undefined;
|
|
810
|
+
this.appliedDirs = undefined;
|
|
811
|
+
this.awaitingCommands.clear();
|
|
812
|
+
this.inheritedGoalCommand = undefined;
|
|
813
|
+
this.contextAppendCommand = undefined;
|
|
814
|
+
this.endAgentTasks();
|
|
815
|
+
await done;
|
|
816
|
+
// Questions/plans the old process was waiting on can no longer be answered.
|
|
817
|
+
this.dropPending();
|
|
818
|
+
this.setState("idle");
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
/**
|
|
822
|
+
* Free an idle CLI process (each holds ~200 MB). Kept while anything lives in it: a question
|
|
823
|
+
* waiting for the user, scheduled tasks, running background work. Resume restores the rest.
|
|
824
|
+
*/
|
|
825
|
+
reapIfIdle(maxIdleMs: number): boolean {
|
|
826
|
+
if (!this.q || this.starting || this.stopping || this.state !== "idle") return false;
|
|
827
|
+
if (this.awaitingCommands.size || this.pending.size || this.hasScheduledWork() || this.tasks.some((t) => t.status === "running")) return false;
|
|
828
|
+
if (Date.now() - this.lastActivity < maxIdleMs) return false;
|
|
829
|
+
void this.operate(async () => {
|
|
830
|
+
// A send may have arrived since the reaper inspected this session.
|
|
831
|
+
if (this.state === "idle" && !this.awaitingCommands.size && !this.pending.size && !this.hasScheduledWork() && !this.tasks.some((t) => t.status === "running") && Date.now() - this.lastActivity >= maxIdleMs) await this.stopLocked();
|
|
832
|
+
});
|
|
833
|
+
return true;
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
private dropPending() {
|
|
837
|
+
for (const [id, p] of this.pending) {
|
|
838
|
+
p.resolve({ behavior: "deny", message: "Session ended" });
|
|
839
|
+
this.emit({ k: "resolved", id });
|
|
840
|
+
}
|
|
841
|
+
this.pending.clear();
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
/** Tell every viewer to drop live events and reload history. */
|
|
845
|
+
private resetView() {
|
|
846
|
+
this.agentTasks.clear();
|
|
847
|
+
this.events.length = 0;
|
|
848
|
+
this.historyRevision = crypto.randomUUID();
|
|
849
|
+
this.emit({ k: "reset", historyRevision: this.historyRevision });
|
|
850
|
+
this.events.length = 0;
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
/** Rewind the conversation so the next message continues after `anchor` (undefined clears it). */
|
|
854
|
+
async rewindConversation(projectPath: string, anchor: string, text: string, rewindId = crypto.randomUUID()) {
|
|
855
|
+
return this.operate(() => this.rewindConversationLocked(projectPath, anchor, text, rewindId));
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
private async rewindConversationLocked(projectPath: string, anchor: string, text: string, rewindId: string) {
|
|
859
|
+
await this.stopLocked();
|
|
860
|
+
db.prepare(
|
|
861
|
+
`INSERT INTO session_view (session_id, project_path, rewind_anchor, rewind_text, rewind_id) VALUES (?, ?, ?, ?, ?)
|
|
862
|
+
ON CONFLICT(session_id) DO UPDATE SET rewind_anchor = excluded.rewind_anchor, rewind_text = excluded.rewind_text, rewind_id = excluded.rewind_id`,
|
|
863
|
+
).run(this.sessionId, projectPath, anchor, text, rewindId);
|
|
864
|
+
this.resetView();
|
|
865
|
+
this.onChange();
|
|
866
|
+
return rewindId;
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
/** Restore files changed by Edit/Write since `userMessageUuid` was sent (dryRun only previews). */
|
|
870
|
+
async rewindFiles(userMessageUuid: string, dryRun: boolean) {
|
|
871
|
+
return this.operate(() => this.rewindFilesLocked(userMessageUuid, dryRun));
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
private async rewindFilesLocked(userMessageUuid: string, dryRun: boolean) {
|
|
875
|
+
if (this.rewindAnchor && this.q) await this.stopLocked();
|
|
876
|
+
await this.ensureStarted();
|
|
877
|
+
return this.q!.rewindFiles(userMessageUuid, { dryRun });
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
async exportText(): Promise<{ text: string; filename: string }> {
|
|
881
|
+
return this.operate(() => this.exportTextLocked());
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
private async exportTextLocked() {
|
|
885
|
+
await this.ensureStarted();
|
|
886
|
+
const r = (await (this.q as unknown as { exportConversation(): Promise<{ text: string; default_filename?: string }> }).exportConversation());
|
|
887
|
+
return { text: r.text, filename: r.default_filename ?? `${this.sessionId}.txt` };
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
/** Rename through the running CLI so its cached title does not overwrite ours later. */
|
|
891
|
+
/** /cd: move the running CLI (and its transcript) to another directory. */
|
|
892
|
+
async changeDir(dir: string, commit?: () => void) {
|
|
893
|
+
return this.operate(() => this.changeDirLocked(dir, commit));
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
private async changeDirLocked(dir: string, commit?: () => void) {
|
|
897
|
+
await this.ensureStarted();
|
|
898
|
+
type SetCwd = (p: string, o?: { trustAccepted?: boolean; trustedDirectory?: string }) => Promise<Record<string, unknown> | undefined>;
|
|
899
|
+
const setCwd = (this.q as unknown as { setCwd: SetCwd }).setCwd.bind(this.q);
|
|
900
|
+
// Two steps, like the CLI's trust dialog: an untrusted directory answers needs_trust with the directory
|
|
901
|
+
// to trust; the user chose it explicitly with /cd, so accept and echo it back.
|
|
902
|
+
const first = await setCwd(dir);
|
|
903
|
+
const trustDir = (first?.trusted_directory ?? first?.trustedDirectory) as string | undefined;
|
|
904
|
+
const result = first?.needs_trust || first?.status === "needs_trust" || trustDir
|
|
905
|
+
? await setCwd(dir, { trustAccepted: true, trustedDirectory: trustDir ?? dir }) : first;
|
|
906
|
+
if (result?.error || result?.success === false || result?.needs_trust || ["error", "needs_trust"].includes(String(result?.status))) throw new Error(String(result?.error ?? "CLI 未能切换目录。"));
|
|
907
|
+
this.cwd = dir;
|
|
908
|
+
commit?.();
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
/** /feedback: report about Claude Code itself to Anthropic, optionally with this transcript. */
|
|
912
|
+
async submitFeedback(description: string, attachTranscript: boolean) {
|
|
913
|
+
await this.ensureStarted();
|
|
914
|
+
return (this.q as unknown as { submitFeedback(d: string, o: object): Promise<unknown> }).submitFeedback(description, { attach_transcript: attachTranscript });
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
async renameLive(title: string): Promise<boolean> {
|
|
918
|
+
if (!this.q) return false;
|
|
919
|
+
await (this.q as unknown as { renameSession(title: string): Promise<void> }).renameSession(title);
|
|
920
|
+
return true;
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
/** `!command`: run it in the project dir and add the output to the conversation without starting a turn. */
|
|
924
|
+
async runBash(uuid: string, command: string) {
|
|
925
|
+
return this.operate(() => this.runBashLocked(uuid, command));
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
private async runBashLocked(uuid: string, command: string) {
|
|
929
|
+
if (this.closed) throw new Error("这个会话已被删除。");
|
|
930
|
+
if (!this.writeEnabled) throw new Error(WRITES_DISABLED);
|
|
931
|
+
this.emit({ k: "bash", uuid, command, ts: Date.now() });
|
|
932
|
+
const out = await runBang(command, this.cwd);
|
|
933
|
+
const text = bangMessage(command, out);
|
|
934
|
+
this.emit({ k: "user", uuid, text, ts: Date.now() });
|
|
935
|
+
let input: InputQueue;
|
|
936
|
+
try {
|
|
937
|
+
await this.ensureStarted();
|
|
938
|
+
if (this.closed || !this.q || !this.input) throw new Error("会话进程已停止,请重试。");
|
|
939
|
+
input = this.input;
|
|
940
|
+
} catch (err) {
|
|
941
|
+
this.emit({ k: "error", message: err instanceof Error ? err.message : String(err) });
|
|
942
|
+
return;
|
|
943
|
+
}
|
|
944
|
+
input.push({
|
|
945
|
+
type: "user",
|
|
946
|
+
uuid: uuid as never,
|
|
947
|
+
session_id: this.sessionId,
|
|
948
|
+
parent_tool_use_id: null,
|
|
949
|
+
message: { role: "user", content: text },
|
|
950
|
+
shouldQuery: false,
|
|
951
|
+
});
|
|
952
|
+
this.awaitingCommands.add(uuid);
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
/** Context window usage (cheap 'summary' detail); undefined until the CLI process runs. */
|
|
956
|
+
async contextUsage() {
|
|
957
|
+
if (!this.q) return undefined;
|
|
958
|
+
return this.q.getContextUsage({ detail: "summary" });
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
/** Set by the session manager: keeps sessions with scheduled tasks resumable after a restart. */
|
|
962
|
+
onCronsChanged?: (crons: CronInfo[], hasDurable: boolean) => void;
|
|
963
|
+
|
|
964
|
+
/** Durable tasks in this session's cwd that belong to it (or predate ownership). */
|
|
965
|
+
private ownDurableCrons(): CronInfo[] {
|
|
966
|
+
return durableTasksForSession(this.cwd, this.sessionId).map((t) => this.durableCron(t));
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
/** Every durable task in this session's cwd; the panel shows the project's tasks to every session. */
|
|
970
|
+
private projectDurableCrons(): CronInfo[] {
|
|
971
|
+
return durableTasks(this.cwd).map((t) => this.durableCron(t));
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
private durableCron(t: DurableTask): CronInfo {
|
|
975
|
+
return {
|
|
976
|
+
id: t.id,
|
|
977
|
+
schedule: t.cron,
|
|
978
|
+
recurring: t.recurring,
|
|
979
|
+
prompt: t.prompt,
|
|
980
|
+
nextRun: nextRun(t.cron),
|
|
981
|
+
durable: true,
|
|
982
|
+
...(t.createdBySessionId ? { createdBySessionId: t.createdBySessionId } : {}),
|
|
983
|
+
};
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
/** Everything the UI should show: in-process, durable file, then IDECron server tasks. */
|
|
987
|
+
private allCrons(): CronInfo[] {
|
|
988
|
+
const out = [...this.crons, ...this.projectDurableCrons()];
|
|
989
|
+
const ids = new Set(out.map((c) => c.id));
|
|
990
|
+
for (const c of idecron.cronsFor(this.sessionId)) if (!ids.has(c.id)) out.push(c);
|
|
991
|
+
return out;
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
/** Re-emit after an IDECron create/cancel/fire without touching the CLI task list. */
|
|
995
|
+
emitCronList() {
|
|
996
|
+
this.emit({ k: "crons", crons: this.allCrons() });
|
|
997
|
+
this.onChange();
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
/** True when the durable file has tasks belonging to this session. */
|
|
1001
|
+
hasDurableWork(): boolean {
|
|
1002
|
+
return this.ownDurableCrons().length > 0;
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
/** True while anything in the CLI process (or its durable file) can wake this session. */
|
|
1006
|
+
hasScheduledWork(): boolean {
|
|
1007
|
+
const durable = this.hasDurableWork();
|
|
1008
|
+
if (this.cronsStale && this.staleFromDurable && !this.crons.length && !durable) {
|
|
1009
|
+
// The durable file was removed after the row was written. The stale marker no longer
|
|
1010
|
+
// describes any scheduled work, so stop protecting an otherwise idle process.
|
|
1011
|
+
this.cronsStale = false;
|
|
1012
|
+
this.staleFromDurable = false;
|
|
1013
|
+
this.onCronsChanged?.([], false);
|
|
1014
|
+
this.emit({ k: "crons", crons: this.allCrons() });
|
|
1015
|
+
this.onChange();
|
|
1016
|
+
}
|
|
1017
|
+
return this.cronsStale || this.crons.length > 0 || durable;
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
/** Restore the last list reported by the CLI before the server restarted. */
|
|
1021
|
+
hydrateCrons(list: CronInfo[], stale: boolean, staleFromDurable = false) {
|
|
1022
|
+
this.crons = list.map((c) => ({ ...c, nextRun: nextRun(c.schedule) }));
|
|
1023
|
+
this.cronsStale = stale;
|
|
1024
|
+
this.staleFromDurable = stale && staleFromDurable;
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
/** Mark a durable-only row stale; unlike a legacy cached row, this can be cleared by the file. */
|
|
1028
|
+
markStaleDurable() {
|
|
1029
|
+
this.cronsStale = true;
|
|
1030
|
+
this.staleFromDurable = true;
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
/** Persist a row for a durable-only session too, so a restart brings its owner back. */
|
|
1034
|
+
noteScheduled() {
|
|
1035
|
+
this.onCronsChanged?.(this.crons, this.ownDurableCrons().length > 0);
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
private setCrons(list: { id: string; schedule: string; recurring: boolean; prompt: string }[]) {
|
|
1039
|
+
this.crons = list.map((c) => ({ ...c, nextRun: nextRun(c.schedule) }));
|
|
1040
|
+
this.cronsStale = false;
|
|
1041
|
+
this.staleFromDurable = false;
|
|
1042
|
+
this.onCronsChanged?.(this.crons, this.ownDurableCrons().length > 0);
|
|
1043
|
+
this.emit({ k: "crons", crons: this.allCrons() });
|
|
1044
|
+
this.onChange();
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
private setTasks(tasks: BgTask[]) {
|
|
1048
|
+
this.tasks = tasks;
|
|
1049
|
+
this.emit({ k: "tasks", tasks });
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
private configStale = false;
|
|
1053
|
+
|
|
1054
|
+
/**
|
|
1055
|
+
* Make the CLI see changed config (MCP servers, plugins, hooks, agents, settings).
|
|
1056
|
+
* An idle session restarts its CLI process (resume keeps the conversation); a busy one
|
|
1057
|
+
* does so when the turn ends. Scheduled tasks and background work live in the process,
|
|
1058
|
+
* so then only plugins and skills are reloaded in place.
|
|
1059
|
+
*/
|
|
1060
|
+
async reloadConfig() {
|
|
1061
|
+
return this.operate(() => this.reloadConfigLocked());
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1064
|
+
private async reloadConfigLocked() {
|
|
1065
|
+
const q = this.q;
|
|
1066
|
+
if (!q) return;
|
|
1067
|
+
const holdsWork = this.hasScheduledWork() || this.tasks.some((t) => t.status === "running");
|
|
1068
|
+
if (holdsWork) {
|
|
1069
|
+
await q.reloadPlugins().catch(() => {});
|
|
1070
|
+
await q.reloadSkills().catch(() => {});
|
|
1071
|
+
return;
|
|
1072
|
+
}
|
|
1073
|
+
if (this.state === "idle" && this.awaitingCommands.size === 0) {
|
|
1074
|
+
this.configStale = false;
|
|
1075
|
+
await this.stopLocked();
|
|
1076
|
+
} else this.configStale = true;
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1079
|
+
async taskOutput(taskId: string): Promise<{ output: string; truncated?: boolean }> {
|
|
1080
|
+
if (!this.q) return { output: "" };
|
|
1081
|
+
const r = await (this.q as unknown as { getTaskOutput(id: string): Promise<{ output: string; truncated?: boolean }> }).getTaskOutput(taskId);
|
|
1082
|
+
return { output: r.output, truncated: r.truncated };
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
async stopTask(taskId: string) {
|
|
1086
|
+
await this.q?.stopTask(taskId);
|
|
1087
|
+
}
|
|
1088
|
+
|
|
1089
|
+
/** A live control request, so it must not wait behind the foreground command. */
|
|
1090
|
+
async backgroundTask(toolUseId: string): Promise<boolean> {
|
|
1091
|
+
if (!toolUseId.trim()) throw new Error("缺少工具调用 ID");
|
|
1092
|
+
if (!this.q) return false;
|
|
1093
|
+
return this.q.backgroundTasks(toolUseId);
|
|
1094
|
+
}
|
|
1095
|
+
|
|
1096
|
+
private lastTurnFailed = false;
|
|
1097
|
+
private userInterrupted = false;
|
|
1098
|
+
private resultThisTurn = false;
|
|
1099
|
+
private deferredIdleFrom?: SessionState;
|
|
1100
|
+
private deferredIdleTimer?: NodeJS.Timeout;
|
|
1101
|
+
|
|
1102
|
+
/** Send the "turn ended" notification that was waiting for the turn's result. */
|
|
1103
|
+
private fireDeferredIdle() {
|
|
1104
|
+
if (this.deferredIdleFrom === undefined) return;
|
|
1105
|
+
clearTimeout(this.deferredIdleTimer);
|
|
1106
|
+
const from = this.deferredIdleFrom;
|
|
1107
|
+
this.deferredIdleFrom = undefined;
|
|
1108
|
+
this.onTransition(from, "idle", { failed: this.lastTurnFailed, silent: false });
|
|
1109
|
+
}
|
|
1110
|
+
|
|
1111
|
+
async interrupt() {
|
|
1112
|
+
if (!this.q) return;
|
|
1113
|
+
// Only an interrupt of a turn in progress; otherwise the flag would hide the next real failure.
|
|
1114
|
+
if (this.state !== "idle") this.userInterrupted = true;
|
|
1115
|
+
await this.q.interrupt().catch(() => {});
|
|
1116
|
+
}
|
|
1117
|
+
|
|
1118
|
+
async setModel(model: string) {
|
|
1119
|
+
this.model = model;
|
|
1120
|
+
if (this.q && !config.modelOverride) await this.q.setModel(model).catch(() => {});
|
|
1121
|
+
this.emitSettings();
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
async setEffort(effort: string) {
|
|
1125
|
+
this.effort = effort;
|
|
1126
|
+
if (this.q) await this.q.applyFlagSettings({ effortLevel: effort } as never).catch(() => {});
|
|
1127
|
+
this.emitSettings();
|
|
1128
|
+
}
|
|
1129
|
+
|
|
1130
|
+
async setPlanMode(on: boolean) {
|
|
1131
|
+
this.planMode = on;
|
|
1132
|
+
if (this.q) await this.q.setPermissionMode(on ? "plan" : "bypassPermissions").catch(() => {});
|
|
1133
|
+
this.emitSettings();
|
|
1134
|
+
}
|
|
1135
|
+
|
|
1136
|
+
setAskQuestionsEnabled(on: boolean) {
|
|
1137
|
+
this.askQuestionsEnabled = on;
|
|
1138
|
+
if (!on) {
|
|
1139
|
+
for (const [id, pending] of [...this.pending]) {
|
|
1140
|
+
if (pending.req.kind === "ask") this.resolvePending(id, { kind: "cancel", message: QUESTIONS_DISABLED });
|
|
1141
|
+
}
|
|
1142
|
+
}
|
|
1143
|
+
this.emitSettings();
|
|
1144
|
+
}
|
|
1145
|
+
|
|
1146
|
+
setWriteEnabled(on: boolean) {
|
|
1147
|
+
this.writeEnabled = on;
|
|
1148
|
+
this.emitSettings();
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
close() {
|
|
1152
|
+
this.endAgentTasks();
|
|
1153
|
+
this.input?.close();
|
|
1154
|
+
this.q?.close();
|
|
1155
|
+
}
|
|
1156
|
+
}
|
|
1157
|
+
|
|
1158
|
+
/** Next fire time of a 5-field cron expression in local time, or undefined if it cannot be parsed. */
|
|
1159
|
+
function nextRun(schedule: string): number | undefined {
|
|
1160
|
+
try {
|
|
1161
|
+
return CronExpressionParser.parse(schedule).next().getTime();
|
|
1162
|
+
} catch {
|
|
1163
|
+
return undefined;
|
|
1164
|
+
}
|
|
1165
|
+
}
|