feat(runtime): add PTY and stdin control to background Bash - #778
Conversation
|
Substantial PR, and the lifecycle ownership, fail-closed paths, and real node-pty/xterm test coverage are strong. I verified the items below against the diff; severity is P0-P3. P1
P2
P3 (non-blocking)
The lifecycle and test coverage are in good shape. The two security and two compatibility items in P1 are what I'd want resolved before merge; the rest can be tracked. 简体中文这是个很扎实的 PR,生命周期归属、fail-closed 路径,以及用真 node-pty/xterm 的测试覆盖都做得不错。下面这些我都对着 diff 核过,按 P0-P3 分级。 P1
P2
P3(非阻断)
生命周期和测试覆盖整体不错。合并前我希望先解决 P1 里的两个安全项和两个兼容项;其余可以记下来慢慢弄。 |
|
Small process note, not a blocker: at +7376/-1572 across 85 files this is a lot for one review pass; the quality and test coverage are strong, but I leaned on an external review to catch what I missed. For future "contract reshape + new capability" changes, worth considering two PRs, contract first then the new capability on top, so each fits in one pass. No rush here. 简体中文一个小提醒,不阻断:+7376/-1572、85 个文件,一次 review 真看不全,质量和测试覆盖都很强,我是靠一次外部 review 才补上漏的。以后遇到“契约重塑 + 新能力”这种,可以考虑拆两个 PR,先契约再叠新能力,每个都能一次看全。这次不急。 |
English@Astro-Han Thank you for the detailed review. The examples are concrete and helped separate several intentional tradeoffs from two boundaries that should be tightened. I went back through the permission, persistence, replay, and TUI paths. My current reading and proposed direction are below. 1. Raw WriteStdin input and the audit boundaryYou are right about the current ordering: the raw This was intentional in #775: permission controls whether the side effect executes, while the model-generated tool call remains part of the canonical audit/model history. The same ordering currently applies to Bash commands and Write/Edit contents. Projecting I am also hesitant to enforce There is, however, a related inconsistency that should be fixed: session JSONL currently retains raw args, while the live If Maka wants the stronger invariant that denied tool calls never persist raw arguments, I think that should be designed once at the ToolRuntime semantic-ledger boundary for all tools rather than introduced as a WriteStdin-only exception. I would be glad to discuss that broader contract separately. 2. Permission scopeI agree with this finding. The TUI makes the problem stronger because I propose including the exact side effect in the scope: 3. CompatibilityThe PR originally followed the repository’s pre-release assumption and explicitly replaced the old schema without migration. Given your review, I agree that a narrow compatibility concession is worthwhile. I propose normalizing only the exact known legacy shapes at read/restore boundaries:
This avoids scattered TUI guards and permanent dual-schema branches. Truly malformed data should remain isolated or diagnostic rather than being accepted as legacy data. 4.
|
English@Astro-Han You're right; I did not give reviewability enough weight here. The reason this became one large PR is fairly simple: I had already spent some time privately designing and experimenting with this feature. Once the implementation became coherent, I treated “one complete feature” as “one PR” and submitted it after整理, without stepping back to separate the contract reshaping from the capability delivery from a reviewer's perspective. That concentrated too much review work into a single pass, and I am a little embarrassed that I missed such a basic consideration. For future changes of this shape, I will split them along independently reviewable boundaries: establish the contract and underlying structure first, then add the capability on top. Even if the work is developed together on a private branch, that does not mean it should arrive upstream as a single PR. 简体中文@Astro-Han 你说得对,这次我没有充分考虑 PR 的可审查性。 这个 PR 之所以会这么大,原因其实很简单:我前一段时间已经在私下设计和实验这个 feature。等实现整理成一套完整内容后,我下意识地把“一个完整 feature”等同成了“一个 PR”,没有再站在 reviewer 的角度,把契约重塑和新能力交付拆开。这让一次审查承担了过多内容,漏掉这么基本的考虑,我确实有点不好意思。 以后遇到类似改动,我会按能够独立审查的边界拆分:先建立契约和底层结构,再在其上提交新能力。即使这些工作是在同一条私有分支上连续完成,也不意味着它们应该作为一个上游 PR 一次性提交。 |
|
@M4n5ter Thanks, the code analysis tracks. I'm good with the directions on scope, legacy normalization, and removing observe_for_ms. A few specifics to pin down, and one threat-model call I'd like to confirm. On P1-1, I accept the rebuttals: projecting A few specifics on the other fixes:
Looking forward to the updates. 简体中文@M4n5ter 谢谢,代码分析对得上。scope、legacy 归一化、砍 observe_for_ms 这几个方向我都没问题。有几个具体点想定一下,外加一个 threat-model 想跟你确认。 P1-1 我接受反驳:投影 其他几个具体点:
等你更新。 |
English@Astro-Han I have pushed the agreed follow-up. The resulting contract is:
While exercising the final Desktop path, I also tightened the human projection without changing the runtime protocol: The PR description has also been revised to match these final contracts. 简体中文@Astro-Han 已按我们确认的方向推送后续修改,最终契约如下:
在最终 Desktop 实测中,我也收紧了人类可见投影,但没有改变 runtime 协议: PR 正文也已修订为上述最终契约。 |
Astro-Han
left a comment
There was a problem hiding this comment.
@M4n5ter Approving. The follow-up fixes are solid — the agreed contract holds (canonical args, full scope key, legacy ingress on both durable paths, observe_for_ms gone), and the test coverage on legacy fixtures, scope collision, and concurrent cut ordering is good.
A few things I'd treat as fast follow-ups, none of them blocking this merge:
-
Permission prompt completeness. The permission request sent to the UI carries the projected args (
permission-engine.ts:217), so the "view full parameters" panel and TUI summary only show the 160-charinputPreview, not the full input. For a tool that can write 64 KiB, the user can approve a long harmless prefix hiding a dangerous suffix. Worth giving the permission dialog a separate full-input inspector (escaped, withref+resizeshown), distinct from the activity-card projection which should stay bounded. -
WriteStdin turn memory. The TUI forces
rememberForTurn: trueon every allow (pi-tui-runner.ts:273-285), and the scope isref+input+sizewith no terminal-version binding. Approvingy\ronce auto-approves the same input in a later, different prompt. Simplest first step: don't remember WriteStdin within a turn; later bind anexpectedRevisionchecked in the same cut. -
appliedsemantics.input.appliedis set right afterpty.write()returns void (shell-run-manager.ts:294), but node-pty may still drop bytes silently on a non-EAGAIN error. Renaming toqueuedordelivery_unknownwould stop the model acting on a false "delivered". -
refconsistency (small one): the WriteStdin schema declaresrefas a barez.string(), and the projection copies it verbatim while bounding/redactinginput. Validating themaka://...format + a length cap, and treatingreflikeinputin the projection, closes that gap.
The rest (persist-failure retry idempotency, process-tree graceful-signal gaps, TUI exit timer vs worst-case shutdown, native node-pty as optional adapter, full-snapshot IPC, monotonic-clock flush) I'd file as separate issues rather than growing this PR further. It's already large enough that full surface coverage in one pass is hard.
Thanks for the iteration. The fixes landed cleanly.
简体中文
@M4n5ter 批准合入。后续修复很扎实,商定的契约都落实了(canonical 原文 args、完整 scope key、两条持久化路径的 legacy ingress、observe_for_ms 已删),legacy fixture、scope 碰撞、并发 cut 排序的测试覆盖也到位。
有几个点我建议作为快速跟进,都不阻塞这次合并:
-
权限弹窗完整性。 发给 UI 的权限请求带的是投影后 args(
permission-engine.ts:217),所以"查看完整参数"面板和 TUI 摘要只显示 160 字inputPreview,不是完整 input。一个能写 64 KiB 的工具,用户可能批准一个长无害前缀、后缀藏着危险操作。建议给权限弹窗一个独立的完整输入检查器(转义控制字符,显示ref+resize),跟活动卡片的投影分开,后者保持有界。 -
WriteStdin 的 turn memory。 TUI 每次允许都强制
rememberForTurn: true(pi-tui-runner.ts:273-285),scope 是ref+input+size,没绑终端版本。批准一次y\r,本轮稍后另一个不同的提示里同样的y\r会自动放行。第一步最简单:WriteStdin 不在本轮内记住;后续在同一切片内核对expectedRevision。 -
applied语义。input.applied在pty.write()(返回 void)之后立即置真(shell-run-manager.ts:294),但 node-pty 遇到非 EAGAIN 错误可能静默丢字节。改成queued或delivery_unknown,免得模型基于假的"已送达"继续操作。 -
ref一致性(小点):WriteStdin schema 把ref声明成裸z.string(),投影里直接复制完整、不限长,而input是有界脱敏的。校验maka://...格式 + 长度上限,投影里让ref和input一致,就补上了。
其余的(persist 失败的重试幂等、进程树的温和信号缺口、TUI 退出计时器 vs 最坏关闭路径、node-pty 作为可选 adapter、全量快照 IPC、单调时钟 flush)建议开独立 issue,别再往这个 PR 里塞。它已经大到单轮 review 很难全覆盖表面积了。
谢谢这几轮迭代,修得很干净。
English@Astro-Han Thank you for the approval and for separating merge blockers from follow-up work. I agree with all four points. I will keep them out of this already large PR and track them in a focused follow-up issue: #856. The issue keeps the activity projection bounded and redacted while restoring exact args at the permission boundary for a deliberate full escaped-input inspector; disables The permission-facing changes and the runtime contract changes can then land as independently reviewable updates. Thanks again for the careful review and the concrete examples. 简体中文@Astro-Han 感谢批准,也感谢你明确区分阻断合并的问题与后续工作。这四点我都认同。 我不会继续扩大这个已经很大的 PR,而会在一个聚焦的后续 issue 中跟踪它们:#856。该 issue 保持 activity 投影有界、脱敏,同时在 permission 边界恢复精确参数,为主动展开的完整转义输入检查提供依据;禁用 权限侧修改与 runtime 契约修改可以随后作为彼此独立、易于审查的更新提交。 再次感谢这次细致的审查和具体的例子。 |
|
LGTM, Merging! |
Every projection test called project() directly, so the wiring between the projection and the renderer was untested: a pass added downstream of it — a spread copy of each turn — restores the original defect with the whole suite green. That is the shape of #778 breaking #472, which this work exists to prevent. Extends the existing render-memo-boundary seam to mount the real ChatView and count renders per turn through a prop the test owns. The spread-copy mutation now fails this test.
Every projection test called project() directly, so the wiring between the projection and the renderer was untested: a pass added downstream of it — a spread copy of each turn — restores the original defect with the whole suite green. That is the shape of #778 breaking #472, which this work exists to prevent. Extends the existing render-memo-boundary seam to mount the real ChatView and count renders per turn through a prop the test owns. The spread-copy mutation now fails this test.
…ing it per token (#2034) * refactor(ui): make a turn's timeline its only tool authority turn.tools and turn.timeline were two structures carrying the same tools, each rewritten separately by projectTurnTools. Derive tools by flattening the timeline instead, in materializeTurns, overlayLiveTurn and projectTurnTools alike, so a turn has one tool map and the rewrite pass is idempotent — projecting an unchanged tool set now returns the same turn object rather than an equal one. Also splits the shell-run overlay into its folded-updates and per-tool halves so a caller can apply one update to one tool without re-folding the whole list. * feat(ui): project the transcript incrementally and report affected turns The transcript simulated incremental updates by re-deriving the whole view model per token and trusting memo's reference comparison to work out what had not changed. Two links in that chain were not idempotent, so the guess was wrong: overlayShellRunUpdates re-folded from its original input every delta and never wrote its result back, and a background command's durable revision permanently leads the tool_result snapshot persisted in messages, so the owning turn was rebuilt on every token; and every message refresh replaced every turn wholesale. createTranscriptProjection owns that derived state instead. It remembers the settled turns and hands the previous object back for any turn a refresh did not change, remembers the result of applying each shell-run update to each tool, and reports affectedTurnIds — derived from its own output, since the turn an event names is not the set of turns it affects (a ShellRun result folds into the Bash that owns its ref, which can live in an earlier turn). Coverage asserts identity, not just rendered output, and covers the invalidation boundaries in both directions: session switch, deletion, edit-and-resend, turns missing from the durable snapshot, live-to-settled handoff, lineage, ownership flips at a fixed revision, and live-only tools. * perf(ui): keep the prompt rail out of the streaming path The rail's IntersectionObserver effect depended on the whole turns array, so every streamed token tore it down and rebuilt it — one querySelector and one observe per turn across the entire transcript. Key the effect on which turns exist, which is all the observer set depends on, memoize the rail, and hand it back the previous entries array when no prompt or answer text moved. * fix(desktop): intern the per-turn props a memoized TurnView reads A stable turn only skips a memoized TurnView if every sibling prop is stable too. deriveAppShellTurnViewModel rebuilt turnFooterActionsByTurn and turnLineageBadgesByTurn from scratch on every refreshMessages, which fires at each step and tool boundary, so the memo failed on the footer array and the whole transcript re-rendered regardless of how stable its turns were — the transcript projection's refresh half bought nothing at the renderer. Intern both by value against the previous derivation, the same rule the projection uses for turns. Value equality is the only option available: messages arrive freshly deserialized over IPC, so no input carries identity across a refresh and identity has to be re-established from the output. * test(desktop): pin the transcript render boundary on the real ChatView Every projection test called project() directly, so the wiring between the projection and the renderer was untested: a pass added downstream of it — a spread copy of each turn — restores the original defect with the whole suite green. That is the shape of #778 breaking #472, which this work exists to prevent. Extends the existing render-memo-boundary seam to mount the real ChatView and count renders per turn through a prop the test owns. The spread-copy mutation now fails this test. * refactor(ui): scope the shell-run overlay and drop its unused entry point Three follow-ups from review: - The overlay rebuilt every turn's timeline to discover that only the turns holding a background command had moved. Scope the rebuild to those turns. - affectedTurnIds built a Map and a Set over the whole transcript on every streamed token for an answer no render path reads. Compute it on read. - overlayShellRunUpdates had no caller but its own test, so nothing would have gone red when it and the projection diverged. Remove it and drive its ShellRun behaviours through the projection that actually ships. Also stores its own copy of the update list, so the element-wise comparison is against a snapshot rather than against the caller's array aliased to itself, and covers two gaps the existing tests left: an ownership flip at an unchanged revision (the previous fixture's leading revision masked the ownership comparison entirely) and a lock that a text delta never re-reads the message log. * refactor(ui): derive per-turn presentation from the projected turns The shell derived footer actions, failed-turn labels and lineage badges by materializing the transcript a second time from the raw message log, so the turns it keyed them by were not the turns the renderer drew. Those props then had to be interned by value to line up with a memoized TurnView again. ChatView now hands its projected turns back to the shell through a `deriveTurnPresentation` callback, and the shell keys its cache on those turn objects: a turn the projection did not move costs one WeakMap hit and hands the same props back. The second authority and the interning both go away. Also drops `affectedTurnIds` (test-only, and repeating an input reported the previous step's set), the shell-run per-tool memo (its stated benefit was already provided by reconcileTurnIdentities, and it could serve a result derived from a stale tool), and makes `valuesEqual` fail closed outside plain objects and arrays. Refs #2030 * test(desktop): pin per-turn presentation reuse on a growing transcript * fix(ui): fold shell-run children independently of tool order A turn's tools are a flattening of its timeline, and a live overlay moves that turn's tools to the end of it. That can order a background command's child tool ahead of the Bash that owns the run, and the fold scanned only the tools it had already folded — so it stopped folding there, leaving an orphan tool row and a parent that never took the child's revision. Look the parent up by ref over the whole list instead, and merge the children once their parent's position is known. The invariant test now carries two tools per turn on both the settled and the live path, so a reversal between `tools` and `timeline` is observable at all. * refactor(desktop): repair what the presentation move left behind Moving the per-turn presentation onto the projected turns superseded the interning it replaced, but left three traces of it. A literal NUL byte in the cache key's `join` made git treat the file as binary, so the diff that carries this PR's core was unreviewable. Two docblocks still pointed at `deriveAppShellTurnViewModel`, which no longer exists, and `valuesEqual` still justified its export by an interning rule this change removed — it now documents the coupling that is real: the shell keys a WeakMap on the turn objects this comparison decides. `useAppShellTurnPresentation` claimed its own identity had to stay constant. Nothing memoizes on it, so the `useCallback` bought nothing while the render-phase ref write it required would have silently frozen pending state the day ChatView is memoized. Dropping both leaves the cache where it belongs, in the ref that holds the derivation. * refactor(ui): drop the prompt rail's redundant second stabilization The rail derived a NUL-joined id key and mirrored its turns into a ref so its observer effect would not re-run per streamed token. ChatView already hands the rail the same array while no rail-visible field moves, so both layers guarded the same thing — and each covered for the other well enough that removing either one on its own kept every test green. Keying the effect on the turns array leaves one guard instead of two, and turns the caller's reuse into something the observer count can see. * test(desktop): cover the per-turn fields the presentation feeds TurnView The stand-in this suite drives ChatView with returns empty maps for everything but the footer actions, so failure copy, lineage badges, and the resume pairing were derived in one test and rendered in another, never in the same frame. Cutting any of those five wires kept the suite green. A failed, regenerated transcript now runs the real derivation through the real ChatView and asserts each value reaches the DOM — with two failed turns, so offering the resume on every one of them is visible rather than hidden behind the single turn that could render it. The observer guard was also counting ResizeObserver and IntersectionObserver into one tally, so `observe > 0` passed even when the rail observed nothing. Each kind now counts separately and the guard pins the exact lifecycle. * docs(ui): state that the presentation deriver must outlive one render The prop asked only for purity and idempotence, and a deriver rebuilt in the render body satisfies both while discarding the cache that makes the projection worth doing — with nothing turning red. Say so on the prop, and on the one-shot helper that is exactly the shape someone would copy out of a story.
English
Closes #775.
Summary
WriteStdinfor exact terminal input and resize, returning the persisted terminal state at the control's parser cut.Read(ref)and stoppable through the existing genericStopBackgroundTask({ ref }).Why
The current runtime-owned background Bash lifecycle handles servers, watchers, and other pipe-oriented commands, but it cannot operate programs that require a real TTY or input after startup. REPLs, debuggers, interactive setup commands, confirmation prompts, and full-screen terminal applications otherwise fall back to shell-level workarounds that bypass Maka's lifecycle, ordering, redaction, recovery, and UI contracts.
Key decisions and rationale
Read(ref)and genericStopBackgroundTask({ ref }); add only PTY-specificWriteStdin.Read(ref)for later output.ref + input + sizeside effect without truncation.appliedseparately fromchanged.WriteStdinonly in the main Desktop and TUI hosts.orphanedinstead of reattaching native PIDs.Design
corestorageorphaned.runtimeWriteStdinonly where a host owns the lifecycle, publish durable ShellRun revisions, and terminate live runs during host shutdown.Tool contract
Bash({ run_in_background: true, pty: true })is the only PTY start path. PTY is rejected for foreground Bash, and omitting it preserves current pipe behavior.WriteStdinaccepts exact Unicodeinputand/or a complete{ cols, rows }resize; when both are present, resize commits before input. No newline is added implicitly.gdb -tuiis cramped at 80x24, the model can resize the running PTY to 120x40 before continuing. The debugger receives the terminal-size change and redraws whichever source, assembly, register, or command windows are active, while the parser uses the same geometry for the returned screen state.applied: true, changed: false, and the UI does not repeat a misleading “resized” message beside an input action.WriteStdinpersists and returns the terminal state at the parser cut immediately after its control commit. It does not wait for later output or claim causal attribution; subsequent output is observed throughRead(ref).WriteStdinargs, including denied calls. Permission and presentation use a bounded redacted projection, and turn-level permission memory distinguishes the completeref + input + sizeside effect without truncation.Readuses strict mutually exclusive file and runtime-resource branches:{ path, offset?, limit? } | { ref }. Runtime resources are whole snapshots and do not accept file pagination.StopBackgroundTask({ ref })keeps its generic schema.WriteStdinrejects pipe and unauthorized/unknown refs; a valid PTY that is already terminal or no longer live returns its snapshot with an unapplied control operation instead of being mutated.Terminal and lifecycle guarantees
Read(ref)cuts reserve durable writes in the same order, so revisions cannot publish an older screen after a newer one. Stop closes later control admission and waits for termination, final parser drain, and durable persistence.Scope
Included:
Not included:
Impact
Agent-facing changes for the main Desktop and TUI hosts:
pty, valid only with explicit background execution.WriteStdinis added as an always-available PTY capability on those hosts.Read's existing runtime-resource form becomes a strict{ ref }branch alongside its file branch.Current writes and business logic use only the new ShellRun shape. At durable read/restore ingress, the exact immediately preceding ToolResult and
shell-run.jsonshapes are normalized into the current form; unknown, mixed, or malformed shapes still fail closed. This is a narrow review-requested boundary, not a long-lived dual schema or migration mode.New runtime dependencies are
node-pty@^1.2.0-beta.14,@xterm/headless@^6.0.0, and@xterm/addon-unicode11@^0.9.0. The PTY stack is loaded lazily andnode-ptyis explicitly allowlisted for its install script.Verification
npm run typecheck,npm test, andnpm run build.node-pty, the durable ShellRun store, and xterm parsing. It covers exact Unicode/control input, resize ordering, clear/redraw, alternate screen, Unicode width, parser backpressure, protocol replies, redaction boundaries, persistence failure, concurrent control/Stop races, timeout, shutdown, and detached-descendant cleanup.Read(ref), and bounded human-readableWriteStdinsummaries.Read(ref),StopBackgroundTask, compact/expanded terminal projection, and parent-Bash reconciliation.Windows/ConPTY behavior is implemented and typechecked but was not exercised on a Windows host in this change. No formal packaged-Electron validation is claimed; the repository does not currently expose that packaging boundary as a verification target.
Reviewer notes
The highest-value review boundaries are:
shell-run-manager.ts,completion-latch.ts, andprocess-tree-terminator.ts: admission, termination ownership, process exit, and unique finalization.pty-screen-collector.tsandpty-process-driver.ts: parser backpressure, protocol replies, screen epochs, side-effect isolation, and native driver behavior.shell-run-contract.ts,shell-run-tool-result.ts, and storage: compact handoff versus complete snapshots, revision/persistence ordering, and operation-record separation.shell-tools.ts,builtin-tools.ts, and permission projection: public schema strictness, exact input semantics, bounded human previews, and host capability boundaries.Review status
This PR is ready for review. The implementation and verification are complete, while #775 remains open for design discussion; the tool and output contracts are not being presented as irreversible. I will adapt the implementation if review identifies a simpler or more Maka-aligned boundary.
简体中文
本 PR 合并后关闭 #775。
摘要
WriteStdin,支持精确终端输入与 resize,并返回该控制对应 parser cut 上已持久化的终端状态。Read(ref)观察,并通过现有通用StopBackgroundTask({ ref })停止。原因
当前由 runtime 持有的后台 Bash 生命周期已经适合 server、watcher 等 pipe 型命令,但仍无法操作需要真实 TTY 或启动后输入的程序。REPL、调试器、交互式初始化命令、确认提示和全屏终端应用只能退回 shell 技巧,而这些技巧绕开了 Maka 的生命周期、顺序、脱敏、恢复与 UI 契约。
关键决策与理由
Read(ref)与通用StopBackgroundTask({ ref }),只新增 PTY 专属的WriteStdin。Read(ref)获取。ref + input + size副作用区分。applied与changed。WriteStdin。orphaned,不重新 attach native PID。设计
corestorageorphaned。runtimeWriteStdin,发布 durable ShellRun revision,并在 host shutdown 时终止 live run。工具契约
Bash({ run_in_background: true, pty: true })是唯一 PTY 启动路径。前台 Bash 拒绝 PTY;省略 PTY 时保留当前 pipe 行为。WriteStdin接受精确 Unicodeinput和/或完整{ cols, rows }resize;同时提供时先提交 resize,再提交 input。不隐式追加换行。gdb -tui在 80x24 下过于拥挤,模型可以先把仍在运行的 PTY 调整到 120x40 再继续操作。调试器会收到终端尺寸变化,并重绘当前启用的源码、汇编、寄存器或命令窗口;parser 则以相同几何尺寸生成返回画面。applied: true, changed: false;与 input 同时出现时,UI 不再重复具有误导性的“已调整”文案。WriteStdin在 control commit 后立即持久化并返回该 parser cut 的终端状态;它不等待后续输出,也不声称 snapshot 中的输出由本次输入导致。后续输出通过Read(ref)观察。WriteStdin参数,包括被拒绝的调用。权限与展示使用有界脱敏投影;回合级权限记忆按完整且不截断的ref + input + size副作用区分。Read使用严格互斥的文件与 runtime-resource 分支:{ path, offset?, limit? } | { ref }。runtime resource 是完整 snapshot,不接受文件分页参数。StopBackgroundTask({ ref })保持通用 schema。WriteStdin会拒绝 pipe ref 和未授权/未知 ref;合法但已经终态或不再 live 的 PTY 不会被修改,而是返回 snapshot,并标记 control operation 未应用。终端与生命周期保证
Read(ref)的 cut 以相同顺序预留 durable 写入,避免较新 revision 发布较旧画面;Stop 关闭后续控制入口,并等待终止、final parser drain 与 durable persist。范围
包含:
不包含:
影响
Main Desktop 与 TUI host 的 agent-facing 变化:
pty,且只允许与显式后台执行配合。WriteStdin。Read现有的 runtime-resource 形式收敛为与文件分支并列的严格{ ref }分支。当前写路径与业务逻辑只使用新的 ShellRun shape。在 durable read/restore ingress,紧邻本 feature 前的精确 ToolResult 与
shell-run.jsonshape 会被规范化为当前形式;未知、混合或损坏 shape 仍然 fail closed。这是 review 明确要求的窄边界,不是长期双 schema 或 migration mode。新增 runtime 依赖为
node-pty@^1.2.0-beta.14、@xterm/headless@^6.0.0和@xterm/addon-unicode11@^0.9.0。PTY stack 采用 lazy load,并为node-pty的安装脚本增加显式 allowlist。验证
npm run typecheck、npm test与npm run build。node-pty、durable ShellRun store 与 xterm parser;覆盖精确 Unicode/control input、resize 顺序、清屏/重绘、alternate screen、Unicode width、parser backpressure、protocol reply、跨边界脱敏、持久化失败、并发 control/Stop 竞态、timeout、shutdown 和脱组后代清理。Read(ref)和有界、可读的WriteStdin摘要。Read(ref)、StopBackgroundTask、终端折叠/展开投影和父 Bash reconciliation。Windows/ConPTY 路径已经实现并通过 typecheck,但本次没有在 Windows host 上实际运行。不声称完成正式 packaged Electron 验证;当前仓库也没有把该打包边界暴露为验证目标。
审查重点
最值得审查的边界是:
shell-run-manager.ts、completion-latch.ts与process-tree-terminator.ts:admission、termination ownership、process exit 与唯一 finalization。pty-screen-collector.ts与pty-process-driver.ts:parser backpressure、protocol reply、screen epoch、side-effect 隔离和 native driver 行为。shell-run-contract.ts、shell-run-tool-result.ts与 storage:compact handoff/完整 snapshot、revision/persist 顺序和 operation/record 分离。shell-tools.ts、builtin-tools.ts与权限投影:公开 schema 严格性、精确 input 语义、有界人类预览和 host capability 边界。审查状态
本 PR 已可进入正式审查。实现与验证已经完成,同时 #775 仍用于设计讨论;工具与输出契约并不被视为不可调整。若审查找到更简单或更符合 Maka 的边界,我会相应调整实现。