From b2f146a1b63415b286240011b7ec789edb5fc6e5 Mon Sep 17 00:00:00 2001 From: DanielWalnut <45447813+hetaoBackend@users.noreply.github.com> Date: Wed, 23 Sep 2026 14:48:57 +0800 Subject: [PATCH 01/27] fix(tui): keep interrupted footer hidden during edit resubmission (#332) --- .../tui/src/tui/controller/chat-controller.ts | 9 + .../product/session-mutation-flow.ts | 13 ++ .../tui/transcript/presentation/visibility.ts | 18 ++ packages/tui/test/unit/tui-app.test.ts | 203 ++++++++++++++++++ 4 files changed, 243 insertions(+) diff --git a/packages/tui/src/tui/controller/chat-controller.ts b/packages/tui/src/tui/controller/chat-controller.ts index 4420801a..abd1b851 100644 --- a/packages/tui/src/tui/controller/chat-controller.ts +++ b/packages/tui/src/tui/controller/chat-controller.ts @@ -646,6 +646,15 @@ export class TuiChatController { return Boolean(this.activeTurn || this.runCoordinator.activeTurnId()); } + getTerminalDurationId(): string | undefined { + return this.transcript.snapshot().find((cell) => cell.kind === 'turn-duration')?.id; + } + + dismissTerminalDuration(id: string | undefined): void { + if (!id || this.transcript.get(id)?.kind !== 'turn-duration') return; + if (this.transcript.remove(id)) this.notify(); + } + beginRuntimeTurn(turnId: string, timestamp: number): void { this.outputRate.beginTurn(turnId); this.turnProjection.beginTurn(turnId, timestamp); diff --git a/packages/tui/src/tui/controller/product/session-mutation-flow.ts b/packages/tui/src/tui/controller/product/session-mutation-flow.ts index 1c788cb0..10f95a6e 100644 --- a/packages/tui/src/tui/controller/product/session-mutation-flow.ts +++ b/packages/tui/src/tui/controller/product/session-mutation-flow.ts @@ -427,6 +427,7 @@ export class TuiSessionMutationFlow { ...(mergedAttachments ? { attachments: mergedAttachments } : {}), }; } + const previousDurationId = this.options.controller.getTerminalDurationId(); this.invocations.set(invocation.sequence, { ...invocation, phase: 'submitting' }); this.options.setHint(sessionMutationText('sessionMutation.hint.editSubmitting')); this.options.onChanged(); @@ -446,6 +447,13 @@ export class TuiSessionMutationFlow { return 'retained'; } const code = rewindErrorCode(error); + if ( + code === 'EDIT_RESTART_NEEDS_RESUBMIT' || + code === 'EDIT_SUBMIT_FAILED_AFTER_REWIND' || + code === 'REWIND_DISPLAY_COMMIT_FAILED' + ) { + this.options.controller.dismissTerminalDuration(previousDurationId); + } if (code === 'EDIT_RESTART_NEEDS_NEW_OPERATION') { this.invocations.set(invocation.sequence, { ...current, @@ -481,6 +489,11 @@ export class TuiSessionMutationFlow { ); return 'retained'; } + // Edit bypasses normal submit. Discard only the captured footer on success, + // before history refresh can expose it; a new run may already have finished. + if (this.isCurrentMutation(invocation)) { + this.options.controller.dismissTerminalDuration(previousDurationId); + } try { await this.options.refreshProjection?.(invocation.sourceSessionId); } catch { diff --git a/packages/tui/src/tui/transcript/presentation/visibility.ts b/packages/tui/src/tui/transcript/presentation/visibility.ts index 356acff9..62935d87 100644 --- a/packages/tui/src/tui/transcript/presentation/visibility.ts +++ b/packages/tui/src/tui/transcript/presentation/visibility.ts @@ -12,6 +12,7 @@ import type { */ export class TranscriptVisibilityProjection implements TranscriptProjectionSource { private hiddenFromSourceMessageId: string | undefined; + private hiddenTerminalDurationId: string | undefined; private visibilityRevision = 0; private visibleLengthCache: | { @@ -43,6 +44,18 @@ export class TranscriptVisibilityProjection implements TranscriptProjectionSourc readonly hideFromSourceMessage = (sourceMessageId: string | undefined): void => { if (this.hiddenFromSourceMessageId === sourceMessageId) return; this.hiddenFromSourceMessageId = sourceMessageId; + this.hiddenTerminalDurationId = undefined; + if (sourceMessageId) { + let durationId: string | undefined; + for (let index = this.source.length - 1; index >= 0; index -= 1) { + const cell = this.source.cellAt(index); + if (cell?.kind === 'turn-duration') durationId = cell.id; + if (cell?.sourceMessageId === sourceMessageId) { + this.hiddenTerminalDurationId = durationId; + break; + } + } + } this.visibilityRevision += 1; }; @@ -80,6 +93,11 @@ export class TranscriptVisibilityProjection implements TranscriptProjectionSourc length = index; break; } + // Runtime can refresh rewound history before the edit RPC settles. Keep + // the captured footer hidden if that refresh removes the message anchor. + if (length === this.source.length && this.hiddenTerminalDurationId) { + length = this.source.locateCell(this.hiddenTerminalDurationId)?.index ?? length; + } if (sourceRevision !== undefined) { this.visibleLengthCache = { sourceRevision, diff --git a/packages/tui/test/unit/tui-app.test.ts b/packages/tui/test/unit/tui-app.test.ts index 9a922ceb..6e298b00 100644 --- a/packages/tui/test/unit/tui-app.test.ts +++ b/packages/tui/test/unit/tui-app.test.ts @@ -11654,6 +11654,209 @@ describe("createTuiApp", () => { await app.stop(); }); + it.each([ + "resubmit", + "cancel", + "failed-submit-cancel", + "new-operation-cancel", + "rewound-submit-cancel", + "resubmit-required-cancel", + "fast-completion", + "early-rewind", + "display-commit-cancel", + ])( + "keeps the interrupted footer lifecycle correct after double Escape and %s", + async (action) => { + const terminal = new FakeTerminal(); + const screen = new VirtualTerminalScreen(terminal.columns, terminal.rows); + const frames: string[] = []; + const write = terminal.write.bind(terminal); + terminal.write = (data) => { + write(data); + screen.feed(data); + if (data.includes("\x1b[?2026l")) frames.push(screen.text()); + }; + const runtime = createRuntime(); + const busEvents: TuiRuntimeEvent[] = []; + let wakeBus: (() => void) | undefined; + let finishEdit: (() => void) | undefined; + vi.mocked(runtime.watchEvents).mockImplementation(async function* (signal) { + while (!signal.aborted) { + if (busEvents.length === 0) { + await new Promise((resolve) => { + wakeBus = resolve; + signal.addEventListener("abort", resolve, { once: true }); + }); + } + const event = busEvents.shift(); + if (event) yield event; + } + }); + const originalMessage = { + id: "msg-user-interrupted", + role: "user" as const, + content: "Original query", + timestamp: 1, + }; + vi.mocked(runtime.listSessionInputSummaries).mockResolvedValue([ + { userMessageId: originalMessage.id, timestamp: 1, fileChangeCount: 0 }, + ]); + vi.mocked(runtime.listMessagePage).mockResolvedValue({ + messages: [originalMessage], + hasMore: false, + }); + vi.mocked(runtime.sendMessage).mockImplementation( + async function* (_request, signal) { + yield { type: "message", message: originalMessage }; + yield { type: "delta", content: "Partial response" }; + await new Promise((resolve) => { + signal?.addEventListener("abort", resolve, { once: true }); + }); + yield { type: "done" }; + }, + ); + // The RPC/history refresh can finish before the replacement session.start event. + vi.mocked(runtime.editSessionMessage).mockImplementation(async () => { + if (action === "failed-submit-cancel") + throw new Error("Edit rejected before rewind"); + const failureCodes: Partial> = { + "new-operation-cancel": "EDIT_RESTART_NEEDS_NEW_OPERATION", + "rewound-submit-cancel": "EDIT_SUBMIT_FAILED_AFTER_REWIND", + "resubmit-required-cancel": "EDIT_RESTART_NEEDS_RESUBMIT", + "display-commit-cancel": "REWIND_DISPLAY_COMMIT_FAILED", + }; + const failureCode = failureCodes[action]; + if (failureCode) + throw Object.assign(new Error("Edit failed"), { key: failureCode }); + if (action === "fast-completion") { + app.controller.beginRuntimeTurn("turn-edited", 1); + app.controller.runtimeTurnSettlement.settleProjection( + "turn-edited", + "succeeded", + 2_000, + ); + } + vi.mocked(runtime.getMessages).mockResolvedValue([ + { ...originalMessage, id: "msg-user-edited", content: "Edited query" }, + ]); + if (action === "early-rewind") { + busEvents.push( + runtimeEvent({ + type: "message.rewind", + timestamp: 101, + source: "runtime-v2", + payload: { sessionId: "session-1", contextReset: true }, + }), + ); + wakeBus?.(); + await new Promise((resolve) => { + finishEdit = resolve; + }); + } + return { + rewound: true, + turnId: "turn-edited", + userMessageId: "msg-user-edited", + }; + }); + const app = createTuiApp({ + runtime, + terminal, + version: "0.1.0", + workspaceDir: "/workspace", + }); + app.start(); + try { + await app.ready; + terminal.input?.("Original query"); + terminal.input?.("\r"); + await vi.waitFor(() => + expect( + app.transcript + .snapshot() + .some((cell) => cell.content === "Partial response"), + ).toBe(true), + ); + terminal.input?.("\x1b"); + await vi.waitFor(() => + expect( + app.transcript + .snapshot() + .some((cell) => cell.kind === "turn-duration"), + ).toBe(true), + ); + expect(app.editor.getText()).toBe(""); + app.tui.renderNow(); + expect(screen.text()).toContain("Interrupted after"); + + terminal.input?.("\x1b"); + terminal.input?.("\x1b"); + await vi.waitFor(() => + expect(app.editor.getText()).toBe("Original query"), + ); + app.tui.renderNow(); + expect(screen.text()).not.toContain("Interrupted after"); + + if (action === "cancel") { + terminal.input?.("\x1b"); + app.tui.renderNow(); + expect(screen.text()).toContain("Interrupted after"); + expect(runtime.editSessionMessage).not.toHaveBeenCalled(); + return; + } + + frames.length = 0; + app.editor.setText("Edited query"); + terminal.input?.("\r"); + if (action.endsWith("-cancel")) { + await vi.waitFor(() => + expect(app.editor.getText()).toBe("Edited query"), + ); + expect(runtime.editSessionMessage).toHaveBeenCalledOnce(); + terminal.input?.("\x1b"); + app.tui.renderNow(); + if ( + action === "failed-submit-cancel" || + action === "new-operation-cancel" + ) { + expect(screen.text()).toContain("Interrupted after"); + } else { + expect(screen.text()).not.toContain("Interrupted after"); + } + return; + } + await vi.waitFor(() => + expect( + app.transcript + .snapshot() + .some((cell) => cell.sourceMessageId === "msg-user-edited"), + ).toBe(true), + ); + app.tui.renderNow(); + if (action === "early-rewind") { + // Inspect the actual screen while the edit RPC is still pending. + expect(finishEdit).toBeDefined(); + expect(screen.text()).not.toContain("Interrupted after"); + finishEdit?.(); + await vi.waitFor(() => expect(app.editor.getText()).toBe("")); + app.tui.renderNow(); + } + expect(runtime.editSessionMessage).toHaveBeenCalledOnce(); + expect(screen.text()).toContain("Edited query"); + expect(screen.text()).not.toContain("Interrupted after"); + if (action === "fast-completion") + expect(screen.text()).toContain("Completed in 2s"); + expect( + frames.filter((frame) => frame.includes("Interrupted after")), + ).toEqual([]); + } finally { + finishEdit?.(); + await app.stop(); + screen.dispose(); + } + }, + ); + it("clears the interrupted duration before the next message input returns", async () => { const terminal = new FakeTerminal(); terminal.rows = 10; From ffbfff6c097a45e6c89c090329ec99026aebc8da Mon Sep 17 00:00:00 2001 From: DanielWalnut <45447813+hetaoBackend@users.noreply.github.com> Date: Wed, 23 Sep 2026 14:49:02 +0800 Subject: [PATCH 02/27] fix(tui): restore chat viewport after autocomplete shrinks (#331) --- docs/tui-capabilities.md | 3 + packages/tui/src/tui/app-composition.ts | 3 + .../tui/src/tui/engine/LOCAL_CHANGES.json | 12 +-- packages/tui/src/tui/engine/LOCAL_CHANGES.md | 8 ++ .../tui/src/tui/engine/components/editor.ts | 23 ++++++ .../tui/src/tui/engine/tui-main-screen.ts | 11 ++- packages/tui/src/tui/engine/tui.ts | 6 ++ packages/tui/src/tui/widgets/editor/editor.ts | 8 ++ packages/tui/test/unit/tui-app.test.ts | 79 +++++++++++++++++++ 9 files changed, 146 insertions(+), 7 deletions(-) diff --git a/docs/tui-capabilities.md b/docs/tui-capabilities.md index 61348fe7..dfd23df2 100644 --- a/docs/tui-capabilities.md +++ b/docs/tui-capabilities.md @@ -87,6 +87,9 @@ In regular mode, independent feature panels occupy the complete visible terminal area, including short Rewind previews and scope pickers. Closing a panel restores the current conversation. Closing a full-viewport interaction rebuilds the chat screen so its temporary rows do not leave a large blank area above the conversation. +Closing or shrinking a Composer completion menu also restores the exposed chat +rows. Short documents refresh in place; history is reconstructed only when the +smaller layout needs to bring scrolled rows back into view. When running content shrinks entirely within the current screen, the renderer keeps native scrollback and the Composer position stable. Freed rows temporarily remain blank at the top of the active screen and subsequent diff --git a/packages/tui/src/tui/app-composition.ts b/packages/tui/src/tui/app-composition.ts index 2a823ca6..4db84b77 100644 --- a/packages/tui/src/tui/app-composition.ts +++ b/packages/tui/src/tui/app-composition.ts @@ -208,6 +208,9 @@ export function createTuiApplicationEditor( autocompleteMaxVisible: 8, placeholder: composerText('placeholder'), }); + editor.onAutocompleteResize = (previousRows, rows) => { + if (tui.mode === 'regular' && rows < previousRows) tui.requestLayoutRender(); + }; editor.setAutocompleteProvider(createTuiInitialAutocomplete(workspaceRoots.list(), runtime)); return editor; } diff --git a/packages/tui/src/tui/engine/LOCAL_CHANGES.json b/packages/tui/src/tui/engine/LOCAL_CHANGES.json index 5c9eedcd..f29800f9 100644 --- a/packages/tui/src/tui/engine/LOCAL_CHANGES.json +++ b/packages/tui/src/tui/engine/LOCAL_CHANGES.json @@ -21,8 +21,8 @@ }, { "path": "components/editor.ts", - "currentSha256": "0775a490df136d1083738cce649e026d6153ecb5480715b2c1104d51051968c5", - "changeIds": ["L005", "L009", "L022", "L030", "L032"], + "currentSha256": "0c257eb6225d4847a78152b14bd12fe299223541b193a5f4bc0c5667680c7b52", + "changeIds": ["L005", "L009", "L022", "L030", "L032", "L041"], "reason": "Keep strict TypeScript fixes and add the smallest generic snapshot, paste, telemetry, programmatic submission, undo-extension and empty-placeholder hooks needed by the MCode product wrapper. 提供方通过上下文回调控制自动触发,并通过 applyOnEnter 控制 Enter 是否填入候选。 强制补全上下文失效时立即取消请求并清空菜单。 支持单帧 placeholder 覆盖,由 Editor 统一保留 padding、可见光标和 IME 定位标记。 命令参数候选标记阶段,接受命令后续查参数,并隔离参数回调失败。", "behaviorImpact": "MCode Draft, attachment, product-level submission-intent and empty-input guidance semantics use the canonical Pi Editor cursor, width, padding, history, undo, paste-marker and autocomplete behavior. Shell 补全只由 Tab 打开,菜单内输入继续过滤;Enter 执行当前输入,Tab 填入选中项。其他提供方沿用默认行为。 删除 Shell 标记或命令名前缀后立即关闭候选;已取消请求的延迟结果无法恢复旧菜单。 带参数候选的命令支持连续补全,参数 Enter 仅填入,Esc 保留草稿;无参数命令保持原行为。" }, @@ -163,16 +163,16 @@ }, { "path": "tui-main-screen.ts", - "currentSha256": "5d499306f4b92d443c350dd944fbdc11b6ed7c74a030eb1e7e020890bc30fdb7", - "changeIds": ["L005", "L017", "L027", "L033", "L034", "L037", "L038", "L039"], + "currentSha256": "f0127d16377ed9f159df5e2e78aeae11f9b05bbb857b0ea31ff944dea6869226", + "changeIds": ["L005", "L017", "L027", "L033", "L034", "L037", "L038", "L039", "L041"], "upstreamCommit": "6c4f360264397c59801f6da2bdac13e3b1fcbe91", "reason": "Keep strict TypeScript fixes and stream full and differential renders through Pi's bounded terminal writer. 缩放期间仅重绘可见尾部 常规模式在差分比较前剥离行首 OSC 133 zone 标记。 内容收缩或历史内容变化触发回退重绘时仅更新可见区域。", "behaviorImpact": "Regular viewport redraws erase rows in place so hosts that save an erased screen to scrollback do not retain stale transcript or footer rows. Visible text-only shrink with unchanged historical text temporarily pads the active screen to preserve host scrolling and the input position; later output reuses this space. Historical text replacement or removal still reconstructs the session to avoid stale or duplicate history. Rebuilding clears pre-launch shell scrollback. Genuine resize retains delayed history replay; redundant same-size notifications are ignored." }, { "path": "tui.ts", - "currentSha256": "cea30262a99f7e22dc03c51d4f6dfb9a949aee183c7da1d6138c5eeff77a7ea3", - "changeIds": ["L005", "L015", "L023", "L027", "L028", "L031", "L040"], + "currentSha256": "27c80c6c58f0eb35a4f3e199123aca47c95296abd4dd46b922df770effa9d42a", + "changeIds": ["L005", "L015", "L023", "L027", "L028", "L031", "L040", "L041"], "reason": "Keep strict TypeScript fixes, expose Pi's existing immediate scheduler as a non-destructive product interaction contract, dispatch the input left over after terminal color sequences are removed, and coalesce synchronous submission renders. 提供 resize hook 及焦点输入过滤 独立面板声明键盘分页归属。", "behaviorImpact": "Urgent product interactions render immediately without resetting differential state or clearing native scrollback, and a coalesced color answer no longer discards the keystrokes sharing its chunk. A synchronous submission frame dismisses the previous interrupted footer without a second input render. 焦点先交给 viewport listener,不进入编辑器。 handlesViewportKeys 为 true 时,fullscreen 分页交给焦点面板;默认仍由外层视口处理。" }, diff --git a/packages/tui/src/tui/engine/LOCAL_CHANGES.md b/packages/tui/src/tui/engine/LOCAL_CHANGES.md index e9c7d159..31e2bbb8 100644 --- a/packages/tui/src/tui/engine/LOCAL_CHANGES.md +++ b/packages/tui/src/tui/engine/LOCAL_CHANGES.md @@ -162,3 +162,11 @@ Remove `L024` when the selected Pi baseline natively matches legacy-terminal `Ct - User impact: the previous interrupted footer disappears with the submitted message, and the extra no-op render after Enter is avoided. - Evidence: `tui-app.test.ts` checks every presented frame across interrupt and resend; `tui-engine-local-deltas.test.ts` checks that a synchronous input render has no second pass. - Removal condition: the selected Pi baseline coalesces synchronous input renders while preserving immediate key rendering. + +## L041: Restore chat rows after inline completion shrink + +- Product contract: closing or filtering an inline completion menu restores the conversation instead of leaving the released rows blank above it. Ordinary activity shrink retains L038's native scrolling behavior. +- Minimal difference: Editor reports completion row-count changes relative to its last rendered menu, including asynchronous results and grouped-list navigation. The product requests a layout render on shrink in regular mode. Main-screen layout renders skip L038 padding for one frame, allowing L034 reconstruction only when scrolled rows must return; short documents retain differential rendering. Disposal disables the callback before cancellation. +- Evidence: product VirtualTerminal tests exercise Escape, Tab, Backspace, asynchronous empty results and filtering, compare the complete visible frame, and check unique long history under xterm and an ED 2 clear-to-scrollback model. Short-document and fullscreen cases avoid unnecessary reconstruction; existing activity-shrink regressions remain intact. +- Boundary: full history reconstruction retains L034's shell-scrollback tradeoff. Emulator tests do not establish native terminal or live-service acceptance. +- Removal condition: the selected Pi baseline distinguishes inline completion layout shrink from ordinary visible activity shrink. diff --git a/packages/tui/src/tui/engine/components/editor.ts b/packages/tui/src/tui/engine/components/editor.ts index 999096c7..38bae221 100644 --- a/packages/tui/src/tui/engine/components/editor.ts +++ b/packages/tui/src/tui/engine/components/editor.ts @@ -331,6 +331,7 @@ export class Editor implements Component, Focusable { private autocompleteTriggerPattern = buildTriggerPattern(this.autocompleteTriggerCharacters); private autocompleteDebouncePattern = buildDebouncePattern(this.autocompleteTriggerCharacters); private autocompleteList?: SelectList; + private renderedAutocompleteRows = 0; private autocompleteSuggestions?: AutocompleteSuggestions; private autocompleteState: 'regular' | 'force' | null = null; private autocompletePrefix: string = ''; @@ -381,6 +382,8 @@ export class Editor implements Component, Focusable { public onChange?: (text: string) => void; public onPaste?: (text: string) => boolean; public onAutocompleteView?: (suggestions: AutocompleteSuggestions) => void; + /** Lets hosts restore space occupied by a shrinking inline completion menu. */ + public onAutocompleteResize?: (previousRows: number, rows: number) => void; public onAutocompleteSelect?: ( suggestions: AutocompleteSuggestions, item: AutocompleteSuggestions['items'][number], @@ -695,8 +698,10 @@ export class Editor implements Component, Focusable { } // Add autocomplete list if active + this.renderedAutocompleteRows = 0; if (this.autocompleteState && this.autocompleteList) { const autocompleteResult = this.autocompleteList.render(contentWidth); + this.renderedAutocompleteRows = autocompleteResult.length; for (const line of autocompleteResult) { const lineWidth = visibleWidth(line); const linePadding = ' '.repeat(Math.max(0, contentWidth - lineWidth)); @@ -781,6 +786,7 @@ export class Editor implements Component, Focusable { if (kb.matches(data, 'tui.select.up') || kb.matches(data, 'tui.select.down')) { this.autocompleteList.handleInput(data); + this.notifyAutocompleteResize(); return; } @@ -1214,6 +1220,7 @@ export class Editor implements Component, Focusable { } dispose(): void { + this.onAutocompleteResize = undefined; this.cancelAutocomplete(); this.autocompleteProvider = undefined; this.onPaste = undefined; @@ -2632,6 +2639,7 @@ export class Editor implements Component, Focusable { } this.autocompleteState = state; + this.notifyAutocompleteResize(); const kind = suggestions.prefix.startsWith('/') ? '/' : suggestions.prefix.startsWith('@') @@ -2659,6 +2667,21 @@ export class Editor implements Component, Focusable { this.autocompleteSuggestions = undefined; this.autocompletePrefix = ''; this.reportedAutocompleteKind = undefined; + this.notifyAutocompleteResize(); + } + + private autocompleteRows(): number { + if (!this.onAutocompleteResize || !this.autocompleteState || !this.autocompleteList) return 0; + // SelectList owns group headings and pagination rows; reuse its layout rather + // than assuming one row per candidate. Its rows do not wrap with terminal width. + return this.autocompleteList.render(this.tui.terminal.columns).length; + } + + private notifyAutocompleteResize(): void { + const rows = this.autocompleteRows(); + if (rows !== this.renderedAutocompleteRows) { + this.onAutocompleteResize?.(this.renderedAutocompleteRows, rows); + } } private cancelAutocomplete(): void { diff --git a/packages/tui/src/tui/engine/tui-main-screen.ts b/packages/tui/src/tui/engine/tui-main-screen.ts index 44901c93..bfe68eb7 100644 --- a/packages/tui/src/tui/engine/tui-main-screen.ts +++ b/packages/tui/src/tui/engine/tui-main-screen.ts @@ -129,6 +129,12 @@ export class TuiMainScreen extends TuiBase implements TUI { private previousViewportTop = 0; private resizeTimer: ReturnType | undefined; private historyReplayPending = false; + private layoutRenderPending = false; + + override requestLayoutRender(): void { + this.layoutRenderPending = true; + super.requestLayoutRender(); + } protected override onTerminalResize(): void { // Some hosts repeat resize notifications while scrolling or reconnecting. @@ -184,6 +190,7 @@ export class TuiMainScreen extends TuiBase implements TUI { } protected override resetRenderState(): void { + this.layoutRenderPending = false; this.cancelResize(); this.historyReplayPending = false; this.previousLines = []; @@ -290,6 +297,8 @@ export class TuiMainScreen extends TuiBase implements TUI { return targetScreenRow - currentScreenRow; }; + const layoutRender = this.layoutRenderPending; + this.layoutRenderPending = false; // Render all components to get new lines. Strip OSC 133 zone sentinels before the // differential compare so they never enter previousLines or any terminal write. let newLines = this.render(width).map((line) => line.replace(OSC133_ZONE_PREFIX, "")); @@ -304,7 +313,7 @@ export class TuiMainScreen extends TuiBase implements TUI { // screen instead. The composer stays at the bottom, historical rows stay unique, // and later output consumes this temporary space before scrolling again. if ( - !widthChanged && !heightChanged && !this.historyReplayPending && !this.hasOverlayEntries && + !layoutRender && !widthChanged && !heightChanged && !this.historyReplayPending && !this.hasOverlayEntries && prevViewportTop > 0 && newLines.length > prevViewportTop && newLines.length < prevViewportTop + height && this.previousKittyImageIds.size === 0 && !newLines.some(isImageLine) diff --git a/packages/tui/src/tui/engine/tui.ts b/packages/tui/src/tui/engine/tui.ts index 89cd0bb3..7e3749a1 100644 --- a/packages/tui/src/tui/engine/tui.ts +++ b/packages/tui/src/tui/engine/tui.ts @@ -312,6 +312,8 @@ export interface TUI extends Component { renderNow(force?: boolean): void; requestRender(force?: boolean): void; requestImmediateRender(): void; + /** Restore rows exposed by a shrinking interactive layout instead of padding them. */ + requestLayoutRender(): void; addInputListener(listener: TuiInputListener): () => void; removeInputListener(listener: TuiInputListener): void; onTerminalColorSchemeChange(listener: (scheme: TerminalColorScheme) => void): () => void; @@ -785,6 +787,10 @@ export abstract class TuiBase extends Container implements TUI { process.nextTick(() => this.scheduleRender()); } + requestLayoutRender(): void { + this.requestImmediateRender(); + } + requestImmediateRender(): void { this.cancelRenderTimer(); this.renderRequested = true; diff --git a/packages/tui/src/tui/widgets/editor/editor.ts b/packages/tui/src/tui/widgets/editor/editor.ts index f81e3eff..8cc6ac7a 100644 --- a/packages/tui/src/tui/widgets/editor/editor.ts +++ b/packages/tui/src/tui/widgets/editor/editor.ts @@ -80,6 +80,14 @@ export class Editor implements Component, Focusable { return this.engine.focused; } + get onAutocompleteResize(): PiEditor['onAutocompleteResize'] { + return this.engine.onAutocompleteResize; + } + + set onAutocompleteResize(callback: PiEditor['onAutocompleteResize']) { + this.engine.onAutocompleteResize = callback; + } + set focused(value: boolean) { this.engine.focused = value; } diff --git a/packages/tui/test/unit/tui-app.test.ts b/packages/tui/test/unit/tui-app.test.ts index 6e298b00..670d8cdb 100644 --- a/packages/tui/test/unit/tui-app.test.ts +++ b/packages/tui/test/unit/tui-app.test.ts @@ -2968,6 +2968,7 @@ describe("createTuiApp", () => { app.start(); await app.ready; + const requestLayoutRender = vi.spyOn(app.tui, "requestLayoutRender"); terminal.input?.("/"); await vi.waitFor(() => @@ -2983,6 +2984,7 @@ describe("createTuiApp", () => { terminal.input?.("\u001B"); expect(app.editor.getText()).toBe("/"); expect(app.editor.focused).toBe(true); + expect(requestLayoutRender).not.toHaveBeenCalled(); await app.stop(); }); @@ -3026,6 +3028,83 @@ describe("createTuiApp", () => { await app.stop(); }); + describe.each([ + ["xterm", VirtualTerminal], + ["clear-to-scrollback terminal", ClearToScrollbackTerminal], + ] as const)("autocomplete viewport restoration on %s", (_terminalName, Terminal) => { + it.each([ + ["Escape", "\x1b", "/"], + ["Tab", "\t", "/help "], + ["Backspace", "\x7f", ""], + ["no matches", "zzzzzz", "/zzzzzz"], + ["filter to one command", "help", "/help"], + ] as const)("restores the conversation viewport when Slash Command autocomplete shrinks via %s", async (_name, input, draft) => { + const terminal = new Terminal(80, 24); + const app = createTuiApp({ + runtime: createRuntime(), + terminal, + version: "0.2.0", + workspaceDir: "/workspace", + tuiMode: "regular", + }); + + app.start(); + try { + await app.ready; + for (let index = 0; index < 12; index++) await app.submit(`Message ${index}`); + app.tui.renderNow(); + await terminal.flush(); + + terminal.sendInput("/"); + await vi.waitFor(async () => { + app.tui.renderNow(); + await terminal.flush(); + expect(terminal.getViewport().join("\n")).toContain("Show available commands"); + }); + const menuRows = app.editor.render(78).length; + terminal.sendInput(input); + await vi.waitFor(async () => { + app.tui.renderNow(); + await terminal.flush(); + expect(app.editor.getText()).toBe(draft); + expect(app.editor.render(78).length).toBeLessThan(menuRows); + const expected = app.tui.render(80).map((line) => stripAnsi(line).trimEnd()).slice(-24); + expect(terminal.getViewport().map((line) => line.trimEnd())).toEqual(expected); + }); + const history = terminal.getScrollBuffer(); + for (let index = 0; index < 12; index++) { + expect(history.filter((line) => line.trimEnd().endsWith(`› Message ${index}`))).toHaveLength(1); + } + } finally { + await app.stop(); + } + }); + }); + + it("dismisses autocomplete in a short document without clearing native history", async () => { + const terminal = new VirtualTerminal(80, 100); + const app = createTuiApp({ runtime: createRuntime(), terminal, version: "0.2.0", workspaceDir: "/workspace" }); + const write = vi.spyOn(terminal, "write"); + app.start(); + try { + await app.ready; + terminal.sendInput("/"); + await vi.waitFor(async () => { + app.tui.renderNow(); + await terminal.flush(); + expect(terminal.getViewport().join("\n")).toContain("Show available commands"); + }); + write.mockClear(); + terminal.sendInput("\x1b"); + app.tui.renderNow(); + await terminal.flush(); + expect(terminal.getViewport().join("\n")).not.toContain("Show available commands"); + expect(write.mock.calls.map(([data]) => data).join("")).not.toContain("\x1b[3J"); + } finally { + await app.stop(); + } + }); + it("resumes fullscreen tail following when submitting from scrolled history", async () => { const terminal = new FakeTerminal(); terminal.rows = 6; From 76262afff95962f68d793a5e85a4b23b7a5ff8fa Mon Sep 17 00:00:00 2001 From: DanielWalnut <45447813+hetaoBackend@users.noreply.github.com> Date: Wed, 23 Sep 2026 15:11:48 +0800 Subject: [PATCH 03/27] fix(tui): restore chat after transient layout shrink (#333) --- docs/tui-capabilities.md | 17 +- packages/tui/src/tui/app-composition.ts | 5 +- packages/tui/src/tui/app.ts | 5 +- .../tui/src/tui/engine/LOCAL_CHANGES.json | 8 +- packages/tui/src/tui/engine/LOCAL_CHANGES.md | 12 +- .../tui/src/tui/engine/components/editor.ts | 23 -- .../tui/src/tui/engine/tui-main-screen.ts | 35 ++- packages/tui/src/tui/engine/tui.ts | 13 +- packages/tui/src/tui/shell/chat-layout.ts | 14 ++ .../tui/src/tui/shell/interaction-surface.ts | 6 +- packages/tui/src/tui/shell/surface-host.ts | 5 + packages/tui/src/tui/widgets/editor/editor.ts | 8 - packages/tui/test/unit/tui-app.test.ts | 64 +++++- .../unit/tui-background-work-panel.test.ts | 7 +- .../test/unit/tui-engine-local-deltas.test.ts | 217 ++++++++++++++++++ .../tui/test/unit/tui-surface-host.test.ts | 11 +- 16 files changed, 365 insertions(+), 85 deletions(-) diff --git a/docs/tui-capabilities.md b/docs/tui-capabilities.md index dfd23df2..a67fb9f2 100644 --- a/docs/tui-capabilities.md +++ b/docs/tui-capabilities.md @@ -85,13 +85,16 @@ and initial prompt are not applied. In regular mode, independent feature panels occupy the complete visible terminal area, including short Rewind previews and scope pickers. Closing a panel restores -the current conversation. Closing a full-viewport interaction rebuilds the chat -screen so its temporary rows do not leave a large blank area above the conversation. -Closing or shrinking a Composer completion menu also restores the exposed chat -rows. Short documents refresh in place; history is reconstructed only when the -smaller layout needs to bring scrolled rows back into view. -When running content shrinks entirely within the current screen, the renderer -keeps native scrollback and the Composer position stable. +the current conversation. Closing, replacing or shrinking a transient region +restores the exposed chat rows. This includes inline selectors such as `/theme`, +completion menus, multi-line drafts, image previews, queued messages, task and +Goal summaries, welcome notices and status rows. Short documents refresh in place; +history is reconstructed only when the smaller layout needs to bring scrolled +rows back into view. This rule follows the rendered layout, including asynchronous +updates, rather than requiring each close handler to request a special redraw. +When background running content shrinks entirely within the current screen and +the transient layout stays unchanged, the renderer keeps native scrollback and +the Composer position stable. Freed rows temporarily remain blank at the top of the active screen and subsequent output reuses them. This avoids resetting the host's scroll position when a turn finishes. Redundant resize notifications with unchanged dimensions do not rebuild diff --git a/packages/tui/src/tui/app-composition.ts b/packages/tui/src/tui/app-composition.ts index 4db84b77..6273fc7d 100644 --- a/packages/tui/src/tui/app-composition.ts +++ b/packages/tui/src/tui/app-composition.ts @@ -208,9 +208,6 @@ export function createTuiApplicationEditor( autocompleteMaxVisible: 8, placeholder: composerText('placeholder'), }); - editor.onAutocompleteResize = (previousRows, rows) => { - if (tui.mode === 'regular' && rows < previousRows) tui.requestLayoutRender(); - }; editor.setAutocompleteProvider(createTuiInitialAutocomplete(workspaceRoots.list(), runtime)); return editor; } @@ -428,7 +425,7 @@ export function createTuiApplicationSurface(options: { readonly liveRunId: (snapshot?: TuiChatSnapshot) => string | undefined; readonly shouldResumeDraftAfterLogin: () => boolean; readonly isActive: () => boolean; - readonly requestInteractionRender: (rebuild?: boolean) => void; + readonly requestInteractionRender: () => void; readonly mode: () => TuiMode; readonly switchMode: (mode: TuiMode) => boolean; readonly chatMode: TuiMode; diff --git a/packages/tui/src/tui/app.ts b/packages/tui/src/tui/app.ts index ab9207c9..6d556331 100644 --- a/packages/tui/src/tui/app.ts +++ b/packages/tui/src/tui/app.ts @@ -820,10 +820,9 @@ export function createTuiApp(options: CreateTuiAppOptions): TuiApp { activity, composer, }); - function requestInteractionRender(rebuild = false): void { + function requestInteractionRender(): void { if (!started || stopped) return; - if (rebuild) tui.requestRender(true); - else tui.requestImmediateRender(); + tui.requestImmediateRender(); } const controllerReady = controller.initialize(); const ready = controllerReady.then(async () => { diff --git a/packages/tui/src/tui/engine/LOCAL_CHANGES.json b/packages/tui/src/tui/engine/LOCAL_CHANGES.json index f29800f9..f39b20b7 100644 --- a/packages/tui/src/tui/engine/LOCAL_CHANGES.json +++ b/packages/tui/src/tui/engine/LOCAL_CHANGES.json @@ -21,8 +21,8 @@ }, { "path": "components/editor.ts", - "currentSha256": "0c257eb6225d4847a78152b14bd12fe299223541b193a5f4bc0c5667680c7b52", - "changeIds": ["L005", "L009", "L022", "L030", "L032", "L041"], + "currentSha256": "0775a490df136d1083738cce649e026d6153ecb5480715b2c1104d51051968c5", + "changeIds": ["L005", "L009", "L022", "L030", "L032"], "reason": "Keep strict TypeScript fixes and add the smallest generic snapshot, paste, telemetry, programmatic submission, undo-extension and empty-placeholder hooks needed by the MCode product wrapper. 提供方通过上下文回调控制自动触发,并通过 applyOnEnter 控制 Enter 是否填入候选。 强制补全上下文失效时立即取消请求并清空菜单。 支持单帧 placeholder 覆盖,由 Editor 统一保留 padding、可见光标和 IME 定位标记。 命令参数候选标记阶段,接受命令后续查参数,并隔离参数回调失败。", "behaviorImpact": "MCode Draft, attachment, product-level submission-intent and empty-input guidance semantics use the canonical Pi Editor cursor, width, padding, history, undo, paste-marker and autocomplete behavior. Shell 补全只由 Tab 打开,菜单内输入继续过滤;Enter 执行当前输入,Tab 填入选中项。其他提供方沿用默认行为。 删除 Shell 标记或命令名前缀后立即关闭候选;已取消请求的延迟结果无法恢复旧菜单。 带参数候选的命令支持连续补全,参数 Enter 仅填入,Esc 保留草稿;无参数命令保持原行为。" }, @@ -163,7 +163,7 @@ }, { "path": "tui-main-screen.ts", - "currentSha256": "f0127d16377ed9f159df5e2e78aeae11f9b05bbb857b0ea31ff944dea6869226", + "currentSha256": "9d5c2b83b6b3f0b614f9746a398025eb2c9d324a83959138e77728a08679939c", "changeIds": ["L005", "L017", "L027", "L033", "L034", "L037", "L038", "L039", "L041"], "upstreamCommit": "6c4f360264397c59801f6da2bdac13e3b1fcbe91", "reason": "Keep strict TypeScript fixes and stream full and differential renders through Pi's bounded terminal writer. 缩放期间仅重绘可见尾部 常规模式在差分比较前剥离行首 OSC 133 zone 标记。 内容收缩或历史内容变化触发回退重绘时仅更新可见区域。", @@ -171,7 +171,7 @@ }, { "path": "tui.ts", - "currentSha256": "27c80c6c58f0eb35a4f3e199123aca47c95296abd4dd46b922df770effa9d42a", + "currentSha256": "96591553bc244dbe431de5bff46f57a874d83cd5fe623c94b47b637f83afcec0", "changeIds": ["L005", "L015", "L023", "L027", "L028", "L031", "L040", "L041"], "reason": "Keep strict TypeScript fixes, expose Pi's existing immediate scheduler as a non-destructive product interaction contract, dispatch the input left over after terminal color sequences are removed, and coalesce synchronous submission renders. 提供 resize hook 及焦点输入过滤 独立面板声明键盘分页归属。", "behaviorImpact": "Urgent product interactions render immediately without resetting differential state or clearing native scrollback, and a coalesced color answer no longer discards the keystrokes sharing its chunk. A synchronous submission frame dismisses the previous interrupted footer without a second input render. 焦点先交给 viewport listener,不进入编辑器。 handlesViewportKeys 为 true 时,fullscreen 分页交给焦点面板;默认仍由外层视口处理。" diff --git a/packages/tui/src/tui/engine/LOCAL_CHANGES.md b/packages/tui/src/tui/engine/LOCAL_CHANGES.md index 31e2bbb8..50b72941 100644 --- a/packages/tui/src/tui/engine/LOCAL_CHANGES.md +++ b/packages/tui/src/tui/engine/LOCAL_CHANGES.md @@ -142,7 +142,7 @@ Remove `L024` when the selected Pi baseline natively matches legacy-terminal `Ct ## L038: Preserve native scrolling during visible content shrink - Product contract: settling visible activity rows must not clear native scrollback or pin a scrolled host viewport to the top. The Composer and status remain at the bottom, and historical content remains unique. -- Minimal difference: when terminal geometry and the text already in scrollback are unchanged, absorb visible text-only shrink with blank rows at the current screen boundary before cursor extraction and differential rendering. Subsequent output consumes the space before advancing native history. Ignore redundant same-size resize notifications without cancelling a genuine pending resize replay. +- Minimal difference: when terminal geometry, the text already in scrollback and the declared transient layout keys are unchanged, absorb visible text-only shrink with blank rows at the current screen boundary before cursor extraction and differential rendering. L041 makes this an explicit background-content policy; unclassified layouts restore exposed rows. Subsequent output consumes the space before advancing native history. Ignore redundant same-size resize notifications without cancelling a genuine pending resize replay. - Boundary: padding is confined to the active screen. Historical text replacement/removal, real resize, overlays and image reflow retain the structural reconstruction path. Blank rows can temporarily separate native history from the visible tail; this is preferable to clearing and replaying the terminal's scrollback during ordinary completion. No mouse capture is enabled in regular mode. - Evidence: local-delta tests use xterm's host scroll API independently of the hardware cursor, reproduce the pre-fix jump to line zero, and verify stable scrolling, Composer position, unique history, reclaimed space, corrected-history reconstruction and resize behavior. The product queue/feature tests continue to cover canonical history replacement. Native Windows Terminal and UU Remote acceptance remain separate. - Removal condition: the selected Pi baseline preserves host scrolling and unique history through visible shrink. @@ -163,10 +163,10 @@ Remove `L024` when the selected Pi baseline natively matches legacy-terminal `Ct - Evidence: `tui-app.test.ts` checks every presented frame across interrupt and resend; `tui-engine-local-deltas.test.ts` checks that a synchronous input render has no second pass. - Removal condition: the selected Pi baseline coalesces synchronous input renders while preserving immediate key rendering. -## L041: Restore chat rows after inline completion shrink +## L041: Restore chat rows after transient layout shrink -- Product contract: closing or filtering an inline completion menu restores the conversation instead of leaving the released rows blank above it. Ordinary activity shrink retains L038's native scrolling behavior. -- Minimal difference: Editor reports completion row-count changes relative to its last rendered menu, including asynchronous results and grouped-list navigation. The product requests a layout render on shrink in regular mode. Main-screen layout renders skip L038 padding for one frame, allowing L034 reconstruction only when scrolled rows must return; short documents retain differential rendering. Disposal disables the callback before cancellation. -- Evidence: product VirtualTerminal tests exercise Escape, Tab, Backspace, asynchronous empty results and filtering, compare the complete visible frame, and check unique long history under xterm and an ED 2 clear-to-scrollback model. Short-document and fullscreen cases avoid unnecessary reconstruction; existing activity-shrink regressions remain intact. +- Product contract: shrinking a transient UI region restores the conversation instead of leaving released rows blank above it. Background activity shrink with unchanged transient layout retains L038's native scrolling behavior. +- Minimal difference: components may expose the layout key of their last rendered frame. MainScreen permits L038 padding only when every root explicitly supplies the same key and no overlay was present. ChatLayout includes every transient section's height and interaction state, while SurfaceHost includes the active feature. Unknown or changed layouts use L034 reconstruction only when scrolled rows must return. Keys are captured with native render state and cleared on reset. This replaces the earlier completion-specific resize callback and full-viewport close exception. +- Evidence: application tests replay `/theme`, `/settings`, prompt-history search, image-preview dismissal, multi-line draft clearing and completion filtering. Engine tests repeatedly expand/shrink each transient section under xterm and an ED 2 clear-to-scrollback model, compare the complete viewport, verify unique history, and retain positive background-activity scroll preservation. Short documents avoid unnecessary clearing. - Boundary: full history reconstruction retains L034's shell-scrollback tradeoff. Emulator tests do not establish native terminal or live-service acceptance. -- Removal condition: the selected Pi baseline distinguishes inline completion layout shrink from ordinary visible activity shrink. +- Removal condition: the selected Pi baseline distinguishes transient UI layout shrink from ordinary background content shrink. diff --git a/packages/tui/src/tui/engine/components/editor.ts b/packages/tui/src/tui/engine/components/editor.ts index 38bae221..999096c7 100644 --- a/packages/tui/src/tui/engine/components/editor.ts +++ b/packages/tui/src/tui/engine/components/editor.ts @@ -331,7 +331,6 @@ export class Editor implements Component, Focusable { private autocompleteTriggerPattern = buildTriggerPattern(this.autocompleteTriggerCharacters); private autocompleteDebouncePattern = buildDebouncePattern(this.autocompleteTriggerCharacters); private autocompleteList?: SelectList; - private renderedAutocompleteRows = 0; private autocompleteSuggestions?: AutocompleteSuggestions; private autocompleteState: 'regular' | 'force' | null = null; private autocompletePrefix: string = ''; @@ -382,8 +381,6 @@ export class Editor implements Component, Focusable { public onChange?: (text: string) => void; public onPaste?: (text: string) => boolean; public onAutocompleteView?: (suggestions: AutocompleteSuggestions) => void; - /** Lets hosts restore space occupied by a shrinking inline completion menu. */ - public onAutocompleteResize?: (previousRows: number, rows: number) => void; public onAutocompleteSelect?: ( suggestions: AutocompleteSuggestions, item: AutocompleteSuggestions['items'][number], @@ -698,10 +695,8 @@ export class Editor implements Component, Focusable { } // Add autocomplete list if active - this.renderedAutocompleteRows = 0; if (this.autocompleteState && this.autocompleteList) { const autocompleteResult = this.autocompleteList.render(contentWidth); - this.renderedAutocompleteRows = autocompleteResult.length; for (const line of autocompleteResult) { const lineWidth = visibleWidth(line); const linePadding = ' '.repeat(Math.max(0, contentWidth - lineWidth)); @@ -786,7 +781,6 @@ export class Editor implements Component, Focusable { if (kb.matches(data, 'tui.select.up') || kb.matches(data, 'tui.select.down')) { this.autocompleteList.handleInput(data); - this.notifyAutocompleteResize(); return; } @@ -1220,7 +1214,6 @@ export class Editor implements Component, Focusable { } dispose(): void { - this.onAutocompleteResize = undefined; this.cancelAutocomplete(); this.autocompleteProvider = undefined; this.onPaste = undefined; @@ -2639,7 +2632,6 @@ export class Editor implements Component, Focusable { } this.autocompleteState = state; - this.notifyAutocompleteResize(); const kind = suggestions.prefix.startsWith('/') ? '/' : suggestions.prefix.startsWith('@') @@ -2667,21 +2659,6 @@ export class Editor implements Component, Focusable { this.autocompleteSuggestions = undefined; this.autocompletePrefix = ''; this.reportedAutocompleteKind = undefined; - this.notifyAutocompleteResize(); - } - - private autocompleteRows(): number { - if (!this.onAutocompleteResize || !this.autocompleteState || !this.autocompleteList) return 0; - // SelectList owns group headings and pagination rows; reuse its layout rather - // than assuming one row per candidate. Its rows do not wrap with terminal width. - return this.autocompleteList.render(this.tui.terminal.columns).length; - } - - private notifyAutocompleteResize(): void { - const rows = this.autocompleteRows(); - if (rows !== this.renderedAutocompleteRows) { - this.onAutocompleteResize?.(this.renderedAutocompleteRows, rows); - } } private cancelAutocomplete(): void { diff --git a/packages/tui/src/tui/engine/tui-main-screen.ts b/packages/tui/src/tui/engine/tui-main-screen.ts index bfe68eb7..8d4d1741 100644 --- a/packages/tui/src/tui/engine/tui-main-screen.ts +++ b/packages/tui/src/tui/engine/tui-main-screen.ts @@ -1,7 +1,7 @@ import * as fs from "node:fs"; import * as path from "node:path"; import { deleteKittyImage, isImageLine } from "./terminal-image.js"; -import { type TUI, TuiBase, type TuiStopOptions } from "./tui.js"; +import { type Component, type TUI, TuiBase, type TuiStopOptions } from "./tui.js"; import { stripTerminalSequences, visibleWidth } from "./utils.js"; const KITTY_SEQUENCE_PREFIX = "\x1b_G"; @@ -114,6 +114,8 @@ export interface TuiMainScreenRenderState { hardwareCursorRow: number; maxLinesRendered: number; previousViewportTop: number; + viewportLayouts: { component: Component; key: string | undefined }[]; + hadOverlays: boolean; } /** TUI implementation that renders into the terminal's main screen and scrollback. */ @@ -129,12 +131,8 @@ export class TuiMainScreen extends TuiBase implements TUI { private previousViewportTop = 0; private resizeTimer: ReturnType | undefined; private historyReplayPending = false; - private layoutRenderPending = false; - - override requestLayoutRender(): void { - this.layoutRenderPending = true; - super.requestLayoutRender(); - } + private viewportLayouts: TuiMainScreenRenderState['viewportLayouts'] = []; + private hadOverlays = false; protected override onTerminalResize(): void { // Some hosts repeat resize notifications while scrolling or reconnecting. @@ -173,6 +171,8 @@ export class TuiMainScreen extends TuiBase implements TUI { hardwareCursorRow: this.hardwareCursorRow, maxLinesRendered: this.maxLinesRendered, previousViewportTop: this.previousViewportTop, + viewportLayouts: this.viewportLayouts.map((layout) => ({ ...layout })), + hadOverlays: this.hadOverlays, }; } @@ -187,10 +187,13 @@ export class TuiMainScreen extends TuiBase implements TUI { this.hardwareCursorRow = state.hardwareCursorRow; this.maxLinesRendered = state.maxLinesRendered; this.previousViewportTop = state.previousViewportTop; + this.viewportLayouts = state.viewportLayouts.map((layout) => ({ ...layout })); + this.hadOverlays = state.hadOverlays; } protected override resetRenderState(): void { - this.layoutRenderPending = false; + this.viewportLayouts = []; + this.hadOverlays = false; this.cancelResize(); this.historyReplayPending = false; this.previousLines = []; @@ -297,11 +300,21 @@ export class TuiMainScreen extends TuiBase implements TUI { return targetScreenRow - currentScreenRow; }; - const layoutRender = this.layoutRenderPending; - this.layoutRenderPending = false; // Render all components to get new lines. Strip OSC 133 zone sentinels before the // differential compare so they never enter previousLines or any terminal write. let newLines = this.render(width).map((line) => line.replace(OSC133_ZONE_PREFIX, "")); + const viewportLayouts = this.children.map((component) => ({ + component, + key: component.getViewportLayoutKey?.(), + })); + const stableLayout = viewportLayouts.length > 0 && + viewportLayouts.length === this.viewportLayouts.length && + viewportLayouts.every(({ component, key }, index) => + key !== undefined && component === this.viewportLayouts[index]?.component && + key === this.viewportLayouts[index]?.key); + this.viewportLayouts = viewportLayouts; + const hadOverlays = this.hadOverlays; + this.hadOverlays = this.hasOverlayEntries; // Composite overlays into the rendered lines (before differential compare) if (this.hasOverlayEntries) { @@ -313,7 +326,7 @@ export class TuiMainScreen extends TuiBase implements TUI { // screen instead. The composer stays at the bottom, historical rows stay unique, // and later output consumes this temporary space before scrolling again. if ( - !layoutRender && !widthChanged && !heightChanged && !this.historyReplayPending && !this.hasOverlayEntries && + stableLayout && !hadOverlays && !widthChanged && !heightChanged && !this.historyReplayPending && !this.hasOverlayEntries && prevViewportTop > 0 && newLines.length > prevViewportTop && newLines.length < prevViewportTop + height && this.previousKittyImageIds.size === 0 && !newLines.some(isImageLine) diff --git a/packages/tui/src/tui/engine/tui.ts b/packages/tui/src/tui/engine/tui.ts index 7e3749a1..babec471 100644 --- a/packages/tui/src/tui/engine/tui.ts +++ b/packages/tui/src/tui/engine/tui.ts @@ -27,6 +27,13 @@ export interface Component { */ render(width: number): string[]; + /** + * Opt into preserving native scrolling when only background content shrinks. + * Return a key for the last rendered transient layout (menus, editor, banners). + * A changed or missing key restores exposed document rows instead of padding. + */ + getViewportLayoutKey?(): string | undefined; + /** * Optional handler for keyboard input when component has focus */ @@ -312,8 +319,6 @@ export interface TUI extends Component { renderNow(force?: boolean): void; requestRender(force?: boolean): void; requestImmediateRender(): void; - /** Restore rows exposed by a shrinking interactive layout instead of padding them. */ - requestLayoutRender(): void; addInputListener(listener: TuiInputListener): () => void; removeInputListener(listener: TuiInputListener): void; onTerminalColorSchemeChange(listener: (scheme: TerminalColorScheme) => void): () => void; @@ -787,10 +792,6 @@ export abstract class TuiBase extends Container implements TUI { process.nextTick(() => this.scheduleRender()); } - requestLayoutRender(): void { - this.requestImmediateRender(); - } - requestImmediateRender(): void { this.cancelRenderTimer(); this.renderRequested = true; diff --git a/packages/tui/src/tui/shell/chat-layout.ts b/packages/tui/src/tui/shell/chat-layout.ts index 79a74870..a8c4df22 100644 --- a/packages/tui/src/tui/shell/chat-layout.ts +++ b/packages/tui/src/tui/shell/chat-layout.ts @@ -41,6 +41,11 @@ interface MouseAwareComponent extends Component { } export class TuiChatLayout implements Component { + private viewportLayoutKey: string | undefined; + + getViewportLayoutKey(): string | undefined { + return this.viewportLayoutKey; + } readonly fullscreenLayoutRoot: Component; private readonly fullscreenBodyViewport: ScrollView; private pendingFullscreenFrame: @@ -233,8 +238,15 @@ export class TuiChatLayout implements Component { goal.length + followUp.length + activity.length + composer.length + status.length, surface === 'conversation' ? 2 : Math.max(1, notice.length + 1), ); + // Activity/transcript updates may preserve host scrolling. Every transient + // section participates here so new controls cannot silently leave blank rows. + const viewportLayout = [ + surface, interactionActive, interaction.length, composer.length, followUp.length, + goal.length, notice.length, tasks.length, status.length, + ]; if (surface === 'welcome') { if (interactionActive) { + this.viewportLayoutKey = JSON.stringify([...viewportLayout, 0]); return this.fitDocumentFrame( this.viewport() === 'fixed' ? [interaction, activity, composer, status] @@ -259,6 +271,7 @@ export class TuiChatLayout implements Component { frame.horizontalPadding, ) : renderPart(this.parts.welcome); + this.viewportLayoutKey = JSON.stringify([...viewportLayout, welcome.length]); return this.fitDocumentFrame([welcome, ...tailEntries]); } @@ -278,6 +291,7 @@ export class TuiChatLayout implements Component { ]; const transcript = renderPart(this.parts.transcript); const welcome = renderPart(this.parts.welcome); + this.viewportLayoutKey = JSON.stringify([...viewportLayout, welcome.length]); const prelude = joinWelcomeAndTranscript(welcome, transcript); const bodyEntries = [prelude, transcript.length > 0 ? [''] : []]; return this.fitDocumentFrame([...bodyEntries, ...footerEntries]); diff --git a/packages/tui/src/tui/shell/interaction-surface.ts b/packages/tui/src/tui/shell/interaction-surface.ts index 334ce475..288340bb 100644 --- a/packages/tui/src/tui/shell/interaction-surface.ts +++ b/packages/tui/src/tui/shell/interaction-surface.ts @@ -8,7 +8,7 @@ export class TuiInteractionSurface { constructor( private readonly host: TuiInlinePanelHost, private readonly surfaces: TuiSurfaceHost, - private readonly requestRender: (rebuild?: boolean) => void, + private readonly requestRender: () => void, private readonly onActiveChanged?: (active: boolean) => void, private readonly followFullscreenBottom?: () => void, ) {} @@ -38,13 +38,11 @@ export class TuiInteractionSurface { close(panel?: Component): boolean { if (!this.host.isActive(panel)) return false; - const rebuild = this.surfaces.getChatMode() === 'regular' && this.host.fullscreenViewport; if (this.layer && !this.layer.close()) return false; this.layer = undefined; if (!this.host.close(panel)) return false; this.onActiveChanged?.(false); - if (rebuild) this.requestRender(true); - else this.requestRender(); + this.requestRender(); return true; } diff --git a/packages/tui/src/tui/shell/surface-host.ts b/packages/tui/src/tui/shell/surface-host.ts index 7510050f..a13f18df 100644 --- a/packages/tui/src/tui/shell/surface-host.ts +++ b/packages/tui/src/tui/shell/surface-host.ts @@ -220,6 +220,11 @@ export class TuiSurfaceHost implements Component { else this.chatComponent.invalidate(); } + getViewportLayoutKey(): string | undefined { + const key = this.chatComponent.getViewportLayoutKey?.(); + return key === undefined ? undefined : JSON.stringify([this.activeFeature()?.screen.id, key]); + } + render(width: number): string[] { const active = this.activeFeature(); if (!active || this.currentMode() === 'regular') return this.chatComponent.render(width); diff --git a/packages/tui/src/tui/widgets/editor/editor.ts b/packages/tui/src/tui/widgets/editor/editor.ts index 8cc6ac7a..f81e3eff 100644 --- a/packages/tui/src/tui/widgets/editor/editor.ts +++ b/packages/tui/src/tui/widgets/editor/editor.ts @@ -80,14 +80,6 @@ export class Editor implements Component, Focusable { return this.engine.focused; } - get onAutocompleteResize(): PiEditor['onAutocompleteResize'] { - return this.engine.onAutocompleteResize; - } - - set onAutocompleteResize(callback: PiEditor['onAutocompleteResize']) { - this.engine.onAutocompleteResize = callback; - } - set focused(value: boolean) { this.engine.focused = value; } diff --git a/packages/tui/test/unit/tui-app.test.ts b/packages/tui/test/unit/tui-app.test.ts index 670d8cdb..fac9a117 100644 --- a/packages/tui/test/unit/tui-app.test.ts +++ b/packages/tui/test/unit/tui-app.test.ts @@ -2968,7 +2968,7 @@ describe("createTuiApp", () => { app.start(); await app.ready; - const requestLayoutRender = vi.spyOn(app.tui, "requestLayoutRender"); + const writesBefore = terminal.writes.length; terminal.input?.("/"); await vi.waitFor(() => @@ -2984,7 +2984,7 @@ describe("createTuiApp", () => { terminal.input?.("\u001B"); expect(app.editor.getText()).toBe("/"); expect(app.editor.focused).toBe(true); - expect(requestLayoutRender).not.toHaveBeenCalled(); + expect(terminal.writes.slice(writesBefore).join("")).not.toContain("\x1b[3J"); await app.stop(); }); @@ -6127,6 +6127,66 @@ describe("createTuiApp", () => { await app.stop(); }); + it.each(["/theme", "/settings", "history search"])("restores the complete chat viewport after closing %s", async (entry) => { + const terminal = new VirtualTerminal(80, 24); + const app = createTuiApp({ runtime: createRuntime(), terminal, version: "0.2.0", workspaceDir: "/workspace" }); + app.start(); + try { + await app.ready; + for (let index = 0; index < 12; index++) await app.submit(`Message ${index}`); + if (entry === "history search") { + for (let index = 0; index < 12; index++) app.editor.addToHistory(`Message ${index}`); + terminal.sendInput("\x12"); + } + else await app.submit(entry); + app.tui.renderNow(); + await terminal.flush(); + expect(app.interaction.isActive()).toBe(true); + terminal.sendInput("\x1b"); + app.tui.renderNow(); + await terminal.flush(); + expect(app.interaction.isActive()).toBe(false); + const expected = app.tui.render(80).map((line) => stripAnsi(line).trimEnd()).slice(-24); + expect(terminal.getViewport().map((line) => line.trimEnd())).toEqual(expected); + for (let index = 0; index < 12; index++) { + expect(terminal.getScrollBuffer().filter((line) => line.trimEnd().endsWith(`› Message ${index}`))).toHaveLength(1); + } + } finally { + await app.stop(); + } + }); + + it.each(["multiline draft", "image preview"])("restores the chat viewport after dismissing %s", async (kind) => { + const terminal = new ClearToScrollbackTerminal(80, 24); + const app = createTuiApp({ + runtime: createRuntime(), terminal, version: "0.2.0", workspaceDir: "/workspace", + resolveAttachment: async () => ({ type: "image", filePath: "/tmp/preview-missing.png", fileName: "preview.png", mimeType: "image/png", sizeBytes: 32768 }), + }); + app.start(); + try { + await app.ready; + for (let index = 0; index < 12; index++) await app.submit(`Message ${index}`); + if (kind === "multiline draft") { + app.editor.setText(Array.from({ length: 8 }, (_, index) => `Draft ${index}`).join("\n")); + } else { + terminal.sendInput("\x1b[200~/tmp/preview-missing.png\x1b[201~"); + await vi.waitFor(() => expect(app.editor.getAttachmentPreview()).toBeDefined()); + } + app.tui.renderNow(); + await terminal.flush(); + expect(terminal.getViewport().join("\n")).toContain(kind === "multiline draft" ? "Draft 7" : "preview.png"); + terminal.sendInput(kind === "multiline draft" ? "\x03" : "\x1b"); + app.tui.renderNow(); + await terminal.flush(); + if (kind === "multiline draft") expect(app.editor.getText()).toBe(""); + else expect(app.editor.getAttachmentPreview()).toBeUndefined(); + const expected = app.tui.render(80).map((line) => stripAnsi(line).trimEnd()).slice(-24); + expect(terminal.getViewport().map((line) => line.trimEnd())).toEqual(expected); + } finally { + await app.stop(); + } + }); + it("does not push the conversation downward after closing /usage", async () => { const terminal = new VirtualTerminal(80, 50); const runtime = createRuntime(); diff --git a/packages/tui/test/unit/tui-background-work-panel.test.ts b/packages/tui/test/unit/tui-background-work-panel.test.ts index 2ccd563a..592553fe 100644 --- a/packages/tui/test/unit/tui-background-work-panel.test.ts +++ b/packages/tui/test/unit/tui-background-work-panel.test.ts @@ -342,7 +342,12 @@ describe('Tasks in the regular terminal viewport', () => { const terminal = new VirtualTerminal(80, 16); const tui = new TuiMainScreen(terminal); const chatLines = Array.from({ length: 40 }, (_, index) => `chat-line-${index}`); - tui.addChild({ render: () => [...chatLines, 'COMPOSER', 'STATUS'], invalidate() {} }); + tui.addChild({ + render: () => [...chatLines, 'COMPOSER', 'STATUS'], + // This fixture settles background content while the transient footer stays fixed. + getViewportLayoutKey: () => 'composer:1,status:1', + invalidate() {}, + }); const tasks = Array.from({ length: 30 }, (_, index) => ({ ...backgroundTasks()[0]!, taskId: `task-${index}`, diff --git a/packages/tui/test/unit/tui-engine-local-deltas.test.ts b/packages/tui/test/unit/tui-engine-local-deltas.test.ts index da018735..8f5e95ce 100644 --- a/packages/tui/test/unit/tui-engine-local-deltas.test.ts +++ b/packages/tui/test/unit/tui-engine-local-deltas.test.ts @@ -11,6 +11,7 @@ import { type TUI, visibleWidth, } from '../../src/tui/engine/public.js'; +import { TuiChatLayout, type TuiChatLayoutParts } from '../../src/tui/shell/chat-layout.js'; import { VirtualTerminal } from '../pi-084-upstream/virtual-terminal.js'; const passthrough = (value: string): string => value; @@ -47,6 +48,11 @@ class ClearToScrollbackTerminal extends RecordingVirtualTerminal { class MutableLines implements Component { lines: string[] = []; + viewportLayoutKey: string | undefined; + + getViewportLayoutKey(): string | undefined { + return this.viewportLayoutKey; + } render(): string[] { return [...this.lines]; @@ -55,6 +61,25 @@ class MutableLines implements Component { invalidate(): void {} } +function createMutableChatParts(surface: 'welcome' | 'conversation') { + const parts = { + surface: () => surface, + welcome: new MutableLines(), + notice: new MutableLines(), + transcript: new MutableLines(), + interaction: new MutableLines(), + activity: new MutableLines(), + goal: new MutableLines(), + followUp: new MutableLines(), + tasks: new MutableLines(), + composer: new MutableLines(), + status: new MutableLines(), + } satisfies TuiChatLayoutParts; + parts.composer.lines = [`composer${CURSOR_MARKER}`]; + parts.status.lines = ['status']; + return parts; +} + describe('MCode Pi Engine local deltas', () => { it('does not replay a submitted input frame after a synchronous render', async () => { const terminal = new RecordingVirtualTerminal(60, 12); @@ -168,6 +193,7 @@ describe('MCode Pi Engine local deltas', () => { const terminal = new RecordingVirtualTerminal(60, 44); const tui = new TuiMainScreen(terminal); const component = new MutableLines(); + component.viewportLayoutKey = 'stable-footer'; const answer = Array.from({ length: 80 }, (_, index) => `Answer ${index}`); component.lines = [ ...answer, @@ -203,6 +229,194 @@ describe('MCode Pi Engine local deltas', () => { expect(terminal.getViewport().slice(-2)).toEqual(['composer', 'idle']); }); + describe.each([ + ['xterm', RecordingVirtualTerminal], + ['clear-to-scrollback host', ClearToScrollbackTerminal], + ] as const)('%s transient layout restoration', (_name, Terminal) => { + const transientParts = ['welcome', 'interaction', 'composer', 'notice', 'followUp', 'goal', 'tasks', 'status'] as const; + + it.each(transientParts)('restores the document tail after repeated %s collapses', async (part) => { + const terminal = new Terminal(60, 16); + const tui = new TuiMainScreen(terminal); + const parts = createMutableChatParts(part === 'notice' || part === 'welcome' ? 'welcome' : 'conversation'); + const history = Array.from({ length: 40 }, (_, index) => `History ${index}`); + (part === 'notice' || part === 'welcome' ? parts.welcome : parts.transcript).lines = history; + const layout = new TuiChatLayout(terminal, parts); + tui.addChild(layout); + tui.renderNow(); + await terminal.flush(); + const expected = terminal.getScrollBuffer(); + const baseline = [...parts[part].lines]; + + for (let cycle = 0; cycle < 3; cycle++) { + parts[part].lines = [ + ...(part === 'welcome' ? baseline : []), + ...Array.from({ length: 8 }, (_, index) => `${part} ${cycle}-${index}`), + ]; + tui.renderNow(); + await terminal.flush(); + parts[part].lines = [...baseline]; + tui.renderNow(); + await terminal.flush(); + + const logicalDocument = layout.render(terminal.columns).map((line) => line.replace(CURSOR_MARKER, '')); + expect(terminal.getViewport()).toEqual(logicalDocument.slice(-terminal.rows)); + expect(terminal.getScrollBuffer()).toEqual(expected); + for (const line of history) { + expect(terminal.getScrollBuffer().filter((row) => row.trim() === line)).toHaveLength(1); + } + } + }); + + it.each(transientParts)('erases a short %s without clearing host history', async (part) => { + const terminal = new Terminal(60, 16); + const tui = new TuiMainScreen(terminal); + const parts = createMutableChatParts(part === 'notice' || part === 'welcome' ? 'welcome' : 'conversation'); + (part === 'notice' || part === 'welcome' ? parts.welcome : parts.transcript).lines = ['Short answer']; + const layout = new TuiChatLayout(terminal, parts); + tui.addChild(layout); + tui.renderNow(); + await terminal.flush(); + const expected = terminal.getScrollBuffer(); + const baseline = [...parts[part].lines]; + parts[part].lines = [ + ...(part === 'welcome' ? baseline : []), + ...Array.from({ length: 8 }, (_, index) => `${part} ${index}`), + ]; + tui.renderNow(); + await terminal.flush(); + terminal.takeWrites(); + parts[part].lines = baseline; + tui.renderNow(); + await terminal.flush(); + + expect(terminal.getScrollBuffer()).toEqual(expected); + expect(terminal.takeWrites()).not.toContain('\x1b[3J'); + }); + + it.each([ + ['hide', 2], + ['hide', 40], + ['setHidden', 2], + ['setHidden', 40], + ] as const)('restores %s overlays after activity settles with %i history rows', async (close, historyRows) => { + const terminal = new Terminal(60, 16); + const tui = new TuiMainScreen(terminal); + const parts = createMutableChatParts('conversation'); + parts.transcript.lines = Array.from({ length: historyRows }, (_, index) => `History ${index}`); + const layout = new TuiChatLayout(terminal, parts); + tui.addChild(layout); + tui.renderNow(); + await terminal.flush(); + const expected = terminal.getScrollBuffer(); + const overlay = new MutableLines(); + overlay.lines = ['Overlay contents']; + + for (let cycle = 0; cycle < 2; cycle++) { + // Background activity changes do not alter the chat's transient layout key. + // The previous overlay frame must still prevent padding after it disappears. + parts.activity.lines = Array.from({ length: 5 }, (_, index) => `Activity ${index}`); + const handle = tui.showOverlay(overlay, { width: 24 }); + tui.renderNow(); + await terminal.flush(); + expect(terminal.getViewport().join('\n')).toContain('Overlay contents'); + terminal.takeWrites(); + if (close === 'hide') handle.hide(); + else handle.setHidden(true); + parts.activity.lines = []; + tui.renderNow(); + await terminal.flush(); + + expect(terminal.getScrollBuffer()).toEqual(expected); + expect(terminal.getViewport().join('\n')).not.toContain('Overlay contents'); + if (historyRows < terminal.rows) expect(terminal.takeWrites()).not.toContain('\x1b[3J'); + // Hidden overlays remain registered; removing one must keep the restored frame. + handle.hide(); + tui.renderNow(); + await terminal.flush(); + expect(terminal.getScrollBuffer()).toEqual(expected); + } + }); + + it.each([false, true])('reconstructs when any root lacks a layout key (mixed roots: %s)', async (mixedRoots) => { + const terminal = new Terminal(60, 16); + const tui = new TuiMainScreen(terminal); + const component = new MutableLines(); + const history = Array.from({ length: 40 }, (_, index) => `History ${index}`); + component.lines = [...history, 'composer', 'status']; + tui.addChild(component); + if (mixedRoots) { + component.viewportLayoutKey = 'stable-footer'; + tui.addChild(new MutableLines()); + } + tui.renderNow(); + await terminal.flush(); + const expected = terminal.getScrollBuffer(); + component.lines.splice(-2, 0, ...Array.from({ length: 8 }, (_, index) => `Transient ${index}`)); + tui.renderNow(); + await terminal.flush(); + component.lines = [...history, 'composer', 'status']; + tui.renderNow(); + await terminal.flush(); + + expect(terminal.getScrollBuffer()).toEqual(expected); + expect(terminal.getViewport()).toEqual(component.lines.slice(-terminal.rows)); + }); + + it('tracks layout changes even when they produce an identical frame', async () => { + const terminal = new Terminal(60, 16); + const tui = new TuiMainScreen(terminal); + const component = new MutableLines(); + const history = Array.from({ length: 40 }, (_, index) => `History ${index}`); + component.viewportLayoutKey = 'closed'; + component.lines = [...history, 'composer', 'status']; + tui.addChild(component); + tui.renderNow(); + await terminal.flush(); + const expected = terminal.getScrollBuffer(); + component.lines.splice(-2, 0, ...Array.from({ length: 8 }, (_, index) => `Transient ${index}`)); + tui.renderNow(); + await terminal.flush(); + + // A selector can replace a same-height footer without changing any rows. + component.viewportLayoutKey = 'open'; + tui.renderNow(); + await terminal.flush(); + component.viewportLayoutKey = 'closed'; + component.lines = [...history, 'composer', 'status']; + tui.renderNow(); + await terminal.flush(); + + expect(terminal.getScrollBuffer()).toEqual(expected); + expect(terminal.getViewport()).toEqual(component.lines.slice(-terminal.rows)); + }); + + it('preserves a scrolled host viewport when only chat activity settles', async () => { + const terminal = new Terminal(60, 16); + const tui = new TuiMainScreen(terminal); + const parts = createMutableChatParts('conversation'); + parts.transcript.lines = Array.from({ length: 40 }, (_, index) => `History ${index}`); + parts.activity.lines = ['Activity 1', 'Activity 2', 'Activity 3']; + const layout = new TuiChatLayout(terminal, parts); + tui.addChild(layout); + tui.renderNow(); + await terminal.flush(); + terminal.scrollLines(-10); + const before = terminal.getScrollPosition(); + terminal.takeWrites(); + parts.activity.lines = []; + tui.renderNow(); + await terminal.flush(); + + expect(terminal.getScrollPosition()).toEqual(before); + expect(terminal.takeWrites()).not.toContain('\x1b[3J'); + expect(terminal.getScrollBuffer().filter((line) => line.trim().startsWith('History '))) + .toHaveLength(40); + terminal.scrollLines(1000); + expect(terminal.getViewport().slice(-2).map((line) => line.trim())).toEqual(['composer', 'status']); + }); + }); + it('ignores same-size resize notifications while the host is scrolled up', async () => { const terminal = new RecordingVirtualTerminal(60, 12); const tui = new TuiMainScreen(terminal); @@ -232,6 +446,7 @@ describe('MCode Pi Engine local deltas', () => { const terminal = new RecordingVirtualTerminal(60, 12); const tui = new TuiMainScreen(terminal); const component = new MutableLines(); + component.viewportLayoutKey = 'stable-footer'; const answer = Array.from({ length: 80 }, (_, index) => `Answer ${index}`); component.lines = [...answer, 'activity', `composer${CURSOR_MARKER}`, 'status']; tui.addChild(component); @@ -296,6 +511,7 @@ describe('MCode Pi Engine local deltas', () => { const terminal = new RecordingVirtualTerminal(67, 44); const tui = new TuiMainScreen(terminal); const component = new MutableLines(); + component.viewportLayoutKey = 'stable-footer'; const answer = Array.from({ length: 80 }, (_, index) => `Answer line ${index}`); component.lines = [...answer, 'activity', `composer${CURSOR_MARKER}`, 'status']; tui.addChild(component); @@ -338,6 +554,7 @@ describe('MCode Pi Engine local deltas', () => { const terminal = new RecordingVirtualTerminal(60, 44); const tui = new TuiMainScreen(terminal); const component = new MutableLines(); + component.viewportLayoutKey = 'stable-footer'; const answer = Array.from({ length: 80 }, (_, index) => `Answer ${index}`); component.lines = [ ...answer, diff --git a/packages/tui/test/unit/tui-surface-host.test.ts b/packages/tui/test/unit/tui-surface-host.test.ts index fbfc2b54..4ed7414b 100644 --- a/packages/tui/test/unit/tui-surface-host.test.ts +++ b/packages/tui/test/unit/tui-surface-host.test.ts @@ -71,10 +71,10 @@ function createRecordingPresenter(): { describe('TuiSurfaceHost', () => { it.each([ - { mode: 'regular', fullscreenViewport: true, rebuild: true }, - { mode: 'regular', fullscreenViewport: false, rebuild: false }, - { mode: 'fullscreen', fullscreenViewport: true, rebuild: false }, - ] as const)('requests rebuild on interaction close only for a regular full-viewport panel ($mode, $fullscreenViewport)', ({ mode, fullscreenViewport, rebuild }) => { + { mode: 'regular', fullscreenViewport: true }, + { mode: 'regular', fullscreenViewport: false }, + { mode: 'fullscreen', fullscreenViewport: true }, + ] as const)('leaves layout restoration to the renderer on interaction close ($mode, $fullscreenViewport)', ({ mode, fullscreenViewport }) => { const inline = new TuiInlinePanelHost(); const host = createSurfaceHost({ chat: { component: inline, focus: component([]) }, @@ -91,8 +91,7 @@ describe('TuiSurfaceHost', () => { requestRender.mockClear(); expect(interaction.close(panel)).toBe(true); expect(requestRender).toHaveBeenCalledOnce(); - if (rebuild) expect(requestRender).toHaveBeenCalledWith(true); - else expect(requestRender).toHaveBeenCalledWith(); + expect(requestRender).toHaveBeenCalledWith(); }); it.each(['regular', 'fullscreen'] as const)( From 47d14243da298e5cbe2cf0f4423a947faa3109f5 Mon Sep 17 00:00:00 2001 From: DanielWalnut <45447813+hetaoBackend@users.noreply.github.com> Date: Wed, 23 Sep 2026 17:54:21 +0800 Subject: [PATCH 04/27] fix(skills): discover linked workspace skill directories (#337) --- docs/tui-capabilities.md | 9 + packages/agent-modules/skills/src/registry.ts | 21 +- packages/agent-modules/skills/src/types.ts | 2 + packages/local-runtime/src/skills/roots.ts | 1 + .../unit/skill-directory-symlinks.test.ts | 233 ++++++++++++++++++ release/public-source.json | 1 + test/vitest-suites.json | 1 + 7 files changed, 264 insertions(+), 4 deletions(-) create mode 100644 packages/local-runtime/test/unit/skill-directory-symlinks.test.ts diff --git a/docs/tui-capabilities.md b/docs/tui-capabilities.md index a67fb9f2..622039f2 100644 --- a/docs/tui-capabilities.md +++ b/docs/tui-capabilities.md @@ -21,6 +21,15 @@ The evidence column summarizes the historical TUI 0.3.11 restoration record from | Files, shell, subagents, sessions, headless, ACP | Actual runtime retained | BYOK, file reads, session resume, ACP, sandbox, and status protocol tests | | Built-in skills, MCP, plugin tools | Original TUI assets and activation conditions retained | Asset build, plugin, and MCP tests; no claim that every skill has passed a real task | +## Skill directory links + +Workspace `.agents/skills`, `.claude/skills`, and `.minimax/skills` support +directory symlinks, both for the entire skill root and for individual skill +directories. Targets may live outside the workspace. Existing external-source +enable settings and duplicate-name priority still apply. Linked directories are +watched for `SKILL.md` creation and edits; broken links are skipped. `SKILL.md` +itself must remain a regular file. + ## ACP Skill commands ACP clients receive enabled Skills alongside built-in slash commands when a session diff --git a/packages/agent-modules/skills/src/registry.ts b/packages/agent-modules/skills/src/registry.ts index 50c469b4..97277ce6 100644 --- a/packages/agent-modules/skills/src/registry.ts +++ b/packages/agent-modules/skills/src/registry.ts @@ -226,7 +226,7 @@ export class SkillRegistry { }; for (const root of this.roots) { collectPath(root.rootPath, root); - for (const skillDir of listWatchableSkillDirs(root.rootPath)) { + for (const skillDir of listWatchableSkillDirs(root)) { collectPath(skillDir, root); } } @@ -472,7 +472,10 @@ function existingWatchTarget(targetPath: string): SkillWatchTarget | undefined { } function allowsDirectorySymlinkOutsideRoot(root: SkillSourceRoot): boolean { - return ['agent', 'global', 'user', 'builtin'].includes(root.kind); + return ( + root.allowDirectorySymlinksOutsideRoot === true || + ['agent', 'global', 'user', 'builtin'].includes(root.kind) + ); } async function listSkillFiles( @@ -811,10 +814,20 @@ function sameSkillFileStat(expected: SkillFileStat, actual: SkillFileStat): bool ); } -function listWatchableSkillDirs(rootPath: string): string[] { +function listWatchableSkillDirs(root: SkillSourceRoot): string[] { + const { rootPath } = root; try { return readdirSync(rootPath, { withFileTypes: true }) - .filter((child) => child.isDirectory()) + .filter((child) => { + if (child.isDirectory()) return true; + if (!child.isSymbolicLink() || !allowsDirectorySymlinkOutsideRoot(root)) return false; + try { + // Watch linked directories even before they contain a SKILL.md. + return statSync(path.join(rootPath, child.name)).isDirectory(); + } catch { + return false; + } + }) .map((child) => path.join(rootPath, child.name)) .sort(); } catch { diff --git a/packages/agent-modules/skills/src/types.ts b/packages/agent-modules/skills/src/types.ts index 150617e0..841c158e 100644 --- a/packages/agent-modules/skills/src/types.ts +++ b/packages/agent-modules/skills/src/types.ts @@ -7,6 +7,8 @@ export interface SkillSourceRoot { rootPath: string; priority?: number; external?: boolean; + /** Follow directory links outside the root for configured compatibility sources. */ + allowDirectorySymlinksOutsideRoot?: boolean; } export type SkillDiagnosticLevel = 'warning' | 'error'; diff --git a/packages/local-runtime/src/skills/roots.ts b/packages/local-runtime/src/skills/roots.ts index 27dec24d..6b3afd7c 100644 --- a/packages/local-runtime/src/skills/roots.ts +++ b/packages/local-runtime/src/skills/roots.ts @@ -134,6 +134,7 @@ function externalSkillRoot( rootPath, priority: sourceConfig.priority + priorityBonus, external: true, + allowDirectorySymlinksOutsideRoot: true, }; } diff --git a/packages/local-runtime/test/unit/skill-directory-symlinks.test.ts b/packages/local-runtime/test/unit/skill-directory-symlinks.test.ts new file mode 100644 index 00000000..68067f42 --- /dev/null +++ b/packages/local-runtime/test/unit/skill-directory-symlinks.test.ts @@ -0,0 +1,233 @@ +import { mkdtemp, mkdir, realpath, rm, symlink, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { dirname, join, relative } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { DEFAULT_SKILLS_CONFIG } from '@mavis/config'; +import { createSkillRegistry, type SkillRegistryWatcher } from '@mavis/skills'; +import { readConfiguredSkillRoots } from '../../src/skills/roots.js'; + +let fixture: string; +let workspace: string; +let watcher: SkillRegistryWatcher | undefined; + +beforeEach(async () => { + fixture = await realpath(await mkdtemp(join(tmpdir(), 'mcode-skill-links-'))); + workspace = join(fixture, 'workspace'); + await mkdir(join(workspace, '.git'), { recursive: true }); +}); + +afterEach(async () => { + watcher?.close(); + watcher = undefined; + await rm(fixture, { recursive: true, force: true }); +}); + +function workspaceRoots() { + return readConfiguredSkillRoots( + { dataDir: join(fixture, 'data'), provider: {} }, + 'mavis', + workspace, + ).filter((root) => root.kind === 'workspace'); +} + +async function writeSkill(dir: string, name = 'linked', body = 'Initial instructions') { + await mkdir(dir, { recursive: true }); + await writeFile( + join(dir, 'SKILL.md'), + `---\nname: ${name}\ndescription: Linked skill\n---\n# Linked skill\n${body}\n`, + ); +} + +async function linkDirectory(target: string, link: string, relativeTarget = false) { + await mkdir(dirname(link), { recursive: true }); + await symlink( + relativeTarget && process.platform !== 'win32' ? relative(dirname(link), target) : target, + link, + process.platform === 'win32' ? 'junction' : 'dir', + ); +} + +describe('configured workspace skill directory links', () => { + it.each(['.agents', '.claude', '.minimax'])( + 'discovers a linked %s/skills root', + async (source) => { + const target = join(workspace, 'skills', 'linked'); + await writeSkill(target); + await linkDirectory(dirname(target), join(workspace, source, 'skills'), true); + + const registry = await createSkillRegistry(workspaceRoots()); + const [skill] = registry.getAvailableSkills(); + expect(skill?.name).toBe('linked'); + expect(skill?.skillDir).toBe(target); + expect(registry.readByLocationUri(skill!.locationUri)).toContain('Initial instructions'); + expect(registry.getSnapshot()?.diagnostics).not.toEqual( + expect.arrayContaining([expect.objectContaining({ code: 'skill_outside_root' })]), + ); + }, + ); + + it.each([false, true])( + 'discovers child directory links (relative: %s) outside the workspace', + async (relativeTarget) => { + const target = join(fixture, 'shared', 'linked'); + const link = join(workspace, '.agents', 'skills', 'linked'); + await writeSkill(target); + await linkDirectory(target, link, relativeTarget); + + const registry = await createSkillRegistry(workspaceRoots()); + const [skill] = registry.getAvailableSkills(); + expect(skill).toMatchObject({ + name: 'linked', + skillDir: target, + entryDir: link, + }); + expect(fileURLToPath(skill!.locationUri.replace(/^files:/, 'file:'))).toBe( + join(target, 'SKILL.md'), + ); + }, + ); + + it('deduplicates shared root aliases using existing source priority', async () => { + const target = join(workspace, 'skills', 'linked'); + await writeSkill(target); + await linkDirectory(dirname(target), join(workspace, '.agents', 'skills'), true); + await linkDirectory(dirname(target), join(workspace, '.claude', 'skills'), true); + + const registry = await createSkillRegistry(workspaceRoots()); + expect(registry.getAvailableSkills()).toHaveLength(1); + expect(registry.getAvailableSkills()[0]?.rootId).toContain('workspace-cc:'); + expect(registry.getSnapshot()?.entries).toHaveLength(2); + }); + + it('retains external-source disable controls', () => { + const config = { + dataDir: join(fixture, 'data'), + provider: {}, + skills: { + external: { ...DEFAULT_SKILLS_CONFIG.external, enabled: false }, + }, + }; + expect( + readConfiguredSkillRoots(config, 'mavis', workspace).filter((root) => root.external), + ).toEqual([]); + config.skills.external.enabled = true; + config.skills.external.sources = { + ...DEFAULT_SKILLS_CONFIG.external.sources, + 'workspace-agents': { enabled: false, priority: 55 }, + }; + expect( + readConfiguredSkillRoots(config, 'mavis', workspace).some((root) => + root.id.startsWith('external:workspace-agents:'), + ), + ).toBe(false); + }); + + it('refreshes a retargeted child link without retaining cached content', async () => { + const first = join(fixture, 'first'); + const second = join(fixture, 'second'); + const link = join(workspace, '.agents', 'skills', 'linked'); + await writeSkill(first, 'linked', 'First instructions'); + await writeSkill(second, 'linked', 'Second instructions'); + await linkDirectory(first, link); + const registry = await createSkillRegistry(workspaceRoots()); + expect(registry.getAvailableSkills()[0]?.content).toContain('First instructions'); + await rm(link); + await linkDirectory(second, link); + await registry.refresh(); + expect(registry.getAvailableSkills()[0]).toMatchObject({ + skillDir: second, + entryDir: link, + }); + expect(registry.getAvailableSkills()[0]?.content).toContain('Second instructions'); + }); + + it('watches linked directories before SKILL.md exists and reloads later edits', async () => { + const target = join(fixture, 'shared', 'linked'); + await mkdir(target, { recursive: true }); + await linkDirectory(target, join(workspace, '.agents', 'skills', 'linked')); + const registry = await createSkillRegistry(workspaceRoots()); + expect(registry.getAvailableSkills()).toEqual([]); + const onChange = vi.fn(); + watcher = registry.watch({ onChange }); + + // Native watchers may finish arming after watch() returns. Observe a real + // event in the linked target before making the one-shot SKILL.md write. + await vi.waitFor( + async () => { + if (onChange.mock.calls.length === 0) { + await writeFile(join(target, '.watch-ready'), String(Date.now())); + } + expect(onChange).toHaveBeenCalled(); + }, + { timeout: 4000, interval: 250 }, + ); + expect(registry.getAvailableSkills()).toEqual([]); + onChange.mockClear(); + await writeSkill(target); + await vi.waitFor( + () => { + expect(onChange).toHaveBeenCalled(); + expect(registry.getAvailableSkills()[0]?.content).toContain('Initial instructions'); + }, + { timeout: 4000 }, + ); + onChange.mockClear(); + await writeSkill(target, 'linked', 'Updated instructions'); + await vi.waitFor( + () => { + expect(onChange).toHaveBeenCalled(); + expect(registry.getAvailableSkills()[0]?.content).toContain('Updated instructions'); + }, + { timeout: 4000 }, + ); + }, 15000); + + it.skipIf(process.platform === 'win32')( + 'skips broken, cyclic and non-directory links without losing valid skills', + async () => { + const root = join(workspace, '.agents', 'skills'); + await writeSkill(join(root, 'valid'), 'valid'); + await symlink(join(fixture, 'missing'), join(root, 'broken')); + await symlink('cycle', join(root, 'cycle')); + await writeFile(join(fixture, 'plain.txt'), 'Not a directory'); + await symlink(join(fixture, 'plain.txt'), join(root, 'file')); + const registry = await createSkillRegistry(workspaceRoots()); + expect(registry.getAvailableSkills().map((skill) => skill.name)).toEqual(['valid']); + expect(registry.getSnapshot()?.diagnostics).toEqual( + expect.arrayContaining([ + expect.objectContaining({ code: 'skill_directory_link_unreadable' }), + expect.objectContaining({ + code: 'skill_directory_link_not_directory', + }), + ]), + ); + }, + ); + + it.skipIf(process.platform === 'win32')('continues rejecting SKILL.md file links', async () => { + const target = join(fixture, 'shared'); + await writeSkill(target); + const skillDir = join(workspace, '.agents', 'skills', 'linked'); + await mkdir(skillDir, { recursive: true }); + await symlink(join(target, 'SKILL.md'), join(skillDir, 'SKILL.md')); + const registry = await createSkillRegistry(workspaceRoots()); + expect(registry.getAvailableSkills()).toEqual([]); + expect(registry.getSnapshot()?.diagnostics).toContainEqual( + expect.objectContaining({ code: 'skill_symlink_rejected' }), + ); + }); + + it.each(['project', 'workspace'] as const)('keeps unopted %s roots bounded', async (kind) => { + const target = join(fixture, 'shared', 'linked'); + const root = join(workspace, 'bounded'); + await writeSkill(target); + await linkDirectory(target, join(root, 'linked')); + const registry = await createSkillRegistry([{ id: 'bounded', kind, rootPath: root }]); + expect(registry.getAvailableSkills()).toEqual([]); + expect(registry.getSnapshot()?.diagnostics).toContainEqual( + expect.objectContaining({ code: 'skill_outside_root' }), + ); + }); +}); diff --git a/release/public-source.json b/release/public-source.json index d795f506..a07638a9 100644 --- a/release/public-source.json +++ b/release/public-source.json @@ -2656,6 +2656,7 @@ "packages/local-runtime/test/unit/local-thread-goal-wiring.test.ts", "packages/local-runtime/test/unit/metrics-telemetry-privacy.test.ts", "packages/local-runtime/test/unit/session-diff-fallback.test.ts", + "packages/local-runtime/test/unit/skill-directory-symlinks.test.ts", "packages/local-runtime/test/unit/thread-goal-kickoff-host.test.ts", "packages/local-runtime/test/unit/thread-goal-verifier-contract.test.ts", "packages/local-runtime/test/unit/thread-goal/contract.test.ts", diff --git a/test/vitest-suites.json b/test/vitest-suites.json index 40ae8d55..9ddb86ca 100644 --- a/test/vitest-suites.json +++ b/test/vitest-suites.json @@ -5,6 +5,7 @@ "test/windows-contract.test.mjs" ], "capability": [ + "packages/local-runtime/test/unit/skill-directory-symlinks.test.ts", "packages/agent-core/test/unit/bash-subprocess-env.test.ts", "packages/agent-modules/permission/test/unit/permission/bash-policy-regressions.test.ts", "packages/agent-tools/src/shared/replace-all-edit.test.ts", From 782f64dade70fe75c18e28cbf5233e860a51d1d8 Mon Sep 17 00:00:00 2001 From: DanielWalnut <45447813+hetaoBackend@users.noreply.github.com> Date: Wed, 23 Sep 2026 17:55:59 +0800 Subject: [PATCH 05/27] fix(tui): reset rewound todos and preserve local commands during edits (#336) --- .../tui/src/tui/controller/chat-controller.ts | 1 + .../tui/controller/product/command-flow.ts | 8 +- packages/tui/test/unit/tui-app.test.ts | 87 +++++++++++++++++++ .../tui/test/unit/tui-chat-controller.test.ts | 43 +++++++++ 4 files changed, 138 insertions(+), 1 deletion(-) diff --git a/packages/tui/src/tui/controller/chat-controller.ts b/packages/tui/src/tui/controller/chat-controller.ts index abd1b851..e058b1ff 100644 --- a/packages/tui/src/tui/controller/chat-controller.ts +++ b/packages/tui/src/tui/controller/chat-controller.ts @@ -254,6 +254,7 @@ export class TuiChatController { getMessages(current.sessionId), ]); if (projectionSequence !== this.sessionProjectionSequence) return; + this.turnProjection.clearTodos(); this.transcript.replaceDurableProjection(() => this.turnProjection.hydrateHistory(messages)); this.durableMessageAnchor = latestHistoryMessageId(messages); if (projectionSequence !== this.sessionProjectionSequence) return; diff --git a/packages/tui/src/tui/controller/product/command-flow.ts b/packages/tui/src/tui/controller/product/command-flow.ts index 43b93fdc..601e2997 100644 --- a/packages/tui/src/tui/controller/product/command-flow.ts +++ b/packages/tui/src/tui/controller/product/command-flow.ts @@ -281,7 +281,13 @@ export class TuiCommandFlow { } : options; const command = input.trim(); - if (this.options.sessionMutationFlow?.isEditing?.()) { + // Local commands remain commands while editing a historical message. + // Skills and explicit message submissions still belong to the edited prompt. + const localCommand = !submitOptions.forceMessage && this.catalog.resolve(command); + if ( + this.options.sessionMutationFlow?.isEditing?.() && + (!localCommand || localCommand.invocationKind === 'skill') + ) { const editDisposition = await this.options.sessionMutationFlow.submitEdit( input, seed?.resources.attachments, diff --git a/packages/tui/test/unit/tui-app.test.ts b/packages/tui/test/unit/tui-app.test.ts index fac9a117..9461c9d6 100644 --- a/packages/tui/test/unit/tui-app.test.ts +++ b/packages/tui/test/unit/tui-app.test.ts @@ -3557,6 +3557,93 @@ describe("createTuiApp", () => { await app.stop(); }); + it.each(["rewind", "edit"] as const)( + "removes discarded todos after /history %s", + async (action) => { + const terminal = new FakeTerminal(); + const runtime = createRuntime(); + vi.mocked(runtime.listSessionInputSummaries).mockResolvedValue([ + { userMessageId: "before-todo", timestamp: 1, fileChangeCount: 0 }, + ]); + vi.mocked(runtime.listMessagePage).mockResolvedValue({ + messages: [{ id: "before-todo", role: "user", content: "Original prompt" }], + hasMore: false, + }); + const app = createTuiApp({ runtime, terminal, version: "0.1.0", workspaceDir: "/workspace" }); + app.start(); + try { + await app.ready; + await app.submit("Seed todo session"); + app.controller.applyRuntimeTurnEvent("discarded-turn", { + type: "generic", + eventType: "todo_updated", + turnId: "discarded-turn", + data: { todos: [{ content: "Discarded todo", status: "in_progress" }] }, + }); + expect(stripAnsi(app.tui.render(100).join("\n"))).toContain("Discarded todo"); + await app.submit("/history"); + await vi.waitFor(() => + expect(app.surfaceHost.getActiveSurface().id).toBe("session-history:explorer"), + ); + terminal.input?.("\r"); + terminal.input?.("\x1b[B"); + if (action === "rewind") terminal.input?.("\x1b[B"); + terminal.input?.("\r"); + await vi.waitFor(() => + expect(app.surfaceHost.getActiveSurface().id).toBe( + action === "rewind" + ? "session-mutation:rewind-confirm" + : "session-mutation:rewind-preview", + ), + ); + terminal.input?.("\r"); + if (action === "edit") { + await vi.waitFor(() => expect(app.editor.getText()).toBe("Original prompt")); + await app.submit("Revised prompt"); + expect(runtime.editSessionMessage).toHaveBeenCalledOnce(); + } else { + await vi.waitFor(() => expect(runtime.rewindSession).toHaveBeenCalledOnce()); + } + await vi.waitFor(() => expect(app.surfaceHost.getActiveSurface().kind).toBe("chat")); + expect(stripAnsi(app.tui.render(100).join("\n"))).not.toContain("Discarded todo"); + } finally { + await app.stop(); + } + }, + ); + + it.each([false, true])("handles exact /rewind locally while editing=%s", async (editing) => { + const terminal = new FakeTerminal(); + const runtime = createRuntime(); + vi.mocked(runtime.listSessionInputSummaries).mockResolvedValue([ + { userMessageId: "before-todo", timestamp: 1, fileChangeCount: 0 }, + ]); + vi.mocked(runtime.listMessagePage).mockResolvedValue({ + messages: [{ id: "before-todo", role: "user", content: "Original prompt" }], + hasMore: false, + }); + const app = createTuiApp({ runtime, terminal, version: "0.1.0", workspaceDir: "/workspace" }); + app.start(); + try { + await app.ready; + await app.submit("Seed session"); + if (editing) { + await app.submit("/edit"); + await vi.waitFor(() => expect(app.editor.getText()).toBe("Original prompt")); + } + const sends = vi.mocked(runtime.sendMessage).mock.calls.length; + app.editor.setText("/rewind"); + app.editor.handleInput("\r"); + await vi.waitFor(() => + expect(app.surfaceHost.getActiveSurface().id).toBe("session-mutation:history"), + ); + expect(runtime.editSessionMessage).not.toHaveBeenCalled(); + expect(runtime.sendMessage).toHaveBeenCalledTimes(sends); + } finally { + await app.stop(); + } + }); + it("uses Ctrl+T for tasks while Ctrl+O remains scoped to transcript details", async () => { const terminal = new FakeTerminal(); const app = createTuiApp({ diff --git a/packages/tui/test/unit/tui-chat-controller.test.ts b/packages/tui/test/unit/tui-chat-controller.test.ts index 922c4e5f..a0102b16 100644 --- a/packages/tui/test/unit/tui-chat-controller.test.ts +++ b/packages/tui/test/unit/tui-chat-controller.test.ts @@ -2745,6 +2745,49 @@ describe('TuiChatController', () => { ); }); + it.each([false, true])( + 'rebuilds todos from rewound history with earlier todos=%s', + async (keepEarlier) => { + const onTodoChange = vi.fn(); + const earlierTodo = { content: 'Earlier task', status: 'pending' }; + const getMessages = vi.fn(async () => + keepEarlier + ? [ + { + id: 'earlier-todo', + turnId: 'earlier-turn', + role: 'system' as const, + content: JSON.stringify({ eventType: 'todo_updated', todos: [earlierTodo] }), + }, + ] + : [], + ); + const controller = new ProductionTuiChatController({ + runtime: { + getSession: vi.fn(async () => ({ sessionId: 'rewound-session' })), + getMessages, + } as never, + transcript: new TranscriptStore(), + workspaceDir: '/workspace', + onTodoChange, + }); + await controller.loadSessionProjection('rewound-session'); + controller.applyRuntimeTurnEvent('discarded-turn', { + type: 'generic', + eventType: 'todo_updated', + turnId: 'discarded-turn', + data: { todos: [{ content: 'Discarded task', status: 'in_progress' }] }, + }); + expect(onTodoChange).toHaveBeenLastCalledWith([ + { content: 'Discarded task', status: 'in_progress' }, + ]); + + await controller.reconcileOwnerHistory(true); + + expect(onTodoChange).toHaveBeenLastCalledWith(keepEarlier ? [earlierTodo] : []); + }, + ); + it('clears input-adjacent tasks when starting a new session', () => { const onTodoChange = vi.fn(); const controller = new ProductionTuiChatController({ From 67afb6134d5fa391148d002b89174a28d115e15e Mon Sep 17 00:00:00 2001 From: DanielWalnut <45447813+hetaoBackend@users.noreply.github.com> Date: Wed, 23 Sep 2026 18:41:14 +0800 Subject: [PATCH 06/27] feat(tui): select plugins with durable identity mentions (#334) * feat(tui): select plugins with durable identity mentions * test(tui): update plugin mention discovery hints * fix(tui): preserve plugin bindings across draft and message edits * fix(tui): retain unique plugin labels across multiple external edits * fix: reconcile plugin mention display and simplify completion rows * fix(tui): reconcile theme edits missed during watcher startup --- docs/tui-capabilities.md | 23 + .../system-reminder/package.json | 3 +- .../system-reminder/src/plugin-reference.ts | 72 +- .../conversation/conversation-application.ts | 5 + .../conversation/direct-send-delivery.ts | 7 +- .../user-message-turn-delivery.ts | 8 + .../session/content-application.ts | 1 + .../session-system/messages/repo/contract.ts | 2 + .../messages/user-message-commit-service.ts | 3 + .../local-turn-plugin-capabilities.test.ts | 169 +++++ .../local-turn-plugin-capabilities.ts | 105 ++- packages/protocol/src/local.ts | 2 + packages/shared/package.json | 4 + packages/shared/src/plugin-mention.ts | 40 ++ .../tui/src/application/run-coordinator.ts | 2 + .../tui/src/runtime/adapters/plugin-access.ts | 3 +- packages/tui/src/runtime/stream-events.ts | 2 + .../src/tui/commands/plugin-autocomplete.ts | 113 +++ .../tui/src/tui/controller/chat-controller.ts | 1 + .../tui/controller/interaction/input-flow.ts | 2 + .../tui/controller/product/command-flow.ts | 19 +- .../product/session-mutation-flow.ts | 11 +- .../projection/turn-user-projection.ts | 14 +- .../src/tui/controller/run/active-run-flow.ts | 38 +- .../tui/src/tui/controller/run/queue-flow.ts | 23 +- .../tui/src/tui/engine/LOCAL_CHANGES.json | 6 +- packages/tui/src/tui/engine/LOCAL_CHANGES.md | 7 + .../tui/src/tui/engine/components/editor.ts | 11 +- .../tui/features/composer/draft-recovery.ts | 17 +- .../src/tui/features/composer/submission.ts | 5 +- .../src/tui/features/history/search-panel.ts | 7 +- packages/tui/src/tui/shell/composer.ts | 2 +- packages/tui/src/tui/theme/custom-themes.ts | 3 + packages/tui/src/tui/widgets/editor/editor.ts | 214 +++++- .../src/tui/widgets/editor/plugin-mentions.ts | 134 ++++ packages/tui/test/unit/tui-app.test.ts | 4 +- .../tui/test/unit/tui-chat-controller.test.ts | 46 +- packages/tui/test/unit/tui-composer.test.ts | 6 +- .../tui/test/unit/tui-plugin-mentions.test.ts | 677 ++++++++++++++++++ .../test/unit/tui/theme/custom-themes.test.ts | 59 +- .../tui/test/unit/tui/theme/runtime.test.ts | 17 +- pnpm-lock.yaml | 3 + release/public-source.json | 5 + test/vitest-suites.json | 4 +- tsconfig.standalone.json | 3 + 45 files changed, 1783 insertions(+), 119 deletions(-) create mode 100644 packages/local-runtime-v2/src/service/turn-system/agent-host/assembly/local-turn-plugin-capabilities.test.ts create mode 100644 packages/shared/src/plugin-mention.ts create mode 100644 packages/tui/src/tui/commands/plugin-autocomplete.ts create mode 100644 packages/tui/src/tui/widgets/editor/plugin-mentions.ts create mode 100644 packages/tui/test/unit/tui-plugin-mentions.test.ts diff --git a/docs/tui-capabilities.md b/docs/tui-capabilities.md index 622039f2..9ced4ea3 100644 --- a/docs/tui-capabilities.md +++ b/docs/tui-capabilities.md @@ -141,3 +141,26 @@ existing diagnostic-counts projection; raw error text, stacks and session IDs are not added to the uploaded ZIP. Offline tests cover persisted tool histories, archives, concurrent parent output, side-session cleanup and local diagnostics; this does not establish native-terminal or live-model acceptance. + +## Select a plugin for a message + +Type `@` in the Composer to search files and installed, enabled plugins. Plugin +candidates show their source so packages with the same display name can be +selected independently. Choose a plugin with Tab or Enter, then describe the task. +The Composer shows `@Name` and retains the plugin identity through editing, undo, +prompt history, queued-message recovery, saved drafts, and `/edit` after a +message is sent. Ctrl+C clearing/restoration and external-editor edits retain +unchanged plugin bindings. If external edits make duplicate labels ambiguous, +reselect those plugins in the Composer. Displayed messages remain readable; Runtime retains +the original input separately when needed to recover the plugin identity for editing. + +Selection applies to that message. Runtime checks the plugin's effective Skills, +MCP tools, and App tools again for the turn and asks the Agent to prefer relevant +capabilities. Selecting a plugin does not install or enable it. An unavailable +selection is reported to the Agent rather than redirected to a same-named package. + +Exec and ACP text prompts can use the durable linked form, for example +`[@Notes](plugin://notes%40local) summarize these files`. The ID is the package +name plus its `local` or `official` source; display labels do not determine the +selection. Legacy whitespace-delimited `@package-name` text remains supported +when it identifies exactly one effective plugin. diff --git a/packages/agent-modules/system-reminder/package.json b/packages/agent-modules/system-reminder/package.json index 1ee0840c..0ddefcd9 100644 --- a/packages/agent-modules/system-reminder/package.json +++ b/packages/agent-modules/system-reminder/package.json @@ -14,7 +14,8 @@ "@mavis/config": "workspace:^", "@types/node": "^20", "typescript": "^5", - "vitest": "^2" + "vitest": "^2", + "@mavis/shared": "workspace:^" }, "private": true, "license": "MIT" diff --git a/packages/agent-modules/system-reminder/src/plugin-reference.ts b/packages/agent-modules/system-reminder/src/plugin-reference.ts index 5878a928..2ad38865 100644 --- a/packages/agent-modules/system-reminder/src/plugin-reference.ts +++ b/packages/agent-modules/system-reminder/src/plugin-reference.ts @@ -1,12 +1,15 @@ +import { parsePluginMentions } from '@mavis/shared/plugin-mention'; + export interface EffectivePluginToolGroup { readonly source: string; readonly tools: readonly string[]; - /** Deferred App tools must be discovered before they can be invoked. */ + /** Deferred tools must be discovered before they can be invoked. */ readonly access?: 'direct' | 'tool_search'; } export interface EffectivePluginCapabilityInventory { readonly name: string; + readonly pluginId?: string; readonly appTools: readonly EffectivePluginToolGroup[]; readonly mcpTools: readonly EffectivePluginToolGroup[]; readonly skills: readonly string[]; @@ -33,7 +36,7 @@ export function detectPluginReferencesForMessages { + if (plugins.length === 0 && unavailablePluginIds.length === 0) return undefined; + const blocks = plugins.slice(0, 8).map((plugin) => { const name = inlineCode(plugin.name); - return [ + const detail = [ ``, `Referenced as \`@${name}\`.`, '', @@ -66,20 +70,46 @@ export function buildPluginReferenceReminder( '', '', ].join('\n'); + return detail.length <= 4096 + ? detail + : `\nCapability details omitted to fit the context limit. Use this Plugin's tool provenance and Skill namespace in the available catalogs.\n`; }); + const bounded: string[] = []; + let remaining = 12_288; + let omitted = plugins.length > 8 || unavailablePluginIds.length > 8; + for (const block of [ + ...blocks, + ...unavailablePluginIds + .slice(0, 8) + .map( + (id) => + `Selected Plugin \`${inlineCode(id)}\` is unavailable for this turn. Tell the user it could not be used; do not substitute a same-named Plugin or enable/install it automatically.`, + ), + ]) { + if (block.length + 2 > remaining) { + omitted = true; + continue; + } + bounded.push(block); + remaining -= block.length + 2; + } + return [ '', 'The user explicitly selected the following Plugin capabilities for this request.', 'Prefer them when relevant; other tools remain available if needed.', '', - blocks.join('\n\n'), + bounded.join('\n\n'), + ...(omitted ? ['Additional selected capabilities omitted to fit the context limit.'] : []), '', - 'Only the capabilities listed above are effective for this turn.', + 'The listed capabilities are drawn from the effective inventory for this turn; lists may be truncated.', 'Do not invent or claim unavailable Plugin capabilities.', - ...(plugins.some((plugin) => plugin.appTools.some((group) => group.access === 'tool_search')) + ...(plugins.some((plugin) => + [...plugin.appTools, ...plugin.mcpTools].some((group) => group.access === 'tool_search'), + ) ? [ - 'For App tools marked `via tool_search + mcp_invoke`, discover the exact tool with `tool_search` before calling it through `mcp_invoke`.', + 'For tools marked `via tool_search + mcp_invoke`, discover the exact tool with `tool_search` before calling it through `mcp_invoke`.', ] : []), 'Before following a listed Skill, call the `skill` tool with its exact name.', @@ -91,6 +121,7 @@ export function buildPluginReferenceReminder( function formatAppToolGroups(groups: readonly EffectivePluginToolGroup[]): string { if (groups.length === 0) return 'none'; return [...groups] + .slice(0, 16) .sort((left, right) => { const sourceOrder = normalizedName(left.source).localeCompare(normalizedName(right.source)); if (sourceOrder !== 0) return sourceOrder; @@ -108,11 +139,24 @@ function detectReferencesInText( text: string, plugins: readonly T[], ): T[] { - const normalizedText = text.normalize('NFKC'); + const linked = parsePluginMentions(text); + let plainText = text; + for (const mention of [...linked].reverse()) + plainText = `${plainText.slice(0, mention.start)}${' '.repeat(mention.end - mention.start)}${plainText.slice(mention.end)}`; + const normalizedText = plainText.normalize('NFKC'); const matches: Array<{ index: number; plugin: T }> = []; for (const plugin of plugins) { + const explicit = linked.find((mention) => mention.pluginId === plugin.pluginId); + if (explicit) { + matches.push({ index: explicit.start, plugin }); + continue; + } const name = normalizedName(plugin.name); - if (!name) continue; + if ( + !name || + plugins.filter((candidate) => normalizedName(candidate.name) === name).length !== 1 + ) + continue; const pattern = new RegExp(`(^|\\s)@${escapeRegExp(name)}(?=\\s|$)`, 'giu'); const match = pattern.exec(normalizedText); if (!match) continue; @@ -128,16 +172,19 @@ function formatToolGroups( ): string { if (groups.length === 0) return 'none'; return [...groups] + .slice(0, 16) .sort((left, right) => normalizedName(left.source).localeCompare(normalizedName(right.source))) .map((group) => { const tools = formatToolNames(group.tools); - return `- ${label} \`${inlineCode(group.source)}\`: ${tools || 'none'}`; + const access = group.access === 'tool_search' ? ' via `tool_search` + `mcp_invoke`' : ''; + return `- ${label} \`${inlineCode(group.source)}\`${access}: ${tools || 'none'}`; }) .join('\n'); } function formatToolNames(tools: readonly string[]): string { return [...new Set(tools)] + .slice(0, 32) .sort((left, right) => normalizedName(left).localeCompare(normalizedName(right))) .map((tool) => `\`${inlineCode(tool)}\``) .join(', '); @@ -146,6 +193,7 @@ function formatToolNames(tools: readonly string[]): string { function formatSkills(skills: readonly string[]): string { if (skills.length === 0) return 'none'; return [...new Set(skills)] + .slice(0, 32) .sort((left, right) => normalizedName(left).localeCompare(normalizedName(right))) .map((skill) => `- \`${inlineCode(skill)}\``) .join('\n'); diff --git a/packages/local-runtime-v2/src/application/conversation/conversation-application.ts b/packages/local-runtime-v2/src/application/conversation/conversation-application.ts index c7be0ea1..1f7c92f8 100644 --- a/packages/local-runtime-v2/src/application/conversation/conversation-application.ts +++ b/packages/local-runtime-v2/src/application/conversation/conversation-application.ts @@ -70,6 +70,8 @@ import { } from "./session-stream-delivery.js"; export interface ConversationSendMessageRequest extends SendMessageReq { + /** Readable projection supplied by the process-local composer; content retains transport identity. */ + readonly displayContent?: string; /** Process-local caller's Unix-ms deadline; its cancellation owner still enforces it. */ readonly executionDeadlineAtMs?: number; /** Process-local opt-in; emits metadata only, never provider payloads. */ @@ -575,6 +577,9 @@ async function toDirectSendInput( input: toAgentHostUserInput(req, text, attachments), ...executionOptions, provenance: messageProvenance(req), + ...(req.displayContent !== undefined + ? { displayContent: req.displayContent } + : {}), ...(materialized.displayAttachments.length > 0 ? { displayAttachments: materialized.displayAttachments } : {}), diff --git a/packages/local-runtime-v2/src/application/conversation/direct-send-delivery.ts b/packages/local-runtime-v2/src/application/conversation/direct-send-delivery.ts index 28f3e872..3c2a6e91 100644 --- a/packages/local-runtime-v2/src/application/conversation/direct-send-delivery.ts +++ b/packages/local-runtime-v2/src/application/conversation/direct-send-delivery.ts @@ -26,6 +26,7 @@ export interface DirectSendInput { readonly outputContract?: SubmitTurnSubmission['outputContract']; readonly executionDeadlineAtMs?: SubmitTurnSubmission['executionDeadlineAtMs']; readonly provenance: SubmitTurnSubmission['provenance']; + readonly displayContent?: string; readonly displayAttachments?: readonly UserMessageAttachment[]; readonly requestedTurnId?: string; readonly hideUserMessage?: boolean; @@ -388,8 +389,12 @@ async function completeAfterSettlement( function directSendDisplayOptions( input: DirectSendInput, -): Pick, 'displayAttachments' | 'hideUserMessage'> { +): Pick< + NonNullable, + 'displayContent' | 'displayAttachments' | 'hideUserMessage' +> { return { + ...(input.displayContent !== undefined ? { displayContent: input.displayContent } : {}), ...(input.displayAttachments ? { displayAttachments: input.displayAttachments.map((attachment) => ({ ...attachment })) } : {}), diff --git a/packages/local-runtime-v2/src/application/conversation/user-message-turn-delivery.ts b/packages/local-runtime-v2/src/application/conversation/user-message-turn-delivery.ts index 22fb114c..c4aa266b 100644 --- a/packages/local-runtime-v2/src/application/conversation/user-message-turn-delivery.ts +++ b/packages/local-runtime-v2/src/application/conversation/user-message-turn-delivery.ts @@ -1,3 +1,4 @@ +import { parsePluginMentions } from '@mavis/shared/plugin-mention'; import { randomUUID } from 'node:crypto'; import { @@ -126,6 +127,7 @@ export class UserMessageTurnDeliveryService implements UserMessageTurnDelivery { ...(input.message.userMessageId ? { userMessageId: input.message.userMessageId } : {}), unstartedFromTurnIds: input.message.unstartedFromTurnIds, content: projectedMessageContent(delivery, input.message.message.text), + inputContent: input.message.message.text, ...(attachments ? { attachments } : {}), ...(input.message.sourceMessageId ? { sourceMessageId: input.message.sourceMessageId } : {}), provenance: input.message.provenance, @@ -295,6 +297,7 @@ async function commitTurnMessages( messageKey: member.messageKey, userMessageId: member.userMessageId, content: projectedMessageContent(member.message, member.message.content), + inputContent: member.message.content, timestamp: member.createdAt, ...(member.unconsumedFromTurnIds ? { unconsumedFromTurnIds: member.unconsumedFromTurnIds } @@ -323,6 +326,7 @@ async function commitTurnMessage( messageKey: input.messageKey ?? `turn:${turnId}`, ...(input.userMessageId ? { userMessageId: input.userMessageId } : {}), content: projectedMessageContent(input, input.input.text), + inputContent: input.input.text, ...(queryKey ? { queryKey } : {}), ...(attachments ? { attachments } : {}), ...(input.sourceMessageId ? { sourceMessageId: input.sourceMessageId } : {}), @@ -340,6 +344,7 @@ function commitMessage( readonly messageKey: string; readonly userMessageId?: UserMessageId; readonly content: string; + readonly inputContent: string; readonly timestamp?: number; readonly unconsumedFromTurnIds?: readonly string[]; readonly unstartedFromTurnIds?: readonly string[]; @@ -360,6 +365,9 @@ function commitMessage( messageKey: input.messageKey, ...(input.userMessageId ? { userMessageId: input.userMessageId } : {}), content: input.content, + ...(input.inputContent !== input.content && parsePluginMentions(input.inputContent).length > 0 + ? { editContent: input.inputContent } + : {}), ...(input.timestamp !== undefined ? { timestamp: input.timestamp } : {}), ...(input.unconsumedFromTurnIds ? { unconsumedFromTurnIds: input.unconsumedFromTurnIds } diff --git a/packages/local-runtime-v2/src/application/session/content-application.ts b/packages/local-runtime-v2/src/application/session/content-application.ts index 81e295e4..27b69097 100644 --- a/packages/local-runtime-v2/src/application/session/content-application.ts +++ b/packages/local-runtime-v2/src/application/session/content-application.ts @@ -481,6 +481,7 @@ export function toSessionMessageView( ); return { msgId: maybeString(firstDefined([message.msgId, message.msg_id])) ?? "", + ...(typeof message.editContent === "string" ? { editContent: message.editContent } : {}), parentMsgId: maybeString( firstDefined([message.parentMsgId, message.parent_msg_id]), ), diff --git a/packages/local-runtime-v2/src/service/session-system/messages/repo/contract.ts b/packages/local-runtime-v2/src/service/session-system/messages/repo/contract.ts index 197c3f35..f2b38fc5 100644 --- a/packages/local-runtime-v2/src/service/session-system/messages/repo/contract.ts +++ b/packages/local-runtime-v2/src/service/session-system/messages/repo/contract.ts @@ -2,6 +2,8 @@ import type { AppDb } from '../../../../infra/db/client.js'; export interface DisplayMessageRecord extends Record { readonly msg_id?: string; + /** Canonical user input preserved when display text hides plugin identities. */ + readonly editContent?: string; /** Stable canonical identity of this completed assistant message, when known. */ readonly canonical_message_id?: string; readonly role?: string; diff --git a/packages/local-runtime-v2/src/service/session-system/messages/user-message-commit-service.ts b/packages/local-runtime-v2/src/service/session-system/messages/user-message-commit-service.ts index d6c54d10..589f0796 100644 --- a/packages/local-runtime-v2/src/service/session-system/messages/user-message-commit-service.ts +++ b/packages/local-runtime-v2/src/service/session-system/messages/user-message-commit-service.ts @@ -47,6 +47,8 @@ export interface CommitUserMessageInput { /** Pre-generated identity shared by Queue, Display and canonical history. */ readonly userMessageId?: UserMessageId; readonly content?: string; + /** Canonical input retained when readable display text hides explicit plugin identities. */ + readonly editContent?: string; /** Display classification independent from Turn/query identity. */ readonly kind?: string; /** Query sidecar identity; absent only for legacy or non-query user Messages. */ @@ -118,6 +120,7 @@ export class UserMessageCommitService { msg_id: messageId, role: 'user', msg_content: input.content ?? '', + ...(input.editContent !== undefined ? { editContent: input.editContent } : {}), msg_type: 1, timestamp: input.timestamp ?? this.nowMs(), kind: input.kind, diff --git a/packages/local-runtime-v2/src/service/turn-system/agent-host/assembly/local-turn-plugin-capabilities.test.ts b/packages/local-runtime-v2/src/service/turn-system/agent-host/assembly/local-turn-plugin-capabilities.test.ts new file mode 100644 index 00000000..28432121 --- /dev/null +++ b/packages/local-runtime-v2/src/service/turn-system/agent-host/assembly/local-turn-plugin-capabilities.test.ts @@ -0,0 +1,169 @@ +import { buildLocalTurnToolCatalog } from './local-turn-tool-catalog.js'; +import { Type } from '@sinclair/typebox'; +import { describe, expect, it, vi } from 'vitest'; +import type { RuntimeTool } from '@mavis/agent-core/tools'; +import { + detectPluginReferencesForMessages, + buildPluginReferenceReminder, +} from '@mavis/system-reminder'; +import { + mergeAgentHostTurnCapabilities, + type MergeAgentHostTurnCapabilitiesInput, +} from './local-turn-plugin-capabilities.js'; + +function tool(name: string): RuntimeTool { + return { + def: { name, description: 'Synthetic test tool', schema: Type.Object({}) }, + impl: { + execute: vi.fn(async () => ({ + tool_name: name, + text: 'ok', + content: [{ type: 'text' as const, text: 'ok' }], + })), + }, + }; +} +function input( + userText = '[@My Notes](plugin://notes%40local) summarize', +): MergeAgentHostTurnCapabilitiesInput { + const mcp = tool('mcp__notes__read'); + const app = tool('app_notes_search'); + return { + tools: [], + plan: { deferred: false, inlineTools: [] }, + model: { provider: 'synthetic', id: 'offline', contextWindow: 128000 }, + options: { + enabled: true, + modelWhitelist: [], + thresholdPct: 10, + minDeferCount: 1, + topKDefault: 5, + topKMax: 10, + systemHint: true, + maxSchemaTextLen: 1000, + }, + effectivePluginSkills: [{ pluginName: 'notes', name: 'notes:organize' }], + capabilities: { + revision: 'r1', + plugins: [{ name: 'notes', source: 'local', appProviders: ['notes-app'] }], + skills: [], + runtimeTools: [mcp, app], + runtimeToolBindings: [ + { kind: 'mcp', source: 'notes-server', pluginName: 'notes', tool: mcp }, + { kind: 'app', source: 'notes-app', toolMode: 'tool_search', tool: app }, + ], + }, + userText, + }; +} + +describe('Plugin identity selection at the final Runtime capability boundary', () => { + it('selects exact IDs independently of aliases and never substitutes a same-named source', () => { + const local = { + name: 'notes', + pluginId: 'notes@local', + skills: ['notes:organize'], + appTools: [], + mcpTools: [], + }; + const official = { ...local, pluginId: 'notes@official' }; + expect( + detectPluginReferencesForMessages([], '[@Alias](plugin://notes%40local)', [official, local]), + ).toEqual([local]); + expect( + detectPluginReferencesForMessages([], '[@notes](plugin://notes%40missing)', [local]), + ).toEqual([]); + expect(detectPluginReferencesForMessages([], '@notes summarize', [official, local])).toEqual( + [], + ); + expect(detectPluginReferencesForMessages([], '@notes summarize', [local])).toEqual([local]); + }); + + it('advertises only effective skills, direct MCP and searchable App tools, preserving callable implementations', async () => { + const request = input(); + const result = mergeAgentHostTurnCapabilities(request); + expect(result.reminder).toContain('notes:organize'); + expect(result.reminder).toContain('mcp__notes__read'); + expect(result.reminder).toContain('app_notes_search'); + expect(result.reminder).toContain('tool_search'); + expect(result.plan.deferred).toBe(true); + const mcp = result.tools.find((tool) => tool.def.name === 'mcp__notes__read')!; + await expect(mcp.impl.execute({ sessionId: 's', turnId: 't' }, {})).resolves.toMatchObject({ + text: 'ok', + }); + expect(mcp.toolCallProvenanceResolver?.({ phase: 'start', toolName: mcp.def.name })).toEqual([ + expect.objectContaining({ plugin_name: 'notes' }), + ]); + }); + + it('discovers and invokes deferred MCP and App tools through the final catalog', async () => { + const request = input(); + const mcp = request.capabilities!.runtimeTools[0]!; + const result = buildLocalTurnToolCatalog({ + sessionId: 's', + llmModel: request.model, + sources: { + nativeTools: [], + mcpEntries: [{ tool: mcp, source: 'configured', serverName: 'notes-server' }], + threadGoalTools: [], + cuRuntimeAvailable: false, + }, + config: { ...request.options, modelWhitelist: ['*'], thresholdPct: 0 }, + desktopCapabilities: request.capabilities, + effectivePluginSkills: request.effectivePluginSkills, + userText: request.userText, + }); + expect(result.tools.map((tool) => tool.def.name)).not.toContain(mcp.def.name); + expect(result.userPromptPrefix).toContain( + 'server `notes-server` via `tool_search` + `mcp_invoke`', + ); + const search = result.tools.find((tool) => tool.def.name === 'tool_search')!; + const invoke = result.tools.find((tool) => tool.def.name === 'mcp_invoke')!; + for (const name of ['mcp__notes__read', 'app_notes_search']) { + const found = await search.impl.execute({ sessionId: 's', turnId: 't' }, { query: name }); + expect(JSON.stringify(found)).toContain(name); + await expect( + invoke.impl.execute({ sessionId: 's', turnId: 't' }, { tool_name: name, arguments: {} }), + ).resolves.toMatchObject({ text: 'ok' }); + } + }); + + it('reports a plugin disabled before this turn and does not activate it or use another source', () => { + const request = input(); + const result = mergeAgentHostTurnCapabilities({ + ...request, + effectivePluginSkills: [], + capabilities: { + revision: 'r2', + plugins: [], + runtimeTools: [], + runtimeToolBindings: [], + skills: [], + }, + }); + expect(result.reminder).toContain('notes@local'); + expect(result.reminder).toContain('unavailable for this turn'); + expect(result.tools).toEqual([]); + expect( + mergeAgentHostTurnCapabilities({ ...request, capabilities: undefined }).reminder, + ).toContain('unavailable'); + expect( + mergeAgentHostTurnCapabilities(input('[@notes](plugin://notes%40official)')).reminder, + ).toContain('notes@official` is unavailable'); + }); + + it('bounds capability instructions even for large inventories', () => { + const result = buildPluginReferenceReminder( + Array.from({ length: 40 }, (_, index) => ({ + name: `plugin-${index}`, + pluginId: `plugin-${index}@local`, + skills: Array.from({ length: 200 }, (_, i) => `plugin-${index}:skill-${i}`), + appTools: [], + mcpTools: [], + })), + ); + expect(result!.length).toBeLessThan(16384); + expect(result).toContain('omitted'); + expect(result).toContain(''); + }); +}); diff --git a/packages/local-runtime-v2/src/service/turn-system/agent-host/assembly/local-turn-plugin-capabilities.ts b/packages/local-runtime-v2/src/service/turn-system/agent-host/assembly/local-turn-plugin-capabilities.ts index 6d7656a1..0f956f1c 100644 --- a/packages/local-runtime-v2/src/service/turn-system/agent-host/assembly/local-turn-plugin-capabilities.ts +++ b/packages/local-runtime-v2/src/service/turn-system/agent-host/assembly/local-turn-plugin-capabilities.ts @@ -1,3 +1,4 @@ +import { buildPluginId, parsePluginMentions } from '@mavis/shared/plugin-mention'; import type { RuntimeTool } from '@mavis/agent-core/tools'; import { attachPluginCapabilityAttribution, @@ -66,7 +67,10 @@ export interface MergeAgentHostTurnCapabilitiesInput { readonly capabilities?: AgentHostTurnCapabilityView; readonly model: McpModelIdentity; readonly options: McpDisclosureOptions; - readonly effectivePluginSkills: readonly { readonly pluginName: string; readonly name: string }[]; + readonly effectivePluginSkills: readonly { + readonly pluginName: string; + readonly name: string; + }[]; readonly hostCapabilityRegistry?: HostCapabilityResolver; readonly surface?: HostCapabilitySurface; readonly userText?: string; @@ -84,7 +88,13 @@ export function mergeAgentHostTurnCapabilities( ): MergedAgentHostTurnCapabilities { const merged = mergeTools(input); const capabilities = input.capabilities; - if (!capabilities) return merged; + if (!capabilities) { + const reminder = buildPluginReferenceReminder( + [], + parsePluginMentions(input.userText ?? '').map((mention) => mention.pluginId), + ); + return { ...merged, ...(reminder ? { reminder } : {}) }; + } const attributed = { ...merged, tools: attachPluginCapabilityAttribution( @@ -98,29 +108,38 @@ export function mergeAgentHostTurnCapabilities( ), }; if (input.userText === undefined) return attributed; - const reminder = buildPluginReferenceReminder( - detectPluginReferencesForMessages( - [{ content: input.userText }], - input.userText, - buildCapabilityInventory({ - capabilities, - effectivePluginSkills: input.effectivePluginSkills, - // Attribution decorates relevant tools with cloned wrapper objects. - // Inventory filtering must retain the pre-decoration identities used - // by runtimeToolBindings and the deferred registry. - finalTools: merged.tools, - deferredToolNames: new Set( - attributed.plan.deferred ? attributed.plan.deferredRegistry.keys() : [], - ), - }), + const inventory = buildCapabilityInventory({ + capabilities, + effectivePluginSkills: input.effectivePluginSkills, + // Inventory filtering uses the pre-attribution tool identities. + finalTools: merged.tools, + deferredToolNames: new Set( + attributed.plan.deferred ? attributed.plan.deferredRegistry.keys() : [], ), + }); + const unavailable = [ + ...new Set(parsePluginMentions(input.userText).map((mention) => mention.pluginId)), + ].filter( + (id) => + !inventory.some( + (plugin) => + plugin.pluginId === id && + (plugin.skills.length > 0 || plugin.appTools.length > 0 || plugin.mcpTools.length > 0), + ), + ); + const reminder = buildPluginReferenceReminder( + detectPluginReferencesForMessages([{ content: input.userText }], input.userText, inventory), + unavailable, ); return { ...attributed, ...(reminder ? { reminder } : {}) }; } function buildAttributionIndex(input: { readonly capabilities: AgentHostTurnCapabilityView; - readonly effectivePluginSkills: readonly { readonly pluginName: string; readonly name: string }[]; + readonly effectivePluginSkills: readonly { + readonly pluginName: string; + readonly name: string; + }[]; readonly finalTools: readonly RuntimeTool[]; readonly plan: McpDisclosurePlan; }): PluginCapabilityAttributionIndex { @@ -273,7 +292,10 @@ function mergeSearchableTools( ); for (const tool of selected.searchable) deferredRegistry.set(tool.def.name, tool); const index = buildOrReuseIndex( - [...deferredRegistry.values()].map((tool) => ({ tool, source: 'configured' as const })), + [...deferredRegistry.values()].map((tool) => ({ + tool, + source: 'configured' as const, + })), { maxSchemaTextLen: input.options.maxSchemaTextLen }, ); const estimate = input.options.estimateTokens ?? estimateToolTokens; @@ -312,16 +334,20 @@ function mergeSearchableTools( function buildCapabilityInventory(input: { readonly capabilities: AgentHostTurnCapabilityView; - readonly effectivePluginSkills: readonly { readonly pluginName: string; readonly name: string }[]; + readonly effectivePluginSkills: readonly { + readonly pluginName: string; + readonly name: string; + }[]; readonly finalTools: readonly RuntimeTool[]; readonly deferredToolNames: ReadonlySet; }): EffectivePluginCapabilityInventory[] { const finalTools = new Set(input.finalTools); - const searchableAppToolsAvailable = + const searchableToolsAvailable = input.finalTools.some((tool) => tool.def.name === 'tool_search') && input.finalTools.some((tool) => tool.def.name === 'mcp_invoke'); return input.capabilities.plugins.map((plugin) => ({ name: plugin.name, + pluginId: buildPluginId(plugin.name, plugin.source), appTools: plugin.appProviders.flatMap((provider) => { const bindings = input.capabilities.runtimeToolBindings.filter( (binding) => binding.kind === 'app' && binding.source === provider, @@ -333,7 +359,7 @@ function buildCapabilityInventory(input: { ? [binding.tool.def.name] : [], ); - const searchable = searchableAppToolsAvailable + const searchable = searchableToolsAvailable ? bindings.flatMap((binding) => binding.toolMode === 'tool_search' && input.deferredToolNames.has(binding.tool.def.name) ? [binding.tool.def.name] @@ -343,18 +369,37 @@ function buildCapabilityInventory(input: { return [ ...(direct.length > 0 ? [{ source: provider, tools: direct }] : []), ...(searchable.length > 0 - ? [{ source: provider, tools: searchable, access: 'tool_search' as const }] + ? [ + { + source: provider, + tools: searchable, + access: 'tool_search' as const, + }, + ] : []), ]; }), - mcpTools: groupMcpTools( - input.capabilities.runtimeToolBindings.filter( - (binding) => - binding.kind === 'mcp' && - binding.pluginName === plugin.name && - finalTools.has(binding.tool), + mcpTools: [ + ...groupMcpTools( + input.capabilities.runtimeToolBindings.filter( + (binding) => + binding.kind === 'mcp' && + binding.pluginName === plugin.name && + finalTools.has(binding.tool), + ), ), - ), + ...(searchableToolsAvailable + ? groupMcpTools( + input.capabilities.runtimeToolBindings.filter( + (binding) => + binding.kind === 'mcp' && + binding.pluginName === plugin.name && + !finalTools.has(binding.tool) && + input.deferredToolNames.has(binding.tool.def.name), + ), + ).map((group) => ({ ...group, access: 'tool_search' as const })) + : []), + ], skills: input.effectivePluginSkills.flatMap((skill) => skill.pluginName === plugin.name ? [skill.name] : [], ), diff --git a/packages/protocol/src/local.ts b/packages/protocol/src/local.ts index c313e961..10c8268f 100644 --- a/packages/protocol/src/local.ts +++ b/packages/protocol/src/local.ts @@ -339,6 +339,8 @@ export interface MemoryReferenceView { export interface SessionMessageView { msgId: string; + /** Canonical user text for editing when display text omits plugin identities. */ + editContent?: string; parentMsgId?: string; timestamp?: number; msgContent?: string; diff --git a/packages/shared/package.json b/packages/shared/package.json index 57e7636a..456b4e3a 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -191,6 +191,10 @@ "./safety-check-v2": { "types": "./src/safety-check-v2.ts", "import": "./dist/safety-check-v2.js" + }, + "./plugin-mention": { + "types": "./src/plugin-mention.ts", + "import": "./dist/plugin-mention.js" } }, "types": "./src/index.ts", diff --git a/packages/shared/src/plugin-mention.ts b/packages/shared/src/plugin-mention.ts new file mode 100644 index 00000000..d738380b --- /dev/null +++ b/packages/shared/src/plugin-mention.ts @@ -0,0 +1,40 @@ +/** Durable plugin identity shared by Composer history and Runtime selection. */ +export function buildPluginId(name: string, source: 'official' | 'local'): string { + return `${name.trim()}@${source}`; +} + +export interface PluginMention { + readonly pluginId: string; + readonly label: string; + readonly start: number; + readonly end: number; +} + +export function serializePluginMention(pluginId: string, label: string): string { + const name = label + .replace(/^@/u, '') + .replace(/[\[\]\r\n\\]/gu, ' ') + .slice(0, 256); + return `[@${name}](plugin://${encodeURIComponent(pluginId)})`; +} + +/** Linked references also work in exec and ACP text inputs. Display names never select identity. */ +export function parsePluginMentions(text: string): PluginMention[] { + const result: PluginMention[] = []; + for (const match of text.matchAll(/\[@([^\]\r\n]{1,256})\]\(plugin:\/\/([^\s()]{1,768})\)/gu)) { + let pluginId: string; + try { + pluginId = decodeURIComponent(match[2]!); + } catch { + continue; + } + if (!pluginId || pluginId.length > 512 || /[\u0000-\u001f\u007f]/u.test(pluginId)) continue; + result.push({ + pluginId, + label: `@${match[1]}`, + start: match.index, + end: match.index + match[0].length, + }); + } + return result; +} diff --git a/packages/tui/src/application/run-coordinator.ts b/packages/tui/src/application/run-coordinator.ts index bfdcda4e..211a1eb2 100644 --- a/packages/tui/src/application/run-coordinator.ts +++ b/packages/tui/src/application/run-coordinator.ts @@ -28,6 +28,7 @@ export interface TuiRunRequest { turnId: string; session: Promise; content: string; + displayContent?: string; workspace: string; version: string; attachments?: readonly TuiTransportAttachment[]; @@ -406,6 +407,7 @@ function toSendMessageRequest( id: session.sessionId, turnId: request.turnId, content: request.content, + ...(request.displayContent !== undefined ? { displayContent: request.displayContent } : {}), ...(executionDeadlineAtMs !== undefined ? { executionDeadlineAtMs } : {}), ...(request.executionDiagnostics ? { executionDiagnostics: true } : {}), ...(request.clientIntent ? { clientIntent: request.clientIntent } : {}), diff --git a/packages/tui/src/runtime/adapters/plugin-access.ts b/packages/tui/src/runtime/adapters/plugin-access.ts index 3b857017..035ec738 100644 --- a/packages/tui/src/runtime/adapters/plugin-access.ts +++ b/packages/tui/src/runtime/adapters/plugin-access.ts @@ -1,3 +1,4 @@ +import { buildPluginId } from '@mavis/shared/plugin-mention'; import type { CliService, InstalledPluginSummary, @@ -124,7 +125,7 @@ function toPluginView( const name = plugin.name.trim(); if (!name) return undefined; return { - pluginId: `${name}@${marketplace}`, + pluginId: buildPluginId(name, marketplace), name, displayName: plugin.displayName?.trim() || name, marketplace, diff --git a/packages/tui/src/runtime/stream-events.ts b/packages/tui/src/runtime/stream-events.ts index 5644b107..55ca8996 100644 --- a/packages/tui/src/runtime/stream-events.ts +++ b/packages/tui/src/runtime/stream-events.ts @@ -44,6 +44,7 @@ export type TuiMessagePart = }; export interface TuiMessage { + editContent?: string; id?: string; turnId?: string; role: TuiMessageRole; @@ -333,6 +334,7 @@ function normalizeMessage(message: Record, fallbackTurnId?: str tokensBefore: readNumber(message, ['tokensBefore', 'tokens_before']), tokensAfter: readNumber(message, ['tokensAfter', 'tokens_after']), content: readString(message, ['msg_content', 'msgContent', 'content']), + editContent: readString(message, ['editContent']), thinking: readString(message, ['thinking_content', 'thinkingContent', 'thinking']), thinkingDurationMs: readNumber(message, ['thinking_duration_ms', 'thinkingDurationMs']), toolCalls: normalizeToolCalls(readArray(message, ['tool_calls', 'toolCalls'])), diff --git a/packages/tui/src/tui/commands/plugin-autocomplete.ts b/packages/tui/src/tui/commands/plugin-autocomplete.ts new file mode 100644 index 00000000..abe63065 --- /dev/null +++ b/packages/tui/src/tui/commands/plugin-autocomplete.ts @@ -0,0 +1,113 @@ +import type { McodePluginRuntimeAccess } from '../../plugin/contract.js'; +import type { AutocompleteItem, AutocompleteProvider } from '../widgets/autocomplete.js'; +import { truncateToWidth } from '../engine/public.js'; +import { sanitizeTerminalText } from '../rendering/terminal-text.js'; + +export interface PluginAutocompleteItem extends AutocompleteItem { + readonly pluginId: string; + readonly groupLabel: string; +} + +/** Product-level plugin candidates share @ with files; plugin state stays in Runtime. */ +export class TuiPluginAutocomplete implements AutocompleteProvider { + readonly triggerCharacters = ['@']; + + constructor( + private readonly base: AutocompleteProvider, + private readonly plugins?: Partial>, + ) {} + + async getSuggestions( + lines: string[], + cursorLine: number, + cursorCol: number, + options: { signal: AbortSignal; force?: boolean }, + ) { + const before = (lines[cursorLine] ?? '').slice(0, cursorCol); + const prefix = /(?:^|\s)(@[^\s"/]*)$/u.exec(before)?.[1]; + if ( + !prefix || + lines.join('\n').trimStart().startsWith('!') || + !this.plugins?.listInstalledPlugins + ) { + return this.base.getSuggestions(lines, cursorLine, cursorCol, options); + } + const [files, plugins] = await Promise.allSettled([ + this.base.getSuggestions(lines, cursorLine, cursorCol, options), + this.plugins.listInstalledPlugins(), + ]); + if (options.signal.aborted) return null; + const fileSuggestions = files.status === 'fulfilled' ? files.value : null; + const query = prefix.slice(1).normalize('NFKC').toLocaleLowerCase(); + const items: PluginAutocompleteItem[] = + plugins.status === 'fulfilled' + ? plugins.value + .filter( + (plugin) => + plugin.installed && + plugin.enabled && + [plugin.name, plugin.displayName, plugin.pluginId].some((text) => + text.normalize('NFKC').toLocaleLowerCase().includes(query), + ), + ) + .sort( + (a, b) => + a.displayName.localeCompare(b.displayName) || a.pluginId.localeCompare(b.pluginId), + ) + .map((plugin) => { + const name = + sanitizeTerminalText(plugin.displayName) + .replace(/[\[\]\\]/gu, ' ') + .replace(/\s+/gu, ' ') + .trim() + .slice(0, 256) || plugin.name; + return { + value: `@${name}`, + label: `@${name}`, + pluginId: plugin.pluginId, + groupLabel: ' Plugins', + description: `${plugin.marketplace} · ${truncateToWidth( + sanitizeTerminalText(plugin.description ?? plugin.name) + .replace(/\s+/gu, ' ') + .trim(), + 64, + '…', + )}`, + }; + }) + : []; + const combined = [ + ...items, + ...(fileSuggestions?.items ?? []).map((item) => ({ + ...item, + ...(items.length > 0 ? { groupLabel: ' Files' } : {}), + })), + ]; + return combined.length ? { prefix, items: combined } : null; + } + + applyCompletion( + lines: string[], + cursorLine: number, + cursorCol: number, + item: AutocompleteItem, + prefix: string, + ) { + if (!('pluginId' in item)) + return this.base.applyCompletion(lines, cursorLine, cursorCol, item, prefix); + const line = lines[cursorLine] ?? ''; + const start = cursorCol - prefix.length; + const insertion = `${item.value} `; + const next = [...lines]; + next[cursorLine] = line.slice(0, start) + insertion + line.slice(cursorCol); + return { lines: next, cursorLine, cursorCol: start + insertion.length }; + } + + shouldAutoTriggerCompletion(lines: string[], cursorLine: number, cursorCol: number) { + return this.base.shouldAutoTriggerCompletion?.(lines, cursorLine, cursorCol); + } + + shouldTriggerFileCompletion(lines: string[], cursorLine: number, cursorCol: number) { + return this.base.shouldTriggerFileCompletion?.(lines, cursorLine, cursorCol) ?? false; + } +} diff --git a/packages/tui/src/tui/controller/chat-controller.ts b/packages/tui/src/tui/controller/chat-controller.ts index e058b1ff..520ca372 100644 --- a/packages/tui/src/tui/controller/chat-controller.ts +++ b/packages/tui/src/tui/controller/chat-controller.ts @@ -436,6 +436,7 @@ export class TuiChatController { turnId, session, content, + ...(options.displayContent !== undefined ? { displayContent } : {}), workspace: this.workspaceDir, version: this.version, ...(attachments.length > 0 ? { attachments } : {}), diff --git a/packages/tui/src/tui/controller/interaction/input-flow.ts b/packages/tui/src/tui/controller/interaction/input-flow.ts index 75509bb3..612196e7 100644 --- a/packages/tui/src/tui/controller/interaction/input-flow.ts +++ b/packages/tui/src/tui/controller/interaction/input-flow.ts @@ -109,6 +109,7 @@ export class TuiInputFlow { item, ) => { previousAutocompleteSelect?.(suggestions, item); + if ('pluginId' in item) return; const imageReference = selectedImageMentionReference(item.value, suggestions.prefix); if (!imageReference) return; void this.options.composerDraft.queueAttachment(imageReference).catch((error: unknown) => @@ -505,6 +506,7 @@ export class TuiInputFlow { this.options.editor.restoreDraft({ ...this.clearedEditorDraft, text: '', + pluginMentions: [], cursor: 0, pastes: [], attachmentPlaceholders: [], diff --git a/packages/tui/src/tui/controller/product/command-flow.ts b/packages/tui/src/tui/controller/product/command-flow.ts index 601e2997..b75a012b 100644 --- a/packages/tui/src/tui/controller/product/command-flow.ts +++ b/packages/tui/src/tui/controller/product/command-flow.ts @@ -1,3 +1,4 @@ +import { parsePluginMentions } from '@mavis/shared/plugin-mention'; import { createTuiCommandCatalog, matchTuiCommandInput, @@ -12,7 +13,7 @@ import { TuiLoginRegionPicker } from '../../features/auth/login-region-picker.js import { TuiPermissionModePicker } from '../../features/interaction/permission-mode-picker.js'; import { TuiSettingsPicker } from '../../features/settings/picker.js'; import { TuiHotkeysPicker } from '../../features/settings/hotkeys-picker.js'; -import { submittedEditorContent, type Editor } from '../../widgets/editor/editor.js'; +import { submittedEditorContent, submittedEditorTransport, type Editor } from '../../widgets/editor/editor.js'; import type { TuiInteractionSurface } from '../../shell/interaction-surface.js'; import type { TuiSurfaceHost } from '../../shell/surface-host.js'; import type { TuiRunProjection } from '../../state/run-projection.js'; @@ -226,10 +227,14 @@ export class TuiCommandFlow { // Hidden context may be rebuilt for an edited session-mutation message. // Other opaque transport (including /review) belongs to the old text and // must not override the user's correction. - const transportContent = recoverable?.transportContent && + const pluginTransport = recoverable?.transportContent && parsePluginMentions(recoverable.transportContent).length > 0 + ? rebuildSessionMutationTransport(recoverable.transportContent, submittedEditorTransport(editor) ?? visibleContent) + : undefined; + const transportContent = pluginTransport ?? (recoverable?.transportContent && + parsePluginMentions(recoverable.transportContent).length === 0 && (unchangedText || rebuildSessionMutationTransport(recoverable.transportContent, visibleContent)) ? recoverable.transportContent - : undefined; + : undefined); const transportAttachments = recoverable?.transportAttachments ? reconcileRecoveredTransportAttachments(recoverable, resources.attachments) : undefined; @@ -273,7 +278,7 @@ export class TuiCommandFlow { requireRuntimeAcceptance: true, transportContent: options.transportContent ?? - rebuildSessionMutationTransport(recoveryTransport, input) ?? + rebuildSessionMutationTransport(recoveryTransport, seed ? submittedEditorTransport(seed.editor) ?? input : input) ?? recoveryTransport, ...(seed?.reviewRequest && !options.reviewRequest ? { reviewRequest: seed.reviewRequest } @@ -513,9 +518,11 @@ export class TuiCommandFlow { const bashContext = this.options.bashFlow?.takeContext( seed ? seed.sessionId : this.options.controller.snapshot().session?.sessionId, ); + const boundContent = seed ? submittedEditorTransport(seed.editor) : undefined; + const messageContent = options.transportContent ?? boundContent; const transportContent = bashContext - ? `${bashContext}\n\n${options.transportContent ?? command}` - : options.transportContent; + ? `${bashContext}\n\n${messageContent ?? command}` + : messageContent; let submission = createTuiSubmissionSnapshot({ submissionId, sessionId: seed ? seed.sessionId : this.options.controller.snapshot().session?.sessionId, diff --git a/packages/tui/src/tui/controller/product/session-mutation-flow.ts b/packages/tui/src/tui/controller/product/session-mutation-flow.ts index 10f95a6e..9ffc66d3 100644 --- a/packages/tui/src/tui/controller/product/session-mutation-flow.ts +++ b/packages/tui/src/tui/controller/product/session-mutation-flow.ts @@ -1,3 +1,4 @@ +import { submittedEditorTransport } from '../../widgets/editor/editor.js'; import type { TuiChatController } from '../chat-controller.js'; import type { Component, Focusable } from '../../rendering/component.js'; import { matchesKey } from '../../engine/public.js'; @@ -415,8 +416,12 @@ export class TuiSessionMutationFlow { this.options.onChanged(); return 'retained'; } + const boundContent = draft ? submittedEditorTransport(draft) ?? content : content; const transportContent = - rebuildSessionMutationTransport(invocation.targetMessage?.content, content) ?? content; + rebuildSessionMutationTransport( + invocation.targetMessage?.editContent ?? invocation.targetMessage?.content, + boundContent, + ) ?? boundContent; if (invocation.phase === 'resubmit') { this.invocations.set(invocation.sequence, { ...invocation, phase: 'resubmitting' }); this.options.setHint(sessionMutationText('sessionMutation.hint.editSubmitting')); @@ -658,7 +663,9 @@ export class TuiSessionMutationFlow { this.closeHistoryScreen(); this.options.setEditTranscriptBoundary?.(targetMessage.id); this.invocations.set(sequence, { ...invocation, targetMessage, phase: 'editing' }); - const content = visibleSessionMutationContent(targetMessage.content); + const content = visibleSessionMutationContent( + targetMessage.editContent ?? targetMessage.content, + ); const placeholders = this.editAttachmentEntries(); if (placeholders.length > 0) this.options.editor.restoreMessageDraft(content, placeholders); else this.options.editor.setText(content); diff --git a/packages/tui/src/tui/controller/projection/turn-user-projection.ts b/packages/tui/src/tui/controller/projection/turn-user-projection.ts index 7bf3910c..957d0c1c 100644 --- a/packages/tui/src/tui/controller/projection/turn-user-projection.ts +++ b/packages/tui/src/tui/controller/projection/turn-user-projection.ts @@ -1,3 +1,4 @@ +import { parsePluginMentions } from '@mavis/shared/plugin-mention'; import type { TuiMessage } from '../../../runtime/port.js'; import { formatTuiHistorySubmission, @@ -34,7 +35,7 @@ export class TuiUserProjection { return; } const visibleContent = - message.source === 'code_review' ? '/review' : visibleSessionMutationContent(content); + message.source === 'code_review' ? '/review' : visibleUserContent(content); this.transcript.upsert({ id: `history:user:${messageId}`, kind: 'user', @@ -69,7 +70,7 @@ export class TuiUserProjection { return false; } const formatted = formatTuiHistorySubmission( - message.source === 'code_review' ? '/review' : visibleSessionMutationContent(content), + message.source === 'code_review' ? '/review' : visibleUserContent(content), message.attachments ?? [], ); const attachments = toTuiTranscriptAttachments(message.attachments ?? []); @@ -187,3 +188,12 @@ function mergeTranscriptAttachments( ...attachment, })); } + +/** Older persisted rows may still contain the durable plugin transport links. */ +function visibleUserContent(content: string): string { + let visible = visibleSessionMutationContent(content); + for (const mention of parsePluginMentions(visible).reverse()) { + visible = visible.slice(0, mention.start) + mention.label + visible.slice(mention.end); + } + return visible; +} diff --git a/packages/tui/src/tui/controller/run/active-run-flow.ts b/packages/tui/src/tui/controller/run/active-run-flow.ts index 2604786d..333083bb 100644 --- a/packages/tui/src/tui/controller/run/active-run-flow.ts +++ b/packages/tui/src/tui/controller/run/active-run-flow.ts @@ -1,3 +1,5 @@ +import { TuiPluginAutocomplete } from '../../commands/plugin-autocomplete.js'; +import type { McodePluginRuntimeAccess } from '../../../plugin/contract.js'; import type { Component } from '../../rendering/component.js'; import type { Terminal } from '../../engine/public.js'; import { @@ -57,7 +59,9 @@ export interface TuiSteerOptions { export function createTuiInitialAutocomplete( workspace: string | readonly TuiWorkspaceRoot[], - workspaceFiles?: Partial, + workspaceFiles?: Partial< + TuiWorkspaceFilePort & Pick + >, builtInCommands: readonly TuiCommand[] = MINIMAX_CODE_DISCOVERABLE_COMMANDS, ) { return createTuiAutocomplete(builtInCommands, [], workspace, workspaceFiles); @@ -67,15 +71,20 @@ export function createTuiAutocomplete( builtInCommands: readonly TuiCommand[], skillCommands: readonly TuiCommand[], workspace: string | readonly TuiWorkspaceRoot[], - workspaceFiles?: Partial, + workspaceFiles?: Partial< + TuiWorkspaceFilePort & Pick + >, shellCwd?: () => string, ): AutocompleteProvider { - return new TuiAutocompleteProvider( - builtInCommands, - skillCommands, - workspace, + return new TuiPluginAutocomplete( + new TuiAutocompleteProvider( + builtInCommands, + skillCommands, + workspace, + workspaceFiles, + shellCwd, + ), workspaceFiles, - shellCwd, ); } @@ -132,11 +141,9 @@ export class TuiActiveRunFlow { private autocompleteHasLiveRun: boolean | undefined; private autocompleteSignature: string | undefined; private pendingAutocomplete: - | { readonly provider: AutocompleteProvider; readonly signature: string } - | undefined; + { readonly provider: AutocompleteProvider; readonly signature: string } | undefined; private contextInspection: - | { readonly sessionId: string; readonly panel: TuiReportInspectionPanel } - | undefined; + { readonly sessionId: string; readonly panel: TuiReportInspectionPanel } | undefined; constructor( private readonly options: { @@ -569,7 +576,9 @@ class TuiAutocompleteProvider implements AutocompleteProvider { builtInCommands: readonly TuiCommand[], skillCommands: readonly TuiCommand[], workspace: string | readonly TuiWorkspaceRoot[], - private readonly workspaceFiles?: Partial, + private readonly workspaceFiles?: Partial< + TuiWorkspaceFilePort & Pick + >, shellCwd?: () => string, ) { this.workspaceRoots = normalizeAutocompleteRoots(workspace); @@ -732,7 +741,10 @@ class TuiAutocompleteProvider implements AutocompleteProvider { const directory = query ? query.slice(0, -1) : undefined; if (this.workspaceFiles?.listWorkspaceFileTreeCandidates) { const entries = await this.workspaceFiles.listWorkspaceFileTreeCandidates( - { roots: this.workspaceRoots, ...(directory ? { path: directory } : {}) }, + { + roots: this.workspaceRoots, + ...(directory ? { path: directory } : {}), + }, signal, ); return entries.flatMap((entry) => this.toMultiRootTreeItem(entry)); diff --git a/packages/tui/src/tui/controller/run/queue-flow.ts b/packages/tui/src/tui/controller/run/queue-flow.ts index 7632f22b..3a87c34f 100644 --- a/packages/tui/src/tui/controller/run/queue-flow.ts +++ b/packages/tui/src/tui/controller/run/queue-flow.ts @@ -1,3 +1,4 @@ +import { decodePluginMentions, encodePluginMentions, transformPluginMentions } from '../../widgets/editor/plugin-mentions.js'; import type { TuiModelSelection, TuiQueuePort, @@ -301,6 +302,12 @@ export class TuiQueueFlow { ? (submission?.transportContent ?? rememberedTransport) : (item.content ?? submission?.transportContent ?? rememberedTransport); if (!transportContent) return item; + const decoded = decodePluginMentions(visibleSessionMutationContent(transportContent)); + if (decoded.mentions.length) { + this.queuedTransportContents.set(item.itemId, transportContent); + return { ...item, content: decoded.text }; + } + if (rememberedTransport && decodePluginMentions(visibleSessionMutationContent(rememberedTransport)).text === item.content) return item; if (item.reviewRequest) { this.queuedTransportContents.set(item.itemId, transportContent); return { ...item, content: '/review' }; @@ -575,8 +582,11 @@ export class TuiQueueFlow { } const sessionId = this.options.controller.snapshot().session?.sessionId; if (!sessionId) return false; - const transportContent = - rebuildSessionMutationTransport(this.queuedTransportContents.get(itemId), content) ?? content; + const previousTransport = this.queuedTransportContents.get(itemId); + const previous = decodePluginMentions(visibleSessionMutationContent(previousTransport ?? cached?.content)); + const mentions = transformPluginMentions(previous.text, content, previous.mentions); + const boundContent = encodePluginMentions(content, mentions); + const transportContent = rebuildSessionMutationTransport(previousTransport, boundContent) ?? boundContent; const updated = await this.options.runtime.updateQueuedMessageContent( sessionId, itemId, @@ -588,6 +598,8 @@ export class TuiQueueFlow { await this.refresh(sessionId); return false; } + if (transportContent !== content) this.queuedTransportContents.set(itemId, transportContent); + else this.queuedTransportContents.delete(itemId); const captured = this.queuedSubmissions.get(itemId); if (captured) { this.queuedSubmissions.set(itemId, { @@ -596,6 +608,7 @@ export class TuiQueueFlow { schemaVersion: 1, text: content, cursor: content.length, + pluginMentions: mentions, pastes: [], pasteCounter: 0, }, @@ -792,7 +805,8 @@ function createSubmissionFromQueuedMessage( submissionId: string, transportContent?: string, ): TuiSubmissionSnapshot { - const content = item.content ?? ''; + const decoded = decodePluginMentions(visibleSessionMutationContent(transportContent ?? item.content)); + const content = decoded.text; const transportAttachments = (item.attachments ?? []).flatMap( (attachment) => { const filePath = attachment.local?.filePath; @@ -816,12 +830,13 @@ function createSubmissionFromQueuedMessage( editor: { schemaVersion: 1, text: content, + pluginMentions: decoded.mentions, cursor: content.length, pastes: [], pasteCounter: 0, }, content, - ...(transportContent ? { transportContent } : {}), + ...(transportContent || decoded.mentions.length ? { transportContent: transportContent ?? item.content } : {}), attachments: (item.attachments ?? []).flatMap((attachment) => { const filePath = attachment.local?.filePath; if (!filePath) return []; diff --git a/packages/tui/src/tui/engine/LOCAL_CHANGES.json b/packages/tui/src/tui/engine/LOCAL_CHANGES.json index f39b20b7..690550ab 100644 --- a/packages/tui/src/tui/engine/LOCAL_CHANGES.json +++ b/packages/tui/src/tui/engine/LOCAL_CHANGES.json @@ -21,9 +21,9 @@ }, { "path": "components/editor.ts", - "currentSha256": "0775a490df136d1083738cce649e026d6153ecb5480715b2c1104d51051968c5", - "changeIds": ["L005", "L009", "L022", "L030", "L032"], - "reason": "Keep strict TypeScript fixes and add the smallest generic snapshot, paste, telemetry, programmatic submission, undo-extension and empty-placeholder hooks needed by the MCode product wrapper. 提供方通过上下文回调控制自动触发,并通过 applyOnEnter 控制 Enter 是否填入候选。 强制补全上下文失效时立即取消请求并清空菜单。 支持单帧 placeholder 覆盖,由 Editor 统一保留 padding、可见光标和 IME 定位标记。 命令参数候选标记阶段,接受命令后续查参数,并隔离参数回调失败。", + "currentSha256": "55d313e4f7f5760212e0ff397cb7a599c52d42a90df8c0014f25cf4652897d20", + "changeIds": ["L005", "L009", "L022", "L030", "L032", "L042"], + "reason": "Preserve product history metadata through a generic text transform and draft extension state. Keep strict TypeScript fixes and add the smallest generic snapshot, paste, telemetry, programmatic submission, undo-extension and empty-placeholder hooks needed by the MCode product wrapper. 提供方通过上下文回调控制自动触发,并通过 applyOnEnter 控制 Enter 是否填入候选。 强制补全上下文失效时立即取消请求并清空菜单。 支持单帧 placeholder 覆盖,由 Editor 统一保留 padding、可见光标和 IME 定位标记。 命令参数候选标记阶段,接受命令后续查参数,并隔离参数回调失败。", "behaviorImpact": "MCode Draft, attachment, product-level submission-intent and empty-input guidance semantics use the canonical Pi Editor cursor, width, padding, history, undo, paste-marker and autocomplete behavior. Shell 补全只由 Tab 打开,菜单内输入继续过滤;Enter 执行当前输入,Tab 填入选中项。其他提供方沿用默认行为。 删除 Shell 标记或命令名前缀后立即关闭候选;已取消请求的延迟结果无法恢复旧菜单。 带参数候选的命令支持连续补全,参数 Enter 仅填入,Esc 保留草稿;无参数命令保持原行为。" }, { diff --git a/packages/tui/src/tui/engine/LOCAL_CHANGES.md b/packages/tui/src/tui/engine/LOCAL_CHANGES.md index 50b72941..b240a9f3 100644 --- a/packages/tui/src/tui/engine/LOCAL_CHANGES.md +++ b/packages/tui/src/tui/engine/LOCAL_CHANGES.md @@ -170,3 +170,10 @@ Remove `L024` when the selected Pi baseline natively matches legacy-terminal `Ct - Evidence: application tests replay `/theme`, `/settings`, prompt-history search, image-preview dismissal, multi-line draft clearing and completion filtering. Engine tests repeatedly expand/shrink each transient section under xterm and an ED 2 clear-to-scrollback model, compare the complete viewport, verify unique history, and retain positive background-activity scroll preservation. Short documents avoid unnecessary clearing. - Boundary: full history reconstruction retains L034's shell-scrollback tradeoff. Emulator tests do not establish native terminal or live-service acceptance. - Removal condition: the selected Pi baseline distinguishes transient UI layout shrink from ordinary background content shrink. + +## L042: Preserve product mention bindings in prompt history + +- Product contract: plugin labels retain their exact identities while browsing history, including restoration of the working draft. +- Minimal difference: expose a generic history-text decoder and capture/restore the existing undo extension state alongside the history draft. Plugin parsing and identity ownership stay in the product Editor. +- Evidence: `tui-plugin-mentions.test.ts` covers repeated history navigation, identical display labels with different IDs, working-draft restoration, atomic deletion and undo. +- Removal condition: the selected Pi baseline supports durable history decoding and draft extension state. diff --git a/packages/tui/src/tui/engine/components/editor.ts b/packages/tui/src/tui/engine/components/editor.ts index 999096c7..5c8a5657 100644 --- a/packages/tui/src/tui/engine/components/editor.ts +++ b/packages/tui/src/tui/engine/components/editor.ts @@ -356,6 +356,7 @@ export class Editor implements Component, Focusable { private history: string[] = []; private historyIndex: number = -1; // -1 = not browsing, 0 = most recent, 1 = older, etc. private historyDraft: EditorState | null = null; + private historyDraftExtensionState: unknown; // Kill ring for Emacs-style kill/yank operations private killRing = new KillRing(); @@ -377,6 +378,8 @@ export class Editor implements Component, Focusable { // Undo support private undoStack = new UndoStack(); + /** Decode a durable history entry before restoring its editable text. */ + public transformHistoryText?: (text: string) => string; public onSubmit?: (text: string, snapshot: EditorStateSnapshot) => void; public onChange?: (text: string) => void; public onPaste?: (text: string) => boolean; @@ -496,6 +499,7 @@ export class Editor implements Component, Focusable { if (this.historyIndex === -1 && newIndex >= 0) { this.pushUndoSnapshot(); this.historyDraft = structuredClone(this.state); + this.historyDraftExtensionState = this.captureUndoExtensionState?.(); } this.historyIndex = newIndex; @@ -505,6 +509,8 @@ export class Editor implements Component, Focusable { this.historyDraft = null; if (draft) { this.state = draft; + this.restoreUndoExtensionState?.(this.historyDraftExtensionState); + this.historyDraftExtensionState = undefined; this.preferredVisualCol = null; this.snappedFromCursorCol = null; this.scrollOffset = 0; @@ -514,7 +520,7 @@ export class Editor implements Component, Focusable { } } else { this.setTextInternal( - this.history[this.historyIndex] || '', + this.transformHistoryText?.(this.history[this.historyIndex] || '') ?? this.history[this.historyIndex] ?? '', direction === -1 ? 'start' : 'end', ); } @@ -523,6 +529,7 @@ export class Editor implements Component, Focusable { private exitHistoryBrowsing(): void { this.historyIndex = -1; this.historyDraft = null; + this.historyDraftExtensionState = undefined; } /** Internal setText that doesn't reset history state - used by navigateHistory */ @@ -1221,6 +1228,8 @@ export class Editor implements Component, Focusable { this.onAutocompleteSelect = undefined; this.captureUndoExtensionState = undefined; this.restoreUndoExtensionState = undefined; + this.transformHistoryText = undefined; + this.historyDraftExtensionState = undefined; } /** diff --git a/packages/tui/src/tui/features/composer/draft-recovery.ts b/packages/tui/src/tui/features/composer/draft-recovery.ts index 0e8a829e..505198dd 100644 --- a/packages/tui/src/tui/features/composer/draft-recovery.ts +++ b/packages/tui/src/tui/features/composer/draft-recovery.ts @@ -1,3 +1,4 @@ +import { validPluginMentions } from '../../widgets/editor/plugin-mentions.js'; import { createHash } from 'node:crypto'; import { chmod, @@ -431,7 +432,10 @@ export class TuiDraftRecovery { const relocatedPath = attachment.filePath ? relocatedPaths.get(attachment.filePath) : undefined; - return { ...attachment, ...(relocatedPath ? { filePath: relocatedPath } : {}) }; + return { + ...attachment, + ...(relocatedPath ? { filePath: relocatedPath } : {}), + }; }), } : {}), @@ -450,7 +454,9 @@ export class TuiDraftRecovery { .map((attachment) => resolve(attachment.filePath)) .filter((filePath) => dirname(filePath) === resolve(this.assetsDirectory)), ); - const entries = await readdir(this.assetsDirectory, { withFileTypes: true }).catch(() => []); + const entries = await readdir(this.assetsDirectory, { + withFileTypes: true, + }).catch(() => []); await Promise.all( entries.map(async (entry) => { if (!entry.isFile()) return; @@ -509,6 +515,7 @@ function cloneDraft(draft: TuiRecoverableDraft): TuiRecoverableDraft { return { editor: { ...draft.editor, + pluginMentions: draft.editor.pluginMentions?.map((mention) => ({ ...mention })), pastes: draft.editor.pastes.map((paste) => ({ ...paste })), ...(draft.editor.attachmentPlaceholders ? { @@ -538,7 +545,9 @@ function cloneRetrySubmission(retry: TuiRetrySubmission): TuiRetrySubmission { ...(retry.snapshot.transportContent ? { transportContent: retry.snapshot.transportContent } : {}), - attachments: retry.snapshot.attachments.map((attachment) => ({ ...attachment })), + attachments: retry.snapshot.attachments.map((attachment) => ({ + ...attachment, + })), ...(retry.snapshot.transportAttachments ? { transportAttachments: retry.snapshot.transportAttachments.map((attachment) => ({ @@ -556,6 +565,7 @@ function cloneRetrySubmission(retry: TuiRetrySubmission): TuiRetrySubmission { function cloneEditorDraft(editor: EditorDraftSnapshot): EditorDraftSnapshot { return { ...editor, + pluginMentions: editor.pluginMentions?.map((mention) => ({ ...mention })), pastes: editor.pastes.map((paste) => ({ ...paste })), ...(editor.attachmentPlaceholders ? { @@ -749,6 +759,7 @@ function isEditorDraft(value: unknown): value is EditorDraftSnapshot { !isRecord(value) || value.schemaVersion !== 1 || typeof value.text !== 'string' || + !validPluginMentions(value.text, value.pluginMentions) || !Number.isInteger(value.cursor) || (value.cursor as number) < 0 || (value.cursor as number) > value.text.length || diff --git a/packages/tui/src/tui/features/composer/submission.ts b/packages/tui/src/tui/features/composer/submission.ts index 85075427..5f904fb8 100644 --- a/packages/tui/src/tui/features/composer/submission.ts +++ b/packages/tui/src/tui/features/composer/submission.ts @@ -51,7 +51,9 @@ export function createTuiSubmissionSnapshot(options: { editor: cloneEditorDraft(options.editor), content: options.content, ...(options.transportContent ? { transportContent: options.transportContent } : {}), - attachments: options.resources.attachments.map((attachment) => ({ ...attachment })), + attachments: options.resources.attachments.map((attachment) => ({ + ...attachment, + })), transportAttachments: (options.transportAttachments ?? options.resources.attachments).map( (attachment) => ({ ...attachment }), ), @@ -64,6 +66,7 @@ export function createTuiSubmissionSnapshot(options: { function cloneEditorDraft(editor: EditorDraftSnapshot): EditorDraftSnapshot { return { ...editor, + pluginMentions: editor.pluginMentions?.map((mention) => ({ ...mention })), pastes: editor.pastes.map((paste) => ({ ...paste })), ...(editor.attachmentPlaceholders ? { diff --git a/packages/tui/src/tui/features/history/search-panel.ts b/packages/tui/src/tui/features/history/search-panel.ts index 0a568d69..cc2aad2e 100644 --- a/packages/tui/src/tui/features/history/search-panel.ts +++ b/packages/tui/src/tui/features/history/search-panel.ts @@ -1,3 +1,4 @@ +import { decodePluginMentions } from '../../widgets/editor/plugin-mentions.js'; import { Input, matchesKey } from '../../engine/public.js'; import type { Component, Focusable } from '../../rendering/component.js'; import { truncateToWidth, visibleWidth } from '../../rendering/text.js'; @@ -67,8 +68,8 @@ export class TuiHistorySearchPanel implements Component, Focusable { const selected = index === this.selectedIndex; const prefix = selected ? chalk.bold.hex(colors.signal)('› ') : ' '; const value = selected - ? chalk.bold.hex(colors.text)(sanitizeTerminalText(entry)) - : chalk.hex(colors.muted)(sanitizeTerminalText(entry)); + ? chalk.bold.hex(colors.text)(sanitizeTerminalText(decodePluginMentions(entry).text)) + : chalk.hex(colors.muted)(sanitizeTerminalText(decodePluginMentions(entry).text)); return fit(`${prefix}${value}`, safeWidth); }) : [fit(chalk.hex(colors.muted)(' No matching prompts'), safeWidth)]; @@ -82,7 +83,7 @@ export class TuiHistorySearchPanel implements Component, Focusable { private matches(): string[] { const query = this.searchInput.getValue().trim().toLocaleLowerCase(); if (!query) return [...this.options.entries]; - return this.options.entries.filter((entry) => entry.toLocaleLowerCase().includes(query)); + return this.options.entries.filter((entry) => decodePluginMentions(entry).text.toLocaleLowerCase().includes(query)); } private move(direction: -1 | 1): void { diff --git a/packages/tui/src/tui/shell/composer.ts b/packages/tui/src/tui/shell/composer.ts index 4cb207c2..f6e1fbe3 100644 --- a/packages/tui/src/tui/shell/composer.ts +++ b/packages/tui/src/tui/shell/composer.ts @@ -260,7 +260,7 @@ function composerLabels( return [`${attachment} · ${submit} send`, attachment, 'Message']; } if (state.surface === 'welcome') { - return ['Start · @ file · / autocomplete', 'Start below']; + return ['Start · @ file or Plugin · / autocomplete', 'Start below']; } const newline = composerNewlineKeybinding(supportsShiftEnter, keybindings); return [`Message · ${submit} send · ${newline} newline`, `Message · ${submit} send`, 'Message']; diff --git a/packages/tui/src/tui/theme/custom-themes.ts b/packages/tui/src/tui/theme/custom-themes.ts index 453a8c72..afb59ae6 100644 --- a/packages/tui/src/tui/theme/custom-themes.ts +++ b/packages/tui/src/tui/theme/custom-themes.ts @@ -288,6 +288,9 @@ export function watchCustomThemes( try { watcher = watch(directory, { persistent: false }, fire); watcher.on('error', () => undefined); + // Native watching may miss edits made while it starts (notably on macOS). + // Reconcile once after registration, then use the same debounced event path. + fire(); } catch { watcher = undefined; } diff --git a/packages/tui/src/tui/widgets/editor/editor.ts b/packages/tui/src/tui/widgets/editor/editor.ts index f81e3eff..e7ee277f 100644 --- a/packages/tui/src/tui/widgets/editor/editor.ts +++ b/packages/tui/src/tui/widgets/editor/editor.ts @@ -1,3 +1,11 @@ +import { + decodePluginMentions, + encodePluginMentions, + transformPluginMentions, + transformExternalPluginMentions, + validPluginMentions, + type EditorPluginMention, +} from './plugin-mentions.js'; import type { AutocompleteProvider, AutocompleteSuggestions } from '../autocomplete.js'; import { decodePrintableKey, @@ -20,6 +28,7 @@ export type { EditorTheme }; export interface EditorDraftSnapshot extends EditorStateSnapshot { readonly attachmentPlaceholders?: readonly EditorAttachmentElement[]; + readonly pluginMentions?: readonly EditorPluginMention[]; } export interface EditorAttachmentPlaceholder { @@ -46,6 +55,11 @@ export class Editor implements Component, Focusable { ) => void; private readonly engine: PiEditor; + private pluginMentions: EditorPluginMention[] = []; + private pendingPluginMention: EditorPluginMention | undefined; + private pendingPluginState: EditorPluginMention[] | undefined; + private skipNextPluginTransform = false; + private lastPluginSubmission: { content: string; transport: string } | undefined; private attachmentPlaceholders = new Map(); private bindLegacyAttachmentPlaceholders = false; private lastText = ''; @@ -70,10 +84,39 @@ export class Editor implements Component, Focusable { this.engine.onSubmit = (_text, snapshot) => this.handleEngineSubmit(snapshot); this.engine.onPaste = (text) => this.onPaste?.(text) ?? false; this.engine.onAutocompleteView = (suggestions) => this.onAutocompleteView?.(suggestions); - this.engine.onAutocompleteSelect = (suggestions, item) => + this.engine.onAutocompleteSelect = (suggestions, item) => { + if ('pluginId' in item && typeof item.pluginId === 'string') { + const start = this.engine.captureState().cursor - suggestions.prefix.length; + this.pendingPluginMention = { + pluginId: item.pluginId, + label: item.value, + start, + end: start + item.value.length, + }; + } this.onAutocompleteSelect?.(suggestions, item); - this.engine.captureUndoExtensionState = () => [...this.attachmentPlaceholders.values()]; - this.engine.restoreUndoExtensionState = (state) => this.restoreAttachmentUndoState(state); + }; + this.engine.captureUndoExtensionState = () => ({ + attachments: [...this.attachmentPlaceholders.values()], + plugins: this.pluginMentions.map((mention) => ({ ...mention })), + }); + this.engine.restoreUndoExtensionState = (state) => { + if (!state || typeof state !== 'object' || !('attachments' in state) || !('plugins' in state)) + return; + this.restoreAttachmentUndoState(state.attachments); + if (validPluginMentions(this.getText(), state.plugins)) { + this.pluginMentions = (state.plugins ?? []).map((mention) => ({ + ...mention, + })); + this.skipNextPluginTransform = true; + } + }; + this.engine.transformHistoryText = (text) => { + const decoded = decodePluginMentions(text); + this.pluginMentions = decoded.mentions; + this.skipNextPluginTransform = true; + return decoded.text; + }; } get focused(): boolean { @@ -113,7 +156,15 @@ export class Editor implements Component, Focusable { } addToHistory(text: string): void { - this.engine.addToHistory(text); + const current = this.captureDraft(); + const transport = + this.lastPluginSubmission?.content === text.trim() + ? this.lastPluginSubmission.transport + : submittedEditorContent(current) === text.trim() + ? submittedEditorTransport(current) + : undefined; + this.engine.addToHistory(transport ?? text); + this.lastPluginSubmission = undefined; } getHistoryEntries(): readonly string[] { @@ -139,6 +190,7 @@ export class Editor implements Component, Focusable { captureDraft(): EditorDraftSnapshot { return { ...this.engine.captureState(), + pluginMentions: this.pluginMentions.map((mention) => ({ ...mention })), attachmentPlaceholders: [...this.attachmentPlaceholders.values()].map((element) => ({ ...element, })), @@ -148,6 +200,11 @@ export class Editor implements Component, Focusable { restoreDraft(snapshot: EditorDraftSnapshot): boolean { if (!isValidEditorDraftSnapshot(snapshot)) return false; const previous = this.attachmentPlaceholders; + const previousPlugins = this.pluginMentions; + this.pluginMentions = (snapshot.pluginMentions ?? []).map((mention) => ({ + ...mention, + })); + this.skipNextPluginTransform = true; this.attachmentPlaceholders = new Map( (snapshot.attachmentPlaceholders ?? []).map((element) => [element.id, { ...element }]), ); @@ -157,6 +214,8 @@ export class Editor implements Component, Focusable { const restored = this.engine.restoreState(snapshot); if (!restored) { this.attachmentPlaceholders = previous; + this.pluginMentions = previousPlugins; + this.skipNextPluginTransform = false; this.skipNextAttachmentTransform = false; } return restored; @@ -180,8 +239,18 @@ export class Editor implements Component, Focusable { cursor: currentOffset + remappedCurrent.cursor, pastes: [...snapshot.pastes.map((paste) => ({ ...paste })), ...remappedCurrent.pastes], pasteCounter: remappedCurrent.pasteCounter, + pluginMentions: [ + ...(snapshot.pluginMentions ?? []).map((mention) => ({ ...mention })), + ...(remappedCurrent.pluginMentions ?? []).map((mention) => ({ + ...mention, + start: mention.start + currentOffset, + end: mention.end + currentOffset, + })), + ], attachmentPlaceholders: [ - ...(snapshot.attachmentPlaceholders ?? []).map((element) => ({ ...element })), + ...(snapshot.attachmentPlaceholders ?? []).map((element) => ({ + ...element, + })), ...(remappedCurrent.attachmentPlaceholders ?? []) .filter(({ id }) => !submittedAttachmentIds.has(id)) .map((element) => ({ @@ -239,7 +308,10 @@ export class Editor implements Component, Focusable { dismissAttachmentPreview(): void { const preview = this.getAttachmentPreview(); if (!preview) return; - this.dismissedAttachmentPreview = { id: preview.id, cursor: this.engine.captureState().cursor }; + this.dismissedAttachmentPreview = { + id: preview.id, + cursor: this.engine.captureState().cursor, + }; this.freshAttachmentPreview = undefined; this.tui.requestRender(); } @@ -251,6 +323,7 @@ export class Editor implements Component, Focusable { let cursor = this.engine.captureState().cursor; let changed = false; const elements = cloneAttachmentElements(this.attachmentPlaceholders); + let pluginMentions = this.pluginMentions.map((mention) => ({ ...mention })); if (this.bindLegacyAttachmentPlaceholders) { const claimedRanges: Array<{ start: number; end: number }> = []; for (const [id, label] of next) { @@ -270,7 +343,9 @@ export class Editor implements Component, Focusable { replacement: string, target?: { id: string; label: string }, ): void => { - text = `${text.slice(0, start)}${replacement}${text.slice(end)}`; + const nextText = `${text.slice(0, start)}${replacement}${text.slice(end)}`; + pluginMentions = transformPluginMentions(text, nextText, pluginMentions); + text = nextText; if (cursor > end) cursor += replacement.length - (end - start); else if (cursor > start) cursor = start + replacement.length; const delta = replacement.length - (end - start); @@ -342,10 +417,14 @@ export class Editor implements Component, Focusable { return; } this.pendingAttachmentElements = elements; + this.pendingPluginState = pluginMentions; this.skipNextAttachmentTransform = true; this.engine.replaceRange(0, this.getText().length, text, cursor); if (added) { - this.freshAttachmentPreview = { id: added.id, cursor: this.engine.captureState().cursor }; + this.freshAttachmentPreview = { + id: added.id, + cursor: this.engine.captureState().cursor, + }; this.dismissedAttachmentPreview = undefined; } } @@ -359,16 +438,32 @@ export class Editor implements Component, Focusable { } setText(text: string): void { - const value = text.replace(/\r\n?/gu, '\n'); + const decoded = decodePluginMentions(text.replace(/\r\n?/gu, '\n')); + const value = decoded.text; const replaced = appendAttachmentElements(value, this.attachmentPlaceholders); - if (replaced.text === this.getText()) return; + if (replaced.text === this.getText()) { + if (text !== value) { + this.pluginMentions = decoded.mentions; + this.onChange?.(this.getText()); + } + return; + } + this.pendingPluginMention = undefined; + this.pendingPluginState = decoded.mentions; this.pendingAttachmentElements = replaced.elements; this.skipNextAttachmentTransform = true; this.engine.replaceRange(0, this.getText().length, replaced.text, replaced.cursor); } replaceTextUndoable(text: string): void { - this.setText(text); + // External editors edit expanded visible text, while setText loads a new draft. + const draft = this.captureDraft(); + const previous = decodePluginMentions( + expandDraftPastes(encodePluginMentions(draft.text, this.pluginMentions), draft.pastes), + ); + const normalized = text.replace(/\r\n?/gu, '\n'); + const mentions = transformExternalPluginMentions(previous.text, normalized, previous.mentions); + this.setText(encodePluginMentions(normalized, mentions)); } invalidate(): void { @@ -407,6 +502,7 @@ export class Editor implements Component, Focusable { dispose(): void { this.engine.dispose(); this.attachmentPlaceholders.clear(); + this.pluginMentions = []; this.pendingSubmission = undefined; this.lastAttachmentSubmission = undefined; this.onAttachmentPlaceholderDeleted = undefined; @@ -414,7 +510,21 @@ export class Editor implements Component, Focusable { } private handleEngineChange(text: string): void { + if (this.pendingPluginState) { + this.pluginMentions = this.pendingPluginState; + this.pendingPluginState = undefined; + } else if (this.skipNextPluginTransform) this.skipNextPluginTransform = false; + else this.pluginMentions = transformPluginMentions(this.lastText, text, this.pluginMentions); + if (this.pendingPluginMention) { + if ( + text.slice(this.pendingPluginMention.start, this.pendingPluginMention.end) === + this.pendingPluginMention.label + ) + this.pluginMentions.push(this.pendingPluginMention); + this.pendingPluginMention = undefined; + } if (this.pendingSubmission && text === '') { + this.pluginMentions = []; this.attachmentPlaceholders.clear(); this.lastText = ''; this.skipNextAttachmentTransform = false; @@ -445,12 +555,15 @@ export class Editor implements Component, Focusable { private handleEngineSubmit(snapshot: EditorStateSnapshot): void { const draft: EditorDraftSnapshot = { ...snapshot, + pluginMentions: this.pendingSubmission?.pluginMentions?.map((mention) => ({ ...mention })), attachmentPlaceholders: (this.pendingSubmission?.attachmentPlaceholders ?? []).map( (element) => ({ ...element }), ), }; this.pendingSubmission = undefined; const content = submittedEditorContent(draft); + const transport = submittedEditorTransport(draft); + this.lastPluginSubmission = transport ? { content, transport } : undefined; if (draft.attachmentPlaceholders?.length) this.lastAttachmentSubmission = { content, draft }; this.onSubmit?.(content, draft); } @@ -474,14 +587,24 @@ export class Editor implements Component, Focusable { } } + private atomicElements(): EditorAttachmentElement[] { + return [ + ...this.attachmentPlaceholders.values(), + ...this.pluginMentions.map((mention) => ({ + ...mention, + id: `plugin:${mention.start}`, + })), + ]; + } + private handleAtomicAttachmentInput(data: string): boolean { - if (this.attachmentPlaceholders.size === 0) return false; + if (this.atomicElements().length === 0) return false; const command = resolveAttachmentCommand(data); if (!command || command === 'jump-forward' || command === 'jump-backward') return false; const snapshot = this.engine.captureState(); const cursor = snapshot.cursor; const text = snapshot.text; - const elements = [...this.attachmentPlaceholders.values()]; + const elements = this.atomicElements(); if (command === 'left' || command === 'word-left') { const element = elements.find( (candidate) => cursor > candidate.start && cursor <= attachmentElementEnd(text, candidate), @@ -525,7 +648,7 @@ export class Editor implements Component, Focusable { this.jumpOutsideAttachments(printable, direction); return true; } - if (this.attachmentPlaceholders.size === 0) return false; + if (this.atomicElements().length === 0) return false; const command = resolveAttachmentCommand(data); if (command !== 'jump-forward' && command !== 'jump-backward') return false; this.attachmentJumpMode = command === 'jump-forward' ? 'forward' : 'backward'; @@ -541,7 +664,7 @@ export class Editor implements Component, Focusable { ? snapshot.text.indexOf(character, from) : snapshot.text.lastIndexOf(character, from); if (target < 0) return; - const element = [...this.attachmentPlaceholders.values()].find( + const element = this.atomicElements().find( (candidate) => target >= candidate.start && target < candidate.end, ); if (!element) { @@ -555,7 +678,7 @@ export class Editor implements Component, Focusable { private snapCursorOutsideAttachment(previousCursor: number): void { const snapshot = this.engine.captureState(); - const element = [...this.attachmentPlaceholders.values()].find( + const element = this.atomicElements().find( (candidate) => snapshot.cursor > candidate.start && snapshot.cursor < candidate.end, ); if (!element) return; @@ -623,7 +746,10 @@ function editorDeletionRange( }; } if (command === 'delete-line-start') { - return { start: text.lastIndexOf('\n', Math.max(0, cursor - 1)) + 1, end: cursor }; + return { + start: text.lastIndexOf('\n', Math.max(0, cursor - 1)) + 1, + end: cursor, + }; } const lineEnd = text.indexOf('\n', cursor); return { @@ -652,6 +778,7 @@ function isValidEditorDraftSnapshot(snapshot: EditorDraftSnapshot): boolean { !snapshot || snapshot.schemaVersion !== 1 || typeof snapshot.text !== 'string' || + !validPluginMentions(snapshot.text, snapshot.pluginMentions) || !Number.isInteger(snapshot.cursor) || snapshot.cursor < 0 || snapshot.cursor > snapshot.text.length || @@ -698,7 +825,10 @@ function isValidEditorDraftSnapshot(snapshot: EditorDraftSnapshot): boolean { return false; } attachmentIds.add(element.id); - ranges.push({ start: attachmentElementStart(snapshot.text, element), end: element.end }); + ranges.push({ + start: attachmentElementStart(snapshot.text, element), + end: element.end, + }); return true; }); } @@ -728,7 +858,14 @@ function remapEditorDraftPastes( const id = Number(match[1]); const paste = draft.pastes.find((candidate) => candidate.id === id); if (!paste || match.index === undefined) return []; - return [{ start: match.index, end: match.index + match[0].length, paste, marker: match[0] }]; + return [ + { + start: match.index, + end: match.index + match[0].length, + paste, + marker: match[0], + }, + ]; }) .map((replacement, index) => { const id = startingCounter + index + 1; @@ -754,8 +891,16 @@ function remapEditorDraftPastes( schemaVersion: 1, text, cursor: shiftPosition(draft.cursor), - pastes: replacements.map(({ id, paste }) => ({ id, content: paste.content })), + pastes: replacements.map(({ id, paste }) => ({ + id, + content: paste.content, + })), pasteCounter: startingCounter + replacements.length, + pluginMentions: draft.pluginMentions?.map((mention) => ({ + ...mention, + start: shiftPosition(mention.start), + end: shiftPosition(mention.end), + })), attachmentPlaceholders: (draft.attachmentPlaceholders ?? []).map((element) => ({ ...element, start: shiftPosition(element.start), @@ -787,7 +932,11 @@ function attachmentElementEnd(text: string, element: EditorAttachmentElement): n function appendAttachmentElements( text: string, elements: ReadonlyMap, -): { text: string; cursor: number; elements: Map } { +): { + text: string; + cursor: number; + elements: Map; +} { let output = text; let cursor = output.length; const appended = new Map(); @@ -812,6 +961,29 @@ export function submittedEditorContent(draft: EditorDraftSnapshot): string { return expandDraftPastes(visibleText, draft.pastes).trim(); } +/** Serialize identity bindings into the existing durable user-text transport. */ +export function submittedEditorTransport(draft: EditorDraftSnapshot): string | undefined { + if (!draft.pluginMentions?.length) return undefined; + let text = draft.text; + const replacements = [ + ...draft.pluginMentions.map((mention) => ({ + start: mention.start, + end: mention.end, + text: encodePluginMentions(mention.label, [ + { ...mention, start: 0, end: mention.label.length }, + ]), + })), + ...(draft.attachmentPlaceholders ?? []).map((element) => ({ + start: attachmentElementStart(text, element), + end: attachmentElementEnd(text, element), + text: '', + })), + ].sort((a, b) => b.start - a.start); + for (const replacement of replacements) + text = text.slice(0, replacement.start) + replacement.text + text.slice(replacement.end); + return expandDraftPastes(text, draft.pastes).trim(); +} + function removeAttachmentElements( text: string, elements: readonly EditorAttachmentElement[], diff --git a/packages/tui/src/tui/widgets/editor/plugin-mentions.ts b/packages/tui/src/tui/widgets/editor/plugin-mentions.ts new file mode 100644 index 00000000..ca882832 --- /dev/null +++ b/packages/tui/src/tui/widgets/editor/plugin-mentions.ts @@ -0,0 +1,134 @@ +import { + parsePluginMentions, + serializePluginMention, + type PluginMention, +} from '@mavis/shared/plugin-mention'; + +export type EditorPluginMention = PluginMention; + +/** Decode durable history links into visible labels with exact identity bindings. */ +export function decodePluginMentions(text: string): { + text: string; + mentions: EditorPluginMention[]; +} { + const mentions: EditorPluginMention[] = []; + let offset = 0; + let previousEnd = 0; + let output = ''; + for (const mention of parsePluginMentions(text)) { + output += text.slice(previousEnd, mention.start) + mention.label; + const start = mention.start + offset; + mentions.push({ ...mention, start, end: start + mention.label.length }); + offset += mention.label.length - (mention.end - mention.start); + previousEnd = mention.end; + } + return { text: output + text.slice(previousEnd), mentions }; +} + +export function encodePluginMentions( + text: string, + mentions: readonly EditorPluginMention[], +): string { + let output = text; + for (const mention of [...mentions].sort((a, b) => b.start - a.start)) { + if (text.slice(mention.start, mention.end) !== mention.label) continue; + output = + output.slice(0, mention.start) + + serializePluginMention(mention.pluginId, mention.label) + + output.slice(mention.end); + } + return output; +} + +export function validPluginMentions( + text: string, + value: unknown, +): value is readonly EditorPluginMention[] | undefined { + if (value === undefined) return true; + if (!Array.isArray(value)) return false; + const ranges: Array<{ start: number; end: number }> = []; + return value.every((item) => { + if ( + !item || + typeof item !== 'object' || + typeof item.pluginId !== 'string' || + !item.pluginId || + item.pluginId.length > 512 || + /[\u0000-\u001f\u007f]/u.test(item.pluginId) || + typeof item.label !== 'string' || + !item.label.startsWith('@') || + item.label.length > 257 || + !Number.isInteger(item.start) || + !Number.isInteger(item.end) || + item.start < 0 || + item.end <= item.start || + item.end > text.length || + text.slice(item.start, item.end) !== item.label || + ranges.some((range) => item.start < range.end && item.end > range.start) + ) + return false; + ranges.push(item); + return true; + }); +} + +/** Edits inside a mention remove its binding; edits outside only move its range. */ +export function transformPluginMentions( + previous: string, + next: string, + mentions: readonly EditorPluginMention[], +): EditorPluginMention[] { + let start = 0; + while (start < previous.length && start < next.length && previous[start] === next[start]) start++; + let oldEnd = previous.length; + let newEnd = next.length; + while (oldEnd > start && newEnd > start && previous[oldEnd - 1] === next[newEnd - 1]) { + oldEnd--; + newEnd--; + } + return mentions.flatMap((mention) => { + if (mention.end <= start) return [{ ...mention }]; + if (mention.start < oldEnd) return []; + const delta = newEnd - oldEnd; + return [{ ...mention, start: mention.start + delta, end: mention.end + delta }]; + }); +} + +/** External edits can change several ranges at once; only relocate unambiguous labels. */ +export function transformExternalPluginMentions( + previous: string, + next: string, + mentions: readonly EditorPluginMention[], +): EditorPluginMention[] { + const positions = new Map(); + const occurrences = (text: string, label: string): number[] => { + const result: number[] = []; + for ( + let start = text.indexOf(label); + start >= 0; + start = text.indexOf(label, start + label.length) + ) { + result.push(start); + } + return result; + }; + return mentions + .flatMap((mention) => { + let matches = positions.get(mention.label); + if (!matches) { + matches = { + before: occurrences(previous, mention.label), + after: occurrences(next, mention.label), + }; + positions.set(mention.label, matches); + } + // Inserting/removing a duplicate visible label makes its source identity ambiguous. + if (matches.before.length !== matches.after.length) return []; + if (matches.before.length === 1) { + const start = matches.after[0]!; + return [{ ...mention, start, end: start + mention.label.length }]; + } + return transformPluginMentions(previous, next, [mention]); + }) + .sort((a, b) => a.start - b.start); +} diff --git a/packages/tui/test/unit/tui-app.test.ts b/packages/tui/test/unit/tui-app.test.ts index 9461c9d6..30d84699 100644 --- a/packages/tui/test/unit/tui-app.test.ts +++ b/packages/tui/test/unit/tui-app.test.ts @@ -1982,7 +1982,7 @@ describe("createTuiApp", () => { const starting = app.tui.render(80).join("\n"); expect(starting).toContain("⠋ Starting server..."); - expect(starting).not.toContain("Start · @ file · / autocomplete"); + expect(starting).not.toContain("Start · @ file or Plugin · / autocomplete"); expect(starting).not.toContain("Loading session"); releaseSessions?.(); @@ -1990,7 +1990,7 @@ describe("createTuiApp", () => { app.setStartupStatus(undefined); expect(app.tui.render(80).join("\n")).toContain( - "Start · @ file · / autocomplete", + "Start · @ file or Plugin · / autocomplete", ); } finally { releaseSessions?.(); diff --git a/packages/tui/test/unit/tui-chat-controller.test.ts b/packages/tui/test/unit/tui-chat-controller.test.ts index a0102b16..e13994f9 100644 --- a/packages/tui/test/unit/tui-chat-controller.test.ts +++ b/packages/tui/test/unit/tui-chat-controller.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from 'vitest'; -import type { SendMessageReq } from '@mavis/local-runtime-v2/cli-service'; +import type { CliSendMessageReq, SendMessageReq } from '@mavis/local-runtime-v2/cli-service'; import { TuiFailure } from '../../src/failure.js'; import { TuiChatController as ProductionTuiChatController, @@ -20,6 +20,50 @@ class TuiChatController extends ProductionTuiChatController { } describe('TuiChatController', () => { + it.each(['display', 'legacy transport'])( + 'reconciles plugin mentions using %s without duplicate user cells', + async (echo) => { + const content = '[@Codex 插件](plugin://codex%40official) 这个插件可以干什么'; + const displayContent = '@Codex 插件 这个插件可以干什么'; + let turn = 0; + const runtime = { + createSession: vi.fn(async () => ({ sessionId: 'plugin-session' })), + sendMessage: vi.fn(async function* ( + request: SendMessageReq, + ): AsyncGenerator { + expect(request).toMatchObject({ content, displayContent }); + const message = { + id: `message-${request.turnId}`, + turnId: request.turnId, + role: 'user' as const, + content: echo === 'display' ? request.displayContent : request.content, + }; + yield { type: 'message', message }; + yield { type: 'message', message }; + yield { type: 'done', turnId: request.turnId }; + }), + abortSession: vi.fn(async () => true), + }; + const transcript = new TranscriptStore(); + const controller = new ProductionTuiChatController({ + runtime: runtime as never, + transcript, + workspaceDir: '/workspace', + createTurnId: () => `plugin-turn-${++turn}`, + }); + await controller.submit(content, { displayContent }); + expect(transcript.snapshot().filter((cell) => cell.kind === 'user')).toMatchObject([ + { content: displayContent, sourceMessageId: 'message-plugin-turn-1' }, + ]); + // An intentional repeat in another turn must remain a separate message. + await controller.submit(content, { displayContent }); + expect(transcript.snapshot().filter((cell) => cell.kind === 'user')).toHaveLength(2); + const rendered = new TranscriptView(transcript).render(100).join('\n'); + expect(rendered).not.toContain('plugin://'); + expect(rendered).not.toContain('\x1b[4m'); + }, + ); + it('hides plain and namespaced AskUser protocol tools from the transcript', () => { expect(isQuestionnaireTool('ask_user')).toBe(true); expect(isQuestionnaireTool('functions.AskUser')).toBe(true); diff --git a/packages/tui/test/unit/tui-composer.test.ts b/packages/tui/test/unit/tui-composer.test.ts index 5c6da276..eb7d980a 100644 --- a/packages/tui/test/unit/tui-composer.test.ts +++ b/packages/tui/test/unit/tui-composer.test.ts @@ -312,7 +312,7 @@ describe("TuiComposer", () => { ); const firstHeader = stripAnsi(composer.render(120)[0] ?? ""); - expect(firstHeader).toContain("Start · @ file · / autocomplete"); + expect(firstHeader).toContain("Start · @ file or Plugin · / autocomplete"); expect(firstHeader).toContain( "Tip: /goal keeps multi-step work focused on a finish line", ); @@ -329,7 +329,7 @@ describe("TuiComposer", () => { { now: () => 30_000, tips: ordinaryTip ? [ordinaryTip] : [] }, ); const nextHeader = stripAnsi(nextComposer.render(120)[0] ?? ""); - expect(nextHeader).toContain("Start · @ file · / autocomplete"); + expect(nextHeader).toContain("Start · @ file or Plugin · / autocomplete"); expect(nextHeader).toContain( "Tip: Ctrl+U resumes your recent Codex session", ); @@ -459,7 +459,7 @@ describe("TuiComposer", () => { ], [ { mode: "message", surface: "welcome" }, - "Start · @ file · / autocomplete", + "Start · @ file or Plugin · / autocomplete", ], ] satisfies Array<[TuiComposerState, string]>)( "explains the %s submission mode before the user presses Enter", diff --git a/packages/tui/test/unit/tui-plugin-mentions.test.ts b/packages/tui/test/unit/tui-plugin-mentions.test.ts new file mode 100644 index 00000000..6ddf09f0 --- /dev/null +++ b/packages/tui/test/unit/tui-plugin-mentions.test.ts @@ -0,0 +1,677 @@ +import { ConversationApplication } from '../../../local-runtime-v2/src/application/conversation/conversation-application.js'; +import { DirectSendDeliveryService } from '../../../local-runtime-v2/src/application/conversation/direct-send-delivery.js'; +import { TuiUserProjection } from '../../src/tui/controller/projection/turn-user-projection.js'; +import { TranscriptStore } from '../../src/tui/transcript/store.js'; +import { visibleWidth } from '../../src/tui/engine/public.js'; +import { TuiInputFlow } from '../../src/tui/controller/interaction/input-flow.js'; +import { TuiExternalEditorFlow } from '../../src/tui/controller/interaction/external-editor-flow.js'; +import { TuiSessionMutationFlow } from '../../src/tui/controller/product/session-mutation-flow.js'; +import { UserMessageTurnDeliveryService } from '../../../local-runtime-v2/src/application/conversation/user-message-turn-delivery.js'; +import { UserMessageCommitService } from '../../../local-runtime-v2/src/service/session-system/messages/user-message-commit-service.js'; +import { + normalizeDisplayMessage, + decodeDisplayMessage, +} from '../../../local-runtime-v2/src/service/session-system/messages/repo/codec.js'; +import { toSessionMessageView } from '../../../local-runtime-v2/src/application/session/content-application.js'; +import { normalizeTuiMessage } from '../../src/runtime/stream-events.js'; +import { TuiHistorySearchPanel } from '../../src/tui/features/history/search-panel.js'; +import { TuiQueueFlow } from '../../src/tui/controller/run/queue-flow.js'; +import { TuiRunProjection } from '../../src/tui/state/run-projection.js'; +import { TuiCommandFlow } from '../../src/tui/controller/product/command-flow.js'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { serializePluginMention, parsePluginMentions } from '@mavis/shared/plugin-mention'; +import { createTuiAutocomplete } from '../../src/tui/controller/run/active-run-flow.js'; +import { + Editor, + submittedEditorTransport, + submittedEditorContent, +} from '../../src/tui/widgets/editor/editor.js'; +import { TuiDraftRecovery } from '../../src/tui/features/composer/draft-recovery.js'; +import { createTuiSubmissionSnapshot } from '../../src/tui/features/composer/submission.js'; +import { stripTerminalSequences } from '../../src/tui/engine/public.js'; + +const identity = (value: string) => value; +const makePlugin = (name = 'notes', marketplace: 'local' | 'official' = 'local') => ({ + pluginId: `${name}@${marketplace}`, + name, + displayName: 'My Notes', + marketplace, + installed: true, + enabled: true, + capabilities: { appCount: 0, mcpServerCount: 0, skillCount: 1 }, +}); +const editors: Editor[] = []; +afterEach(() => { + for (const editor of editors.splice(0)) editor.dispose(); +}); +function createEditor() { + const editor = new Editor( + { terminal: { rows: 24 }, requestRender: vi.fn() }, + { + borderColor: identity, + selectList: { + selectedPrefix: identity, + selectedText: identity, + description: identity, + scrollInfo: identity, + noMatch: identity, + }, + }, + ); + editors.push(editor); + editor.focused = true; + editor.setAutocompleteProvider( + createTuiAutocomplete([], [], '/workspace', { + listInstalledPlugins: async () => [makePlugin()], + listWorkspaceFileTree: async () => [], + searchWorkspaceFiles: async () => [], + }), + ); + return editor; +} +async function selectPlugin(editor: Editor) { + editor.handleInput('@'); + await vi.waitFor(() => + expect(stripTerminalSequences(editor.render(80).join('\n'))).toContain('local · notes'), + ); + editor.handleInput('\t'); + expect(editor.getText()).toBe('@My Notes '); + expect(editor.captureDraft().pluginMentions).toEqual([ + { pluginId: 'notes@local', label: '@My Notes', start: 0, end: 9 }, + ]); +} + +describe('Plugin mentions from Composer to durable text', () => { + it('carries the display label through Runtime direct-send admission while preserving execution identity', async () => { + const submit = vi.fn(async () => ({ accepted: false, reason: 'invalid-input' })); + const directSend = new DirectSendDeliveryService({ + turns: { submit }, + stream: { reserve: () => ({ discardIfEmpty: vi.fn() }) }, + } as never); + const application = new ConversationApplication({ directSend } as never); + const content = '[@Codex 插件](plugin://codex%40official) hello'; + const displayContent = '@Codex 插件 hello'; + await application.sendMessage({}, { id: 's1', content, displayContent }); + expect(submit).toHaveBeenCalledWith(expect.objectContaining({ + input: expect.objectContaining({ text: content }), + delivery: expect.objectContaining({ displayContent }), + })); + }); + + it('renders legacy plugin history as plain labels without losing editable transport', () => { + const transcript = new TranscriptStore(); + const message = { + role: 'user' as const, + content: '[@Codex 插件](plugin://codex%40official) hello', + }; + new TuiUserProjection(transcript).hydrate(message, 'message-1', 'turn-1', 1); + expect(transcript.snapshot()[0]?.content).toBe('@Codex 插件 hello'); + expect(message.content).toContain('plugin://'); + }); + + it('groups plugins and files, bounds wide descriptions, and keeps all matching plugins selectable', async () => { + const provider = createTuiAutocomplete([], [], '/workspace', { + listInstalledPlugins: async () => + Array.from({ length: 10 }, (_, index) => ({ + ...makePlugin(`notes-${index}`), + description: '读取、生成、重排、填写和处理文件。'.repeat(20), + })), + listWorkspaceFileTree: async () => [ + { name: 'notes.md', path: 'notes.md', type: 'file' as const }, + ], + }); + const suggestions = await provider.getSuggestions(['@'], 0, 1, { + signal: new AbortController().signal, + }); + expect(suggestions?.items).toHaveLength(11); + expect(suggestions?.items[0]).toMatchObject({ groupLabel: ' Plugins' }); + expect(suggestions?.items[10]).toMatchObject({ groupLabel: ' Files' }); + for (const item of suggestions!.items.slice(0, 10)) { + expect(visibleWidth(item.description!)).toBeLessThanOrEqual(72); + expect(item.description).toContain('…'); + } + const editor = createEditor(); + editor.setAutocompleteProvider(provider); + editor.handleInput('@'); + await vi.waitFor(() => expect(editor.render(180).join('\n')).toContain('Plugins')); + expect(editor.render(180).join('\n')).not.toContain('local · notes'); + for (const width of [40, 80, 180]) { + for (const line of editor.render(width)) + expect(visibleWidth(line)).toBeLessThanOrEqual(width); + } + for (let index = 0; index < 10; index++) editor.handleInput('\x1b[B'); + expect(editor.render(80).join('\n')).toContain('Files'); + editor.handleInput('\t'); + expect(editor.getText()).toContain('notes.md'); + }); + + it('offers enabled plugins alongside files, refreshes state, and preserves file completion', async () => { + const listInstalledPlugins = vi.fn(async () => [ + makePlugin(), + { ...makePlugin('off'), enabled: false }, + ]); + const provider = createTuiAutocomplete([], [], '/workspace', { + listInstalledPlugins, + listWorkspaceFileTree: async () => [ + { name: 'notes.md', path: 'notes.md', type: 'file' as const }, + ], + }); + const request = { signal: new AbortController().signal }; + const result = await provider.getSuggestions(['@'], 0, 1, request); + expect(result?.items).toHaveLength(2); + expect(result?.items[0]).toMatchObject({ pluginId: 'notes@local' }); + expect(result?.items[1]?.value).toContain('notes.md'); + listInstalledPlugins.mockResolvedValue([{ ...makePlugin(), enabled: false }]); + expect((await provider.getSuggestions(['@'], 0, 1, request))?.items).toHaveLength(1); + expect(provider.applyCompletion(['see @ suffix'], 0, 5, result!.items[0]!, '@').lines).toEqual([ + 'see @My Notes suffix', + ]); + }); + + it('renders a label, submits stable identity, and preserves history through multiple recalls', async () => { + const editor = createEditor(); + await selectPlugin(editor); + editor.handleInput('整理笔记'); + const submitted = vi.fn(); + editor.onSubmit = submitted; + editor.handleInput('\r'); + const [content, draft] = submitted.mock.calls[0]!; + expect(content).toBe('@My Notes 整理笔记'); + const transport = submittedEditorTransport(draft); + expect(transport).toBe('[@My Notes](plugin://notes%40local) 整理笔记'); + editor.addToHistory(content); + editor.addToHistory('another prompt'); + editor.handleInput('\x1b[A'); + expect(editor.getText()).toBe('another prompt'); + editor.handleInput('\x1b[A'); + expect(editor.getText()).toBe(content); + expect(submittedEditorTransport(editor.captureDraft())).toBe(transport); + editor.handleInput('\x1b[B'); + expect(editor.getText()).toBe('another prompt'); + expect(editor.captureDraft().pluginMentions).toEqual([]); + }); + + it('deletes a selected mention atomically and restores its exact identity with undo', async () => { + const editor = createEditor(); + await selectPlugin(editor); + editor.handleInput('\x7f'); + expect(editor.getText()).toBe(''); + expect(editor.captureDraft().pluginMentions).toEqual([]); + editor.handleInput('\x1f'); + expect(editor.getText()).toBe('@My Notes '); + expect(submittedEditorTransport(editor.captureDraft())).toBe( + '[@My Notes](plugin://notes%40local)', + ); + }); + + it('restores the working draft identity after browsing history', async () => { + const editor = createEditor(); + await selectPlugin(editor); + editor.addToHistory('older'); + editor.handleInput('\x01'); + editor.handleInput('\x1b[A'); + expect(editor.getText()).toBe('older'); + editor.handleInput('\x1b[B'); + expect(submittedEditorTransport(editor.captureDraft())).toBe( + '[@My Notes](plugin://notes%40local)', + ); + }); + + it('keeps identities distinct when history labels are identical', () => { + const editor = createEditor(); + editor.addToHistory(serializePluginMention('notes@local', 'My Notes')); + editor.addToHistory(serializePluginMention('notes@official', 'My Notes')); + editor.handleInput('\x1b[A'); + expect(editor.captureDraft().pluginMentions?.[0]?.pluginId).toBe('notes@official'); + editor.handleInput('\x1b[A'); + expect(editor.captureDraft().pluginMentions?.[0]?.pluginId).toBe('notes@local'); + }); + + it('preserves mentions with attachments, merged retries and persisted drafts', async () => { + const editor = createEditor(); + await selectPlugin(editor); + editor.syncAttachmentPlaceholders([{ id: '/image.png', label: '[Image #1]' }]); + editor.handleInput('summarize'); + const snapshot = editor.captureDraft(); + expect(submittedEditorTransport(snapshot)).toBe( + '[@My Notes](plugin://notes%40local) summarize', + ); + const submission = createTuiSubmissionSnapshot({ + submissionId: 's1', + editor: snapshot, + content: submittedEditorContent(snapshot), + resources: { attachments: [] }, + }); + const directory = await mkdtemp(join(tmpdir(), 'plugin-mention-test-')); + try { + const store = new TuiDraftRecovery({ dataDir: directory, workspaceDir: '/workspace' }); + const plain = { + ...snapshot, + text: '@My Notes summarize', + cursor: 19, + attachmentPlaceholders: [], + }; + await store.flush({ + editor: plain, + attachments: [], + retrySubmissions: [ + { + retryId: 'retry:r1', + failedReason: 'offline', + snapshot: { ...submission, editor: plain }, + }, + ], + }); + const loaded = await new TuiDraftRecovery({ + dataDir: directory, + workspaceDir: '/workspace', + }).load(); + expect(loaded?.editor.pluginMentions).toEqual(snapshot.pluginMentions); + expect(loaded?.retrySubmissions?.[0]?.snapshot.editor.pluginMentions).toEqual( + snapshot.pluginMentions, + ); + editor.setText('new draft'); + expect(editor.restoreSubmittedDraft(loaded!.editor)).toBe(true); + expect(submittedEditorTransport(editor.captureDraft())).toBe( + '[@My Notes](plugin://notes%40local) summarize\nnew draft', + ); + } finally { + await rm(directory, { recursive: true, force: true }); + } + }); + + it('shows human-readable history search and restores the canonical identity', () => { + const entry = serializePluginMention('notes@official', 'My Notes'); + const editor = createEditor(); + const panel = new TuiHistorySearchPanel({ + entries: [entry], + initialQuery: 'My Notes', + onSelect: (text) => editor.setText(text), + onCancel: vi.fn(), + requestRender: vi.fn(), + }); + const rendered = stripTerminalSequences(panel.render(100).join('\n')); + expect(rendered).toContain('@My Notes'); + expect(rendered).not.toContain('plugin://'); + panel.handleInput('\r'); + expect(editor.captureDraft().pluginMentions?.[0]?.pluginId).toBe('notes@official'); + }); + + it('rejects corrupt restored ranges and malformed IDs without binding another plugin', () => { + const editor = createEditor(); + expect( + editor.restoreDraft({ + schemaVersion: 1, + text: '@Notes', + cursor: 6, + pastes: [], + pasteCounter: 0, + pluginMentions: [{ pluginId: 'notes@local', label: '@Wrong', start: 0, end: 6 }], + }), + ).toBe(false); + expect(parsePluginMentions('[@Notes](plugin://%ZZ)')).toEqual([]); + }); + it('submits bound text through the real command flow and restores the binding on failure', async () => { + const editor = createEditor(); + await selectPlugin(editor); + editor.handleInput('summarize'); + const draft = editor.captureDraft(); + const content = submittedEditorContent(draft); + const submit = vi.fn( + async ( + _text: string, + options: { + onSessionResolved?: (id: string) => void; + onRuntimeAccepted?: (id: string) => void; + }, + ) => { + options.onSessionResolved?.('session-a'); + options.onRuntimeAccepted?.('session-a'); + return 'succeeded' as const; + }, + ); + const flow = new TuiCommandFlow({ + workspaceDir: '/workspace', + controller: { + snapshot: () => ({ status: 'idle', sessions: [], session: { sessionId: 'session-a' } }), + submit, + refreshSessionMetadata: async () => undefined, + } as never, + activeRunFlow: {} as never, + featureFlow: { + skillCommands: () => [], + waitForWelcomeModelSelection: async () => undefined, + applyPendingModelSelection: async () => undefined, + } as never, + feedbackFlow: {} as never, + updateFlow: {} as never, + interactionFlow: { handleCommand: async () => false, hasPending: () => false } as never, + sessionFlow: {} as never, + queueFlow: {} as never, + composerDraft: { + hasContent: () => false, + capture: () => ({ attachments: [] }), + reserveSubmission: vi.fn(), + restoreSubmission: vi.fn(), + completeSubmission: async () => undefined, + } as never, + workspaceRoots: { additionalDirectories: () => [] } as never, + runProjection: new TuiRunProjection(), + editor, + surface: {} as never, + surfaceHost: { setChatFocus: vi.fn() } as never, + queueEnabled: true, + liveRunId: () => undefined, + runtimeStopping: () => false, + abortLiveTurn: async () => false, + leaveUi: async () => undefined, + whenReady: async () => undefined, + append: vi.fn(), + setHint: vi.fn(), + onChanged: vi.fn(), + }); + const seed = flow.captureSubmissionSeed(draft); + editor.setText(''); + await expect(flow.submit(content, seed)).resolves.toBe('consumed'); + expect(submit).toHaveBeenCalledWith( + '[@My Notes](plugin://notes%40local) summarize', + expect.objectContaining({ displayContent: content }), + ); + flow.restoreRecoverableSubmission({ + submissionId: 'recovered', + sessionId: 'session-a', + editor: draft, + content, + attachments: [], + createdAtMs: 1, + transportContent: + 'context\n\n[@My Notes](plugin://notes%40local) summarize', + }); + editor.restoreDraft(draft); + editor.handleInput(' tomorrow'); + const retrySeed = flow.captureSubmissionSeed(); + await flow.submit('@My Notes summarize tomorrow', retrySeed); + expect(submit).toHaveBeenLastCalledWith( + 'context\n\n[@My Notes](plugin://notes%40local) summarize tomorrow', + expect.objectContaining({ displayContent: '@My Notes summarize tomorrow' }), + ); + editor.setText(''); + await flow.restoreFailedSeed(content, seed); + expect(submittedEditorTransport(editor.captureDraft())).toBe( + '[@My Notes](plugin://notes%40local) summarize', + ); + }); + + it.each(['', 'synthetic context\n\n'])( + 'reconstructs queued bindings and context after reload: %s', + async (context) => { + let item = { + itemId: 'q1', + sessionId: 's1', + status: 'queued', + source: 'user', + content: `${context}[@My Notes](plugin://notes%40local) summarize`, + attachments: [], + }; + const projection = new TuiRunProjection(); + const updateQueuedMessageContent = vi.fn( + async (_session: string, _item: string, content: string) => { + item = { ...item, content }; + return item; + }, + ); + let removed = false; + const queue = new TuiQueueFlow({ + runtime: { + getQueueSnapshot: async () => ({ + items: removed ? [] : [item], + paused: true, + pendingCount: removed ? 0 : 1, + }), + deleteQueuedMessage: async () => { + removed = true; + return true; + }, + updateQueuedMessageContent, + } as never, + controller: { snapshot: () => ({ session: { sessionId: 's1' } }) } as never, + composerDraft: { restoreQueuedSubmission: vi.fn(), releaseQueueItem: vi.fn() } as never, + runProjection: projection, + transcript: { get: vi.fn(), remove: vi.fn(), upsert: vi.fn() } as never, + followUp: { setQueueSummary: vi.fn(), setItems: vi.fn() } as never, + surface: {} as never, + enabled: true, + setHint: vi.fn(), + onChanged: vi.fn(), + requestRender: vi.fn(), + }); + await queue.refresh('s1'); + expect(projection.snapshot().queuedItems[0]?.content).toBe('@My Notes summarize'); + await queue.updateContent('q1', '@My Notes summarize tomorrow'); + expect(updateQueuedMessageContent).toHaveBeenCalledWith( + 's1', + 'q1', + `${context}[@My Notes](plugin://notes%40local) summarize tomorrow`, + ); + const restored = await queue.restoreLatest(); + expect(restored?.content).toBe('@My Notes summarize tomorrow'); + expect(submittedEditorTransport(restored!.editor)).toBe( + '[@My Notes](plugin://notes%40local) summarize tomorrow', + ); + }, + ); +}); + +describe('Plugin mention review regressions', () => { + it('clears and restores the actual editor binding with Ctrl+C and Ctrl+-', () => { + const editor = createEditor(); + editor.setText('[@My Notes](plugin://notes%40local) summarize'); + const flow = new TuiInputFlow({ + editor, + tui: { requestRender: vi.fn() }, + interaction: { isActive: () => false }, + liveRunId: () => undefined, + hasWaitingMessage: () => false, + featureFlow: { isFeatureScreenActive: () => false }, + composerDraft: { + abortClipboardRead: () => false, + stashForClear: vi.fn(), + hasContent: () => false, + restoreClearedDraft: vi.fn(), + }, + setHint: vi.fn(), + onChanged: vi.fn(), + workspaceDir: '/workspace', + } as never); + flow.handle('\x03'); + expect(editor.getText()).toBe(''); + expect(editor.captureDraft().pluginMentions).toEqual([]); + flow.handle('\x1f'); + expect(submittedEditorTransport(editor.captureDraft())).toBe( + '[@My Notes](plugin://notes%40local) summarize', + ); + }); + + it.each([false, true])( + 'keeps external edits and undo bound, including expanded paste: %s', + async (withPaste) => { + const editor = createEditor(); + editor.setText('[@My Notes](plugin://notes%40local) summarize'); + if (withPaste) { + const draft = editor.captureDraft(); + const prefix = '[paste #1] '; + editor.restoreDraft({ + ...draft, + text: prefix + draft.text, + cursor: prefix.length + draft.text.length, + pastes: [{ id: 1, content: 'Expanded text before plugin' }], + pasteCounter: 1, + pluginMentions: draft.pluginMentions!.map((mention) => ({ + ...mention, + start: mention.start + prefix.length, + end: mention.end + prefix.length, + })), + }); + } + const before = editor.captureDraft(); + const transport = submittedEditorTransport(before); + let rename = false; + const flow = new TuiExternalEditorFlow({ + editor, + tui: { start: vi.fn(), stop: vi.fn(), requestRender: vi.fn() }, + workspaceDir: '/workspace', + configuredCommand: 'synthetic-editor', + editDraft: async ({ draft }) => + rename ? draft.replace('@My Notes', '@Other Notes') : draft + ' tomorrow', + isAppStopped: () => false, + append: vi.fn(), + setHint: vi.fn(), + onChanged: vi.fn(), + }); + await flow.open(); + expect(submittedEditorTransport(editor.captureDraft())).toBe(transport + ' tomorrow'); + editor.handleInput('\x1f'); + expect(editor.captureDraft()).toEqual(before); + rename = true; + await flow.open(); + expect(editor.captureDraft().pluginMentions).toEqual([]); + expect(submittedEditorTransport(editor.captureDraft())).toBeUndefined(); + }, + ); + + it('retains an unchanged plugin across multiple external edits without guessing duplicate identities', async () => { + const editor = createEditor(); + editor.setText('[@My Notes](plugin://notes%40local) summarize'); + const flow = new TuiExternalEditorFlow({ + editor, + tui: { start: vi.fn(), stop: vi.fn(), requestRender: vi.fn() }, + workspaceDir: '/workspace', + configuredCommand: 'synthetic-editor', + editDraft: async ({ draft }) => 'Please ' + draft + ' tomorrow', + isAppStopped: () => false, + append: vi.fn(), + setHint: vi.fn(), + onChanged: vi.fn(), + }); + await flow.open(); + expect(submittedEditorTransport(editor.captureDraft())).toBe( + 'Please [@My Notes](plugin://notes%40local) summarize tomorrow', + ); + editor.setText( + '[@My Notes](plugin://notes%40local) and [@My Notes](plugin://notes%40official)', + ); + editor.replaceTextUndoable('@My Notes and @My Notes tomorrow'); + expect(submittedEditorTransport(editor.captureDraft())).toBe( + '[@My Notes](plugin://notes%40local) and [@My Notes](plugin://notes%40official) tomorrow', + ); + editor.replaceTextUndoable('@My Notes tomorrow'); + expect(editor.captureDraft().pluginMentions).toEqual([]); + }); + + it.each(['ordinary', 'batch', 'steering'] as const)( + 'preserves %s display-message identity through storage, projection and /edit', + async (mode) => { + const canonical = + 'synthetic context\n\n[@My Notes](plugin://notes%40local) summarize'; + const display = '@My Notes summarize'; + let stored: ReturnType | undefined; + const commits = new UserMessageCommitService({ + makeMessageId: () => 'u1', + messages: { + commitUserMessage: async (input) => { + const row = normalizeDisplayMessage(input.message, { + turnId: input.turnId, + nowMs: () => 1, + }); + stored = decodeDisplayMessage({ + sessionId: input.sessionId, + messageId: row.msgId, + dataJson: row.dataJson, + source: null, + sourceContextJson: null, + } as never); + return { message: stored, created: true, firstUserMessageForSession: true }; + }, + }, + }); + const delivery = new UserMessageTurnDeliveryService({ + messages: commits, + stream: { write: vi.fn() }, + queryCollapse: { + resolveQueryKey: async () => 'q1', + start: async () => { + throw new Error('Synthetic unavailable sidecar'); + }, + }, + }); + const message = { content: canonical, attachments: [], displayContent: display }; + if (mode === 'steering') { + await delivery.consumeSteering({ + sessionId: 's1', + turnId: 't1', + message: { + producerId: 'cli', + idempotencyKey: 'fixture', + provenance: { source: 'cli' }, + message: { text: canonical, attachments: [] }, + delivery: { displayContent: display }, + }, + } as never); + } else { + await delivery.deliver({ + sessionId: 's1', + input: { text: canonical, attachments: [] }, + provenance: { source: 'cli' }, + displayContent: display, + requestedTurnId: 't1', + submit: async () => ({ accepted: true, turnId: 't1' }), + ...(mode === 'batch' + ? { + immediateSendBatch: { + members: [ + { message, messageKey: 'batch-1', createdAt: 1, provenance: { source: 'cli' } }, + ], + }, + } + : {}), + } as never); + } + expect(stored?.msg_content).toBe(display); + expect(stored?.editContent).toBe(canonical); + const reloaded = normalizeTuiMessage(toSessionMessageView(stored!)); + expect(reloaded.content).toBe(display); + expect(reloaded.editContent).toBe(canonical); + const editor = createEditor(); + const editSessionMessage = vi.fn(async () => ({})); + const flow = new TuiSessionMutationFlow({ + editor, + controller: { + snapshot: () => ({ session: { sessionId: 's1' } }), + getTerminalDurationId: () => undefined, + dismissTerminalDuration: vi.fn(), + }, + runtime: { + listSessionInputSummaries: async () => [{ userMessageId: 'u1', timestamp: 1 }], + listMessagePage: async () => ({ messages: [reloaded], hasMore: false }), + editSessionMessage, + }, + surfaceHost: { setChatFocus: vi.fn() }, + setHint: vi.fn(), + append: vi.fn(), + onChanged: vi.fn(), + hasLiveRun: () => false, + } as never); + flow.startEdit(); + await vi.waitFor(() => expect(flow.isEditing()).toBe(true)); + expect(editor.getText()).toBe(display); + editor.handleInput(' tomorrow'); + await flow.submitEdit(editor.getText(), [], editor.captureDraft()); + expect(editSessionMessage).toHaveBeenCalledWith( + expect.objectContaining({ content: canonical + ' tomorrow' }), + ); + }, + ); +}); diff --git a/packages/tui/test/unit/tui/theme/custom-themes.test.ts b/packages/tui/test/unit/tui/theme/custom-themes.test.ts index 23020c02..9b48b725 100644 --- a/packages/tui/test/unit/tui/theme/custom-themes.test.ts +++ b/packages/tui/test/unit/tui/theme/custom-themes.test.ts @@ -1,10 +1,12 @@ +import { watch as watchFs, writeFileSync } from 'node:fs'; import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; -import { afterEach, describe, expect, it } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import { customThemesDirectory, loadCustomThemes, + watchCustomThemes, } from '../../../../src/tui/theme/custom-themes.js'; import { TuiThemeRegistry } from '../../../../src/tui/theme/registry.js'; import type { TuiThemeDefinition } from '../../../../src/tui/theme/contracts.js'; @@ -14,9 +16,15 @@ import { MINIMAX_CODE_LIGHT_THEME, } from '../../../../src/tui/theme/palettes.js'; +vi.mock('node:fs', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, watch: vi.fn(actual.watch) }; +}); + const temporaryDirectories: string[] = []; afterEach(async () => { + vi.useRealTimers(); await Promise.all( temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true })), ); @@ -42,6 +50,55 @@ function onlyTheme(themes: readonly TuiThemeDefinition[]): TuiThemeDefinition { } describe('custom TUI theme files', () => { + it('reconciles edits missed during watcher startup and continues to reload later events', async () => { + const dataDir = await themesDataDir({ + 'mine.json': { name: 'mine', appearance: 'dark', colors: { brand: '#112233' } }, + }); + const file = join(customThemesDirectory(dataDir), 'mine.json'); + expect(onlyTheme(loadCustomThemes(dataDir).themes).dark.colors.brand).toBe('#112233'); + const close = vi.fn(); + vi.mocked(watchFs).mockReturnValueOnce({ on: vi.fn(), close } as never); + vi.useFakeTimers(); + const onChange = vi.fn(); + const stop = watchCustomThemes(dataDir, onChange); + try { + // Model macOS dropping the first edit before native watching is ready. + writeFileSync( + file, + JSON.stringify({ name: 'mine', appearance: 'dark', colors: { brand: '#AABBCC' } }), + ); + await vi.advanceTimersByTimeAsync(150); + expect(onChange).toHaveBeenCalledTimes(1); + expect(onlyTheme(onChange.mock.calls[0]![0].themes).dark.colors.brand).toBe('#AABBCC'); + + writeFileSync( + file, + JSON.stringify({ name: 'mine', appearance: 'dark', colors: { brand: '#DDEEFF' } }), + ); + const fire = vi.mocked(watchFs).mock.calls.at(-1)![2]!; + fire('change', 'mine.json'); + await vi.advanceTimersByTimeAsync(150); + expect(onChange).toHaveBeenCalledTimes(2); + expect(onlyTheme(onChange.mock.calls[1]![0].themes).dark.colors.brand).toBe('#DDEEFF'); + } finally { + stop(); + } + expect(close).toHaveBeenCalledOnce(); + }); + + it('cancels the startup reconciliation when disposed', async () => { + const dataDir = await themesDataDir({}); + const close = vi.fn(); + vi.mocked(watchFs).mockReturnValueOnce({ on: vi.fn(), close } as never); + vi.useFakeTimers(); + const onChange = vi.fn(); + const stop = watchCustomThemes(dataDir, onChange); + stop(); + await vi.advanceTimersByTimeAsync(1000); + expect(onChange).not.toHaveBeenCalled(); + expect(close).toHaveBeenCalledOnce(); + }); + it('returns no themes when the directory is absent', async () => { const dataDir = await mkdtemp(join(tmpdir(), 'mcode-tui-themes-empty-')); temporaryDirectories.push(dataDir); diff --git a/packages/tui/test/unit/tui/theme/runtime.test.ts b/packages/tui/test/unit/tui/theme/runtime.test.ts index d0da7544..ab9e80a1 100644 --- a/packages/tui/test/unit/tui/theme/runtime.test.ts +++ b/packages/tui/test/unit/tui/theme/runtime.test.ts @@ -568,6 +568,7 @@ describe('TuiThemeController theme selection', () => { const { tmpdir } = await import('node:os'); const { join } = await import('node:path'); const dataDir = await mkdtemp(join(tmpdir(), 'mcode-theme-reload-')); + let controller: TuiThemeController | undefined; try { const themesDir = join(dataDir, 'tui', 'themes'); await mkdir(themesDir, { recursive: true }); @@ -577,7 +578,7 @@ describe('TuiThemeController theme selection', () => { await write('#112233'); const ui = new ThemeUi(); - const controller = new TuiThemeController({ + controller = new TuiThemeController({ ui, colorLevel: 3, env: { COLORFGBG: '15;0' }, @@ -594,8 +595,8 @@ describe('TuiThemeController theme selection', () => { expect(controller.selectedThemeId()).toBe('mine'); expect(onChange).toHaveBeenCalled(); - controller.dispose(); } finally { + controller?.dispose(); await rm(dataDir, { recursive: true, force: true }); restoreTheme(); } @@ -606,6 +607,7 @@ describe('TuiThemeController theme selection', () => { const { tmpdir } = await import('node:os'); const { join } = await import('node:path'); const dataDir = await mkdtemp(join(tmpdir(), 'mcode-theme-noop-')); + let controller: TuiThemeController | undefined; try { const themesDir = join(dataDir, 'tui', 'themes'); await mkdir(themesDir, { recursive: true }); @@ -619,7 +621,7 @@ describe('TuiThemeController theme selection', () => { const ui = new ThemeUi(); const onThemesChanged = vi.fn(); - const controller = new TuiThemeController({ + controller = new TuiThemeController({ ui, colorLevel: 3, env: { COLORFGBG: '15;0' }, @@ -629,8 +631,11 @@ describe('TuiThemeController theme selection', () => { controller.setTheme('mine'); const onChange = vi.fn(); controller.onChange(onChange); - // The constructor load already fired once; only a watcher-driven reload - // may satisfy the wait below. + // Let the constructor load and startup reconciliation finish before the + // no-op edit, so this assertion still requires a native watcher event. + await vi.waitFor(() => expect(onThemesChanged.mock.calls.length).toBeGreaterThanOrEqual(2), { + timeout: 4000, + }); onThemesChanged.mockClear(); await writeFile(file, body); @@ -640,8 +645,8 @@ describe('TuiThemeController theme selection', () => { expect(tuiColors.brand).toBe('#112233'); expect(onChange).not.toHaveBeenCalled(); - controller.dispose(); } finally { + controller?.dispose(); await rm(dataDir, { recursive: true, force: true }); restoreTheme(); } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8014dab7..00246441 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -335,6 +335,9 @@ importers: packages/agent-modules/system-reminder: devDependencies: + '@mavis/shared': + specifier: workspace:^ + version: link:../../shared '@mavis/config': specifier: workspace:^ version: link:../../config diff --git a/release/public-source.json b/release/public-source.json index a07638a9..9748e819 100644 --- a/release/public-source.json +++ b/release/public-source.json @@ -1278,6 +1278,7 @@ "packages/local-runtime-v2/src/service/turn-system/agent-host/assembly/local-turn-execution-preparation.ts", "packages/local-runtime-v2/src/service/turn-system/agent-host/assembly/local-turn-input-preparation.ts", "packages/local-runtime-v2/src/service/turn-system/agent-host/assembly/local-turn-payload-transform.ts", + "packages/local-runtime-v2/src/service/turn-system/agent-host/assembly/local-turn-plugin-capabilities.test.ts", "packages/local-runtime-v2/src/service/turn-system/agent-host/assembly/local-turn-plugin-capabilities.ts", "packages/local-runtime-v2/src/service/turn-system/agent-host/assembly/local-turn-plugin-hooks.ts", "packages/local-runtime-v2/src/service/turn-system/agent-host/assembly/local-turn-tool-catalog-source.ts", @@ -2764,6 +2765,7 @@ "packages/shared/src/opencode-go-headers.ts", "packages/shared/src/openrouter-attribution.ts", "packages/shared/src/outbound-media.ts", + "packages/shared/src/plugin-mention.ts", "packages/shared/src/plugin-skill-name.ts", "packages/shared/src/product-build-identity.ts", "packages/shared/src/product-time.ts", @@ -2952,6 +2954,7 @@ "packages/tui/src/tui/commands/bash-input.ts", "packages/tui/src/tui/commands/catalog.ts", "packages/tui/src/tui/commands/input-intent.ts", + "packages/tui/src/tui/commands/plugin-autocomplete.ts", "packages/tui/src/tui/commands/side-session.ts", "packages/tui/src/tui/controller/chat-controller-failure.ts", "packages/tui/src/tui/controller/chat-controller-support.ts", @@ -3203,6 +3206,7 @@ "packages/tui/src/tui/widgets/autocomplete.ts", "packages/tui/src/tui/widgets/editor/editor.ts", "packages/tui/src/tui/widgets/editor/paste.ts", + "packages/tui/src/tui/widgets/editor/plugin-mentions.ts", "packages/tui/src/tui/widgets/input.ts", "packages/tui/src/tui/widgets/panel-frame.ts", "packages/tui/src/tui/widgets/select-list.ts", @@ -3272,6 +3276,7 @@ "packages/tui/test/unit/tui-keybindings.test.ts", "packages/tui/test/unit/tui-model-picker.test.ts", "packages/tui/test/unit/tui-plugin-manager.test.ts", + "packages/tui/test/unit/tui-plugin-mentions.test.ts", "packages/tui/test/unit/tui-provider-editor.test.ts", "packages/tui/test/unit/tui-provider-manager.test.ts", "packages/tui/test/unit/tui-provider-onboarding.test.ts", diff --git a/test/vitest-suites.json b/test/vitest-suites.json index 9ddb86ca..5bc27008 100644 --- a/test/vitest-suites.json +++ b/test/vitest-suites.json @@ -174,7 +174,9 @@ "packages/tui/test/unit/tui/features/settings/theme-picker.test.ts", "packages/tui/test/unit/tui/theme/custom-themes.test.ts", "packages/tui/test/unit/tui/theme/palettes.test.ts", - "packages/tui/test/unit/tui/theme/runtime.test.ts" + "packages/tui/test/unit/tui/theme/runtime.test.ts", + "packages/tui/test/unit/tui-plugin-mentions.test.ts", + "packages/local-runtime-v2/src/service/turn-system/agent-host/assembly/local-turn-plugin-capabilities.test.ts" ], "status-contract": [ "packages/tui/test/unit/tui-build-mode-contract.test.ts" diff --git a/tsconfig.standalone.json b/tsconfig.standalone.json index 1ad29d2b..df4c912a 100644 --- a/tsconfig.standalone.json +++ b/tsconfig.standalone.json @@ -328,6 +328,9 @@ "@mavis/shared/navigation-text": [ "./packages/shared/src/navigation-text.ts" ], + "@mavis/shared/plugin-mention": [ + "./packages/shared/src/plugin-mention.ts" + ], "@mavis/shared/product-build-identity": [ "./packages/shared/src/product-build-identity.ts" ], From a914a306c9aa84a71e8c49f03e1d539416287397 Mon Sep 17 00:00:00 2001 From: DanielWalnut <45447813+hetaoBackend@users.noreply.github.com> Date: Wed, 23 Sep 2026 21:21:52 +0800 Subject: [PATCH 07/27] chore(tui): restore source ESLint rules and verification gate (#341) --- .agents/skills/testing-workflow/SKILL.md | 1 + .eslintrc.cjs | 129 ++ CONTRIBUTING.md | 2 + package.json | 13 +- .../tui/src/application/assistant-content.ts | 4 +- .../src/observability/incident-reporter.ts | 4 +- .../src/tui/commands/plugin-autocomplete.ts | 2 +- .../unit/incident-reporter-privacy.test.ts | 2 +- .../tui/test/unit/tui-image-preview.test.ts | 3 +- .../tui/test/unit/tui-plugin-mentions.test.ts | 11 +- .../unit/tui-scrollbar-interaction.test.ts | 2 +- pnpm-lock.yaml | 1847 ++++++++++++++++- release/dependency-licenses.json | 1371 +++++++++++- release/public-source.json | 1 + scripts/verify.mjs | 1 + test/source-sync.test.mjs | 38 + 16 files changed, 3381 insertions(+), 50 deletions(-) create mode 100644 .eslintrc.cjs diff --git a/.agents/skills/testing-workflow/SKILL.md b/.agents/skills/testing-workflow/SKILL.md index b37177ab..e082ffd3 100644 --- a/.agents/skills/testing-workflow/SKILL.md +++ b/.agents/skills/testing-workflow/SKILL.md @@ -34,6 +34,7 @@ suites are outside this distribution's verification. | Sandbox on macOS | `pnpm test:sandbox` | | Source-sync, workflow and release tools | `pnpm test:release-tools` | | npm release archive installation | `MCODE_RELEASE_TAG=vX.Y.Z MCODE_RELEASE_ARCHIVE=/path/to/package.tar.gz pnpm verify --profile package` | +| TUI source and test lint | `pnpm lint:tui` | | Types and standalone build boundary | `pnpm typecheck`, `pnpm build`, `pnpm check:standalone` | | Published files and generated paths | `pnpm check:source`, `pnpm check:tsconfig` | diff --git a/.eslintrc.cjs b/.eslintrc.cjs new file mode 100644 index 00000000..a6759cd0 --- /dev/null +++ b/.eslintrc.cjs @@ -0,0 +1,129 @@ +// TUI rules adapted from the source repository, without browser/React rules. +// Keep Pi engine style exceptions aligned with the audited fork. +module.exports = { + root: true, + env: { + es2022: true, + node: true, + }, + parser: "@typescript-eslint/parser", + parserOptions: { + ecmaVersion: 2022, + sourceType: "module", + }, + plugins: ["@typescript-eslint", "import", "prettier"], + extends: [ + "airbnb-base", + "plugin:@typescript-eslint/recommended", + "plugin:prettier/recommended", + ], + ignorePatterns: [ + "dist/", + "node_modules/", + "third_party/", + "packages/tui/test/pi-084-upstream/", + ], + rules: { + "prettier/prettier": [ + "warn", + { + trailingComma: "all", + tabWidth: 2, + semi: true, + singleQuote: true, + useTabs: false, + bracketSpacing: true, + arrowParens: "always", + jsxSingleQuote: false, + printWidth: 100, + endOfLine: "auto", + embeddedLanguageFormatting: "auto", + proseWrap: "always", + }, + ], + "import/extensions": "off", + "import/prefer-default-export": "off", + "import/no-extraneous-dependencies": "off", + "import/no-unresolved": "off", + "import/order": "off", + "no-shadow": "off", + "@typescript-eslint/no-shadow": "error", + "no-use-before-define": "off", + "@typescript-eslint/no-use-before-define": "off", + "no-unused-vars": "off", + "@typescript-eslint/no-unused-vars": [ + "warn", + { + argsIgnorePattern: "^_", + }, + ], + "consistent-return": "off", + "no-restricted-syntax": "off", + "class-methods-use-this": "off", + "no-underscore-dangle": "off", + "no-continue": "off", + "no-await-in-loop": "off", + "no-void": "off", + "no-nested-ternary": "off", + "no-plusplus": "off", + "no-bitwise": "off", + "no-cond-assign": "off", + "no-promise-executor-return": "off", + "no-restricted-globals": "off", + "no-duplicate-imports": "off", + "no-multi-assign": "off", + "default-case": "off", + "max-classes-per-file": "off", + radix: "off", + "no-param-reassign": [ + "error", + { + props: false, + }, + ], + "no-control-regex": "off", + "@typescript-eslint/naming-convention": "off", + "@typescript-eslint/no-explicit-any": "warn", + "@typescript-eslint/ban-ts-comment": "off", + "@typescript-eslint/no-non-null-assertion": "warn", + "@typescript-eslint/no-loop-func": "warn", + camelcase: "off", + "lines-between-class-members": "off", + "no-unused-expressions": "off", + "dot-notation": "off", + "no-useless-constructor": "off", + "no-empty-function": "off", + "no-return-await": "off", + "no-throw-literal": "off", + "default-param-last": "off", + "no-loop-func": "off", + eqeqeq: "error", + "no-var": "error", + "prefer-const": "error", + "no-console": [ + "warn", + { + allow: ["warn", "error"], + }, + ], + "no-alert": "warn", + "no-empty": "warn", + "no-unsafe-finally": "warn", + "no-constant-condition": "warn", + "prefer-destructuring": "off", + }, + overrides: [ + { + files: ["packages/tui/src/tui/engine/**/*.ts"], + excludedFiles: ["packages/tui/src/tui/engine/public.ts"], + rules: { + "@typescript-eslint/no-shadow": "off", + "no-else-return": "off", + "no-lonely-if": "off", + "no-param-reassign": "off", + "one-var": "off", + "prefer-regex-literals": "off", + }, + }, + ], +}; diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index cddfe258..0e2cec3a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -41,6 +41,8 @@ pnpm verify `pnpm verify` runs the complete gate list in the same order as GitHub CI. Normal PR and main-branch checks use Node.js 24 on Linux and macOS. The Linux job runs the full profile; the macOS job uses `pnpm verify --profile platform`, which omits only the duplicate TypeScript compiler check. Windows runs the focused `pnpm verify --profile windows` contract on PRs; the profile is Windows-only and fails closed elsewhere. It checks source inventory, release tooling, build boundaries, artifacts, and Windows-specific tests without running the full capability suite. Gates that depend on platform behaviour are selected by platform rather than skipped silently; run `pnpm verify --list`, `pnpm verify --profile platform --list`, or `pnpm verify --profile windows --list` to inspect each plan. Individual gates remain available as their own scripts, such as `pnpm typecheck` or `pnpm test:byok`, while you iterate. +`pnpm lint` (or `pnpm lint:tui`) checks TUI source and tests with the source repository's Airbnb, TypeScript, import and Prettier rules. Errors fail verification; formatting, unused variables and explicit `any` remain warnings, matching the source policy. The scripts use `--quiet` to keep existing warning debt out of CI logs; omit it when invoking ESLint directly to inspect warnings. The audited Pi engine retains its narrow style exceptions; its public facade uses the normal rules. Vendored Pi test copies are excluded. Full, platform, archive and Windows verification include this gate; docs and package-installation profiles do not. + CI writes per-gate timing and exit metadata to the Job Summary and a seven-day `verification--node--` artifact. For a local report, set `MCODE_VERIFY_REPORT_DIR` to a directory outside the repository. Reports distinguish `PASS`, `FAIL`, intentional `SKIP`, and `NOT_RUN` after a failure. JSON is checkpointed before and after each gate; a cancelled run may leave `RUNNING`, which is not a pass. If installation fails before verification starts, no verification report is available. Reports do not collect command output, environment variables, or runtime data; read the corresponding gate's job log for failure details, including the existing bounded BYOK timeout diagnostics. CI jobs have a 15-minute verification limit and a 10-minute release-audit limit. Existing README files, `CONTRIBUTING.md`, `.github/PULL_REQUEST_TEMPLATE.md`, Markdown under `docs/`, and media directly under `docs/assets/` use the `docs` profile when they are the only changed paths. That profile checks the source inventory and generated paths, exports the committed source, and tests release tooling. History and source-snapshot secret scans still run; platform builds and distribution scans are skipped. Mixed changes, unknown paths, missing comparisons, and any `release/` inventory change get full CI. Documentation-only changes skip the platform matrix entirely. The `verification` aggregate check always runs and rejects failed, cancelled, or unexpectedly skipped jobs. Use it together with `source-history-artifact` as required checks when configuring branch protection; this repository's automation does not change administrative settings. diff --git a/package.json b/package.json index 0accb098..017e59c6 100644 --- a/package.json +++ b/package.json @@ -27,7 +27,9 @@ "test:capabilities": "node scripts/run-vitest-suite.mjs capability", "test:windows": "node scripts/run-vitest-suite.mjs windows", "test:artifact": "node --test test/public-artifact.test.mjs", - "test:release-tools": "node --test test/source-sync.test.mjs" + "test:release-tools": "node --test test/source-sync.test.mjs", + "lint": "pnpm run lint:tui", + "lint:tui": "eslint packages/tui/src packages/tui/test --ext .ts,.mjs --quiet" }, "devDependencies": { "@types/node": "^20.19.0", @@ -40,7 +42,14 @@ "vitest": "4.1.11", "yaml": "2.9.0", "tar": "7.5.22", - "vite": "7.3.6" + "vite": "7.3.6", + "eslint": "8.57.1", + "@typescript-eslint/eslint-plugin": "7.18.0", + "@typescript-eslint/parser": "7.18.0", + "eslint-config-airbnb-base": "15.0.0", + "eslint-config-prettier": "10.1.8", + "eslint-plugin-import": "2.32.0", + "eslint-plugin-prettier": "5.5.5" }, "pnpm": { "onlyBuiltDependencies": [ diff --git a/packages/tui/src/application/assistant-content.ts b/packages/tui/src/application/assistant-content.ts index c868b702..fa79e124 100644 --- a/packages/tui/src/application/assistant-content.ts +++ b/packages/tui/src/application/assistant-content.ts @@ -25,9 +25,9 @@ export function simplifyAssistantContentForTerminal(content: string): string { return [presentation.text, formatAssetGroup(presentation.assets)].filter(Boolean).join('\n\n'); } -export function projectAssistantContentForTerminal(content: string): TerminalAssistantContent { +export function projectAssistantContentForTerminal(rawContent: string): TerminalAssistantContent { // Model content is data. Only the renderer may introduce terminal controls. - content = sanitizeTerminalText(content); + const content = sanitizeTerminalText(rawContent); if (!content || !RICH_CONTENT_RE.test(content)) return { text: content, assets: [] }; const segments = splitMarkdownProtectedSegments(content); diff --git a/packages/tui/src/observability/incident-reporter.ts b/packages/tui/src/observability/incident-reporter.ts index dd0ff95b..8d46ced9 100644 --- a/packages/tui/src/observability/incident-reporter.ts +++ b/packages/tui/src/observability/incident-reporter.ts @@ -661,8 +661,8 @@ function minimizeEventLog(value: unknown, eventType: string): string { // An already minimized unknown thrown value stays unknown after disk replay. const breadcrumbs = (Array.isArray(log.breadcrumbs) ? log.breadcrumbs : []) .slice(-MAX_BREADCRUMBS) - .flatMap((value) => { - const crumb = record(value); + .flatMap((breadcrumb) => { + const crumb = record(breadcrumb); const name = allowedString(crumb.name, BREADCRUMB_NAMES); const phase = allowedString(record(crumb.details).phase, PHASES); return name && timestamp(crumb.occurredAtMs) diff --git a/packages/tui/src/tui/commands/plugin-autocomplete.ts b/packages/tui/src/tui/commands/plugin-autocomplete.ts index abe63065..6fe8d5fd 100644 --- a/packages/tui/src/tui/commands/plugin-autocomplete.ts +++ b/packages/tui/src/tui/commands/plugin-autocomplete.ts @@ -57,7 +57,7 @@ export class TuiPluginAutocomplete implements AutocompleteProvider { .map((plugin) => { const name = sanitizeTerminalText(plugin.displayName) - .replace(/[\[\]\\]/gu, ' ') + .replace(/[[\]\\]/gu, ' ') .replace(/\s+/gu, ' ') .trim() .slice(0, 256) || plugin.name; diff --git a/packages/tui/test/unit/incident-reporter-privacy.test.ts b/packages/tui/test/unit/incident-reporter-privacy.test.ts index ebe9cc6b..2e2371a3 100644 --- a/packages/tui/test/unit/incident-reporter-privacy.test.ts +++ b/packages/tui/test/unit/incident-reporter-privacy.test.ts @@ -307,7 +307,7 @@ describe('TUI automatic incident HTTP privacy boundary', () => { reporter.capture(input(new Error(privateText))); await reporter.drain(); expect(requests).toEqual([]); - const name = readdirSync(directory).find((name) => name.startsWith('pending-'))!; + const name = readdirSync(directory).find((filename) => filename.startsWith('pending-'))!; const path = join(directory, name); const stored = JSON.parse(readFileSync(path, 'utf8')); const log = JSON.parse(stored.eventLog); diff --git a/packages/tui/test/unit/tui-image-preview.test.ts b/packages/tui/test/unit/tui-image-preview.test.ts index e6734cf5..a18a093e 100644 --- a/packages/tui/test/unit/tui-image-preview.test.ts +++ b/packages/tui/test/unit/tui-image-preview.test.ts @@ -4,6 +4,7 @@ import { fileURLToPath, pathToFileURL } from 'node:url'; // Test-only packaging infrastructure belongs to the repository root, not a runtime package. // eslint-disable-next-line import/no-relative-packages import { packageExportEntries } from '../../../../scripts/lib/package-exports.mjs'; +// eslint-disable-next-line import/no-relative-packages -- read the source-only bundle scope import { readExtraction } from '../../../../scripts/lib/release-metadata.mjs'; // eslint-disable-next-line import/no-relative-packages -- reuse the real bundle module-location contract import { createTuiBundleModuleLocationConfig } from '../../../../scripts/lib/tui-npm-bundle-profile.mjs'; @@ -196,7 +197,7 @@ describe('preview file loader', () => { name: 'preview-test-pi-source', setup(builder) { builder.onResolve({ filter: /^@earendil-works\/pi-/ }, ({ path }) => { - const entry = entries.find((entry) => entry.specifier === path); + const entry = entries.find((candidate) => candidate.specifier === path); if (!entry) return undefined; return { path: join(root, entry.file), diff --git a/packages/tui/test/unit/tui-plugin-mentions.test.ts b/packages/tui/test/unit/tui-plugin-mentions.test.ts index 6ddf09f0..96004de9 100644 --- a/packages/tui/test/unit/tui-plugin-mentions.test.ts +++ b/packages/tui/test/unit/tui-plugin-mentions.test.ts @@ -2,7 +2,7 @@ import { ConversationApplication } from '../../../local-runtime-v2/src/applicati import { DirectSendDeliveryService } from '../../../local-runtime-v2/src/application/conversation/direct-send-delivery.js'; import { TuiUserProjection } from '../../src/tui/controller/projection/turn-user-projection.js'; import { TranscriptStore } from '../../src/tui/transcript/store.js'; -import { visibleWidth } from '../../src/tui/engine/public.js'; +import { visibleWidth, stripTerminalSequences } from '../../src/tui/engine/public.js'; import { TuiInputFlow } from '../../src/tui/controller/interaction/input-flow.js'; import { TuiExternalEditorFlow } from '../../src/tui/controller/interaction/external-editor-flow.js'; import { TuiSessionMutationFlow } from '../../src/tui/controller/product/session-mutation-flow.js'; @@ -31,7 +31,6 @@ import { } from '../../src/tui/widgets/editor/editor.js'; import { TuiDraftRecovery } from '../../src/tui/features/composer/draft-recovery.js'; import { createTuiSubmissionSnapshot } from '../../src/tui/features/composer/submission.js'; -import { stripTerminalSequences } from '../../src/tui/engine/public.js'; const identity = (value: string) => value; const makePlugin = (name = 'notes', marketplace: 'local' | 'official' = 'local') => ({ @@ -525,14 +524,14 @@ describe('Plugin mention review regressions', () => { workspaceDir: '/workspace', configuredCommand: 'synthetic-editor', editDraft: async ({ draft }) => - rename ? draft.replace('@My Notes', '@Other Notes') : draft + ' tomorrow', + rename ? draft.replace('@My Notes', '@Other Notes') : `${draft} tomorrow`, isAppStopped: () => false, append: vi.fn(), setHint: vi.fn(), onChanged: vi.fn(), }); await flow.open(); - expect(submittedEditorTransport(editor.captureDraft())).toBe(transport + ' tomorrow'); + expect(submittedEditorTransport(editor.captureDraft())).toBe(`${transport} tomorrow`); editor.handleInput('\x1f'); expect(editor.captureDraft()).toEqual(before); rename = true; @@ -550,7 +549,7 @@ describe('Plugin mention review regressions', () => { tui: { start: vi.fn(), stop: vi.fn(), requestRender: vi.fn() }, workspaceDir: '/workspace', configuredCommand: 'synthetic-editor', - editDraft: async ({ draft }) => 'Please ' + draft + ' tomorrow', + editDraft: async ({ draft }) => `Please ${draft} tomorrow`, isAppStopped: () => false, append: vi.fn(), setHint: vi.fn(), @@ -670,7 +669,7 @@ describe('Plugin mention review regressions', () => { editor.handleInput(' tomorrow'); await flow.submitEdit(editor.getText(), [], editor.captureDraft()); expect(editSessionMessage).toHaveBeenCalledWith( - expect.objectContaining({ content: canonical + ' tomorrow' }), + expect.objectContaining({ content: `${canonical} tomorrow` }), ); }, ); diff --git a/packages/tui/test/unit/tui-scrollbar-interaction.test.ts b/packages/tui/test/unit/tui-scrollbar-interaction.test.ts index 09342865..06c8394f 100644 --- a/packages/tui/test/unit/tui-scrollbar-interaction.test.ts +++ b/packages/tui/test/unit/tui-scrollbar-interaction.test.ts @@ -187,7 +187,7 @@ describe("Scrollbar interaction boundaries", () => { render: (width: number) => Array.from( { length: CONTENT_LINES }, - () => "x".repeat(width - 2) + "YZ", + () => `${"x".repeat(width - 2)}YZ`, ), invalidate() {}, }; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 00246441..549de119 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -23,12 +23,33 @@ importers: '@types/node': specifier: ^20.19.0 version: 20.19.43 + '@typescript-eslint/eslint-plugin': + specifier: 7.18.0 + version: 7.18.0(@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1)(typescript@5.9.3) + '@typescript-eslint/parser': + specifier: 7.18.0 + version: 7.18.0(eslint@8.57.1)(typescript@5.9.3) '@typescript/native-preview': specifier: 7.0.0-dev.20260707.2 version: 7.0.0-dev.20260707.2 esbuild: specifier: 0.28.2 version: 0.28.2 + eslint: + specifier: 8.57.1 + version: 8.57.1 + eslint-config-airbnb-base: + specifier: 15.0.0 + version: 15.0.0(eslint-plugin-import@2.32.0(@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1) + eslint-config-prettier: + specifier: 10.1.8 + version: 10.1.8(eslint@8.57.1) + eslint-plugin-import: + specifier: 2.32.0 + version: 2.32.0(@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1) + eslint-plugin-prettier: + specifier: 5.5.5 + version: 5.5.5(eslint-config-prettier@10.1.8(eslint@8.57.1))(eslint@8.57.1)(prettier@3.9.6) prettier: specifier: 3.9.6 version: 3.9.6 @@ -335,12 +356,12 @@ importers: packages/agent-modules/system-reminder: devDependencies: - '@mavis/shared': - specifier: workspace:^ - version: link:../../shared '@mavis/config': specifier: workspace:^ version: link:../../config + '@mavis/shared': + specifier: workspace:^ + version: link:../../shared '@types/node': specifier: ^20 version: 20.19.43 @@ -1381,6 +1402,24 @@ packages: cpu: [x64] os: [win32] + '@eslint-community/eslint-utils@4.10.1': + resolution: {integrity: sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 + + '@eslint-community/regexpp@4.12.2': + resolution: {integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==} + engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} + + '@eslint/eslintrc@2.1.4': + resolution: {integrity: sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + '@eslint/js@8.57.1': + resolution: {integrity: sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + '@google/genai@1.52.0': resolution: {integrity: sha512-gwSvbpiN/17O9TbsqSsE/OzZcpv5Fo4RQjdngGgogtuB9RsyJ8ZHhX5KjHj1bp5N9snN2eK8LDGXSaWW2hof8Q==} engines: {node: '>=20.0.0'} @@ -1396,6 +1435,19 @@ packages: peerDependencies: hono: 4.13.5 + '@humanwhocodes/config-array@0.13.0': + resolution: {integrity: sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw==} + engines: {node: '>=10.10.0'} + deprecated: Use @eslint/config-array instead + + '@humanwhocodes/module-importer@1.0.1': + resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==} + engines: {node: '>=12.22'} + + '@humanwhocodes/object-schema@2.0.3': + resolution: {integrity: sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==} + deprecated: Use @eslint/object-schema instead + '@isaacs/fs-minipass@4.0.1': resolution: {integrity: sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==} engines: {node: '>=18.0.0'} @@ -1751,6 +1803,10 @@ packages: '@pinojs/redact@0.4.0': resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==} + '@pkgr/core@0.3.6': + resolution: {integrity: sha512-SEeaJLb3qBNF/OaXnaR1NmmBbFYk1zC0ZH/52fATcRPLFg/p791YrcyFFy44Bo9sLaGuSuLp5Q6axbb/O+v/RA==} + engines: {node: ^14.18.0 || >=16.0.0} + '@pondwader/socks5-server@1.0.10': resolution: {integrity: sha512-bQY06wzzR8D2+vVCUoBsr5QS2U6UgPUQRmErNwtsuI6vLcyRKkafjkr3KxbtGFf9aBBIV2mcvlsKD1UYaIV+sg==} @@ -1906,6 +1962,9 @@ packages: cpu: [x64] os: [win32] + '@rtsao/scc@1.1.0': + resolution: {integrity: sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==} + '@silvia-odwyer/photon-node@0.3.4': resolution: {integrity: sha512-bnly4BKB3KDTFxrUIcgCLbaeVVS8lrAkri1pEzskpmxu9MdfGQTy8b8EgcD83ywD3RPMsIulY8xJH5Awa+t9fA==} @@ -1979,6 +2038,9 @@ packages: '@types/js-yaml@4.0.9': resolution: {integrity: sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==} + '@types/json5@0.0.29': + resolution: {integrity: sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==} + '@types/lazystream@1.0.0': resolution: {integrity: sha512-FMcHyqpFP/OiKgYXBgMHKpiVi321PoKF2s1mSnvCLiDpJlNYC5cdcB78bwObjga2YBOeIEJ7DKx3blwZLTzQlw==} @@ -2015,6 +2077,64 @@ packages: '@types/ws@8.18.1': resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} + '@typescript-eslint/eslint-plugin@7.18.0': + resolution: {integrity: sha512-94EQTWZ40mzBc42ATNIBimBEDltSJ9RQHCC8vc/PDbxi4k8dVwUAv4o98dk50M1zB+JGFxp43FP7f8+FP8R6Sw==} + engines: {node: ^18.18.0 || >=20.0.0} + peerDependencies: + '@typescript-eslint/parser': ^7.0.0 + eslint: ^8.56.0 + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + + '@typescript-eslint/parser@7.18.0': + resolution: {integrity: sha512-4Z+L8I2OqhZV8qA132M4wNL30ypZGYOQVBfMgxDH/K5UX0PNqTu1c6za9ST5r9+tavvHiTWmBnKzpCJ/GlVFtg==} + engines: {node: ^18.18.0 || >=20.0.0} + peerDependencies: + eslint: ^8.56.0 + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + + '@typescript-eslint/scope-manager@7.18.0': + resolution: {integrity: sha512-jjhdIE/FPF2B7Z1uzc6i3oWKbGcHb87Qw7AWj6jmEqNOfDFbJWtjt/XfwCpvNkpGWlcJaog5vTR+VV8+w9JflA==} + engines: {node: ^18.18.0 || >=20.0.0} + + '@typescript-eslint/type-utils@7.18.0': + resolution: {integrity: sha512-XL0FJXuCLaDuX2sYqZUUSOJ2sG5/i1AAze+axqmLnSkNEVMVYLF+cbwlB2w8D1tinFuSikHmFta+P+HOofrLeA==} + engines: {node: ^18.18.0 || >=20.0.0} + peerDependencies: + eslint: ^8.56.0 + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + + '@typescript-eslint/types@7.18.0': + resolution: {integrity: sha512-iZqi+Ds1y4EDYUtlOOC+aUmxnE9xS/yCigkjA7XpTKV6nCBd3Hp/PRGGmdwnfkV2ThMyYldP1wRpm/id99spTQ==} + engines: {node: ^18.18.0 || >=20.0.0} + + '@typescript-eslint/typescript-estree@7.18.0': + resolution: {integrity: sha512-aP1v/BSPnnyhMHts8cf1qQ6Q1IFwwRvAQGRvBFkWlo3/lH29OXA3Pts+c10nxRxIBrDnoMqzhgdwVe5f2D6OzA==} + engines: {node: ^18.18.0 || >=20.0.0} + peerDependencies: + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + + '@typescript-eslint/utils@7.18.0': + resolution: {integrity: sha512-kK0/rNa2j74XuHVcoCZxdFBMF+aq/vH83CXAOHieC+2Gis4mF8jJXT5eAfyD3K0sAxtPuwxaIOIOvhwzVDt/kw==} + engines: {node: ^18.18.0 || >=20.0.0} + peerDependencies: + eslint: ^8.56.0 + + '@typescript-eslint/visitor-keys@7.18.0': + resolution: {integrity: sha512-cDF0/Gf81QpY3xYyJKDV14Zwdmid5+uuENhjH2EqFaF0ni+yAyq/LzMaIJdhNJXZI7uLzwIlA+V7oWoyn6Curg==} + engines: {node: ^18.18.0 || >=20.0.0} + '@typescript/native-preview-darwin-arm64@7.0.0-dev.20260707.2': resolution: {integrity: sha512-wny2pgKjGbiZtnOIHVa3tXC1UfDqxNEFzyPGmiqybedG8hipG2Nfp0l5UxbaKCjkLacUpH/W5bP2hBOMVhCOzg==} engines: {node: '>=16.20.0'} @@ -2062,6 +2182,9 @@ packages: engines: {node: '>=16.20.0'} hasBin: true + '@ungap/structured-clone@1.4.0': + resolution: {integrity: sha512-1mEZtMKPM09vDmQt5y7YvmN2+DFTP7Tg0EWXdic8/C6VRnpb33e4ghisCIE3WZjsE2N8mf+QV1Zqh7ZFYLWInQ==} + '@vitest/coverage-v8@4.1.11': resolution: {integrity: sha512-8MVGEFnJIcdGjcbfKmeq8z0pZHH0JlVtoVZH9Q/qwUp6wyFnEJUBMrw9DCaj+ra3vShGmhavjalMIhPNxZAUcw==} peerDependencies: @@ -2211,6 +2334,9 @@ packages: ajv: optional: true + ajv@6.15.0: + resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==} + ajv@8.18.0: resolution: {integrity: sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==} @@ -2228,6 +2354,34 @@ packages: argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + array-buffer-byte-length@1.0.2: + resolution: {integrity: sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==} + engines: {node: '>= 0.4'} + + array-includes@3.2.0: + resolution: {integrity: sha512-VXY5eFRarnXcYxwBjJzPmEhH55+rmP79/+ueDhi0F+TuqfHCItagIHqxeUZrmgrOPa31QTh9H85DjX3FfJ0FTg==} + engines: {node: '>= 0.4'} + + array-union@2.1.0: + resolution: {integrity: sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==} + engines: {node: '>=8'} + + array.prototype.findlastindex@1.2.6: + resolution: {integrity: sha512-F/TKATkzseUExPlfvmwQKGITM3DGTK+vkAsCZoDc5daVygbJBnjEUCbgkAvVFsgfXfX4YIqZ/27G3k3tdXrTxQ==} + engines: {node: '>= 0.4'} + + array.prototype.flat@1.3.3: + resolution: {integrity: sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg==} + engines: {node: '>= 0.4'} + + array.prototype.flatmap@1.3.3: + resolution: {integrity: sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg==} + engines: {node: '>= 0.4'} + + arraybuffer.prototype.slice@1.0.4: + resolution: {integrity: sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==} + engines: {node: '>= 0.4'} + assertion-error@2.0.1: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} @@ -2235,6 +2389,10 @@ packages: ast-v8-to-istanbul@1.0.6: resolution: {integrity: sha512-fvpl29helSO2w/z7utIbrkNXILdrLwDwAMH2I/zPKlGf5244+gf+B4cyS1sANcrPY2h+hWCGSgC8N61s/+AF9A==} + async-function@1.0.0: + resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==} + engines: {node: '>= 0.4'} + asynckit@0.4.0: resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} @@ -2242,9 +2400,16 @@ packages: resolution: {integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==} engines: {node: '>=8.0.0'} + available-typed-arrays@1.0.7: + resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==} + engines: {node: '>= 0.4'} + axios@1.20.0: resolution: {integrity: sha512-r8aOh8j9cGKpgQAqpzrUHnSIc6a59Y3Xf/cv8sy1DrHCkZHzQGEuoq1tARk6qSyDdtQGSDgpb9kFlruzPvrgwg==} + balanced-match@1.0.2: + resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + balanced-match@4.0.4: resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} engines: {node: 18 || 20 || >=22} @@ -2275,6 +2440,12 @@ packages: bowser@2.14.1: resolution: {integrity: sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==} + brace-expansion@1.1.21: + resolution: {integrity: sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==} + + brace-expansion@2.1.7: + resolution: {integrity: sha512-uZbew1NqdmPDTMJ8ah1y+b+9QEJrfkXFk3RcTQw3X0jW/xRUvFKsg1CfQdSYGdTbXZWExtU3J3ccxtnfw1Fi0g==} + brace-expansion@5.0.9: resolution: {integrity: sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==} engines: {node: 20 || >=22} @@ -2300,10 +2471,18 @@ packages: resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} engines: {node: '>= 0.4'} + call-bind@1.0.9: + resolution: {integrity: sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==} + engines: {node: '>= 0.4'} + call-bound@1.0.4: resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} engines: {node: '>= 0.4'} + callsites@3.1.0: + resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} + engines: {node: '>=6'} + canvas@3.2.3: resolution: {integrity: sha512-PzE5nJZPz72YUAfo8oTp0u3fqqY7IzlTubneAihqDYAUcBk7ryeCmBbdJBEdaH0bptSOe2VT2Zwcb3UaFyaSWw==} engines: {node: ^18.12.0 || >= 20.9.0} @@ -2357,6 +2536,12 @@ packages: resolution: {integrity: sha512-z67u4ZhzCL/Tydu1lJARtEZYWbWaN7oYLHbsuzocr6y4N6WZAagG3RQ4FW61V1/0+jImpj293XfrcYnd1qxtPg==} engines: {node: '>=22.12.0'} + concat-map@0.0.1: + resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + + confusing-browser-globals@1.0.11: + resolution: {integrity: sha512-JsPKdmh8ZkmnHxDk55FZ1TqVLvEQTvoByJZRN9jzI0UjxK/QgAmsphz7PGtqgPieQZ/CQcHWXCR7ATDNhGe+YA==} + content-disposition@1.1.0: resolution: {integrity: sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==} engines: {node: '>=18'} @@ -2403,9 +2588,29 @@ packages: resolution: {integrity: sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==} engines: {node: '>= 12'} + data-view-buffer@1.0.2: + resolution: {integrity: sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==} + engines: {node: '>= 0.4'} + + data-view-byte-length@1.0.2: + resolution: {integrity: sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==} + engines: {node: '>= 0.4'} + + data-view-byte-offset@1.0.1: + resolution: {integrity: sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==} + engines: {node: '>= 0.4'} + dateformat@4.6.3: resolution: {integrity: sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA==} + debug@3.2.7: + resolution: {integrity: sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + debug@4.4.3: resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} engines: {node: '>=6.0'} @@ -2423,6 +2628,17 @@ packages: resolution: {integrity: sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==} engines: {node: '>=4.0.0'} + deep-is@0.1.4: + resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + + define-data-property@1.1.4: + resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==} + engines: {node: '>= 0.4'} + + define-properties@1.2.1: + resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==} + engines: {node: '>= 0.4'} + delayed-stream@1.0.0: resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} engines: {node: '>=0.4.0'} @@ -2444,6 +2660,18 @@ packages: resolution: {integrity: sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==} engines: {node: '>=0.3.1'} + dir-glob@3.0.1: + resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==} + engines: {node: '>=8'} + + doctrine@2.1.0: + resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==} + engines: {node: '>=0.10.0'} + + doctrine@3.0.0: + resolution: {integrity: sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==} + engines: {node: '>=6.0.0'} + drizzle-orm@0.45.2: resolution: {integrity: sha512-kY0BSaTNYWnoDMVoyY8uxmyHjpJW1geOmBMdSSicKo9CIIWkSxMIj2rkeSR51b8KAPB7m+qysjuHme5nKP+E5Q==} peerDependencies: @@ -2560,6 +2788,14 @@ packages: resolution: {integrity: sha512-nGXts5znJzmWPu+mIE9izCOzdg63oJca2mDzGWWTth7sr4aCToKcoyFVBQwN75Ij5Pf6p510EwkTqViTRzDV+w==} engines: {node: '>=10.13.0'} + es-abstract-get@1.0.0: + resolution: {integrity: sha512-6PMWXpdhshVvFp+FoWYs1EvG1Nj0tvk0dZM+XcK0xMEM1czRVcP6ohqPWHy6qPagSpC8j4+p89WXlT+xXJs/fg==} + engines: {node: '>= 0.4'} + + es-abstract@1.24.2: + resolution: {integrity: sha512-2FpH9Q5i2RRwyEP1AylXe6nYLR5OhaJTZwmlcP0dL/+JCbgg7yyEo/sEK6HeGZRf3dFpWwThaRHVApXSkW3xeg==} + engines: {node: '>= 0.4'} + es-define-property@1.0.1: resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} engines: {node: '>= 0.4'} @@ -2579,6 +2815,14 @@ packages: resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} engines: {node: '>= 0.4'} + es-shim-unscopables@1.1.0: + resolution: {integrity: sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw==} + engines: {node: '>= 0.4'} + + es-to-primitive@1.3.4: + resolution: {integrity: sha512-yPDz7wqpg1/mmHLmS3tcfTfbw5f1eryXvyghYBffGdERwe+mV7ZcWzTR8LR17Kvqt3qfPurjlonmnq3MKXIOXw==} + engines: {node: '>= 0.4'} + esbuild@0.28.2: resolution: {integrity: sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==} engines: {node: '>=18'} @@ -2591,9 +2835,108 @@ packages: escape-html@1.0.3: resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} + escape-string-regexp@4.0.0: + resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} + engines: {node: '>=10'} + + eslint-config-airbnb-base@15.0.0: + resolution: {integrity: sha512-xaX3z4ZZIcFLvh2oUNvcX5oEofXda7giYmuplVxoOg5A7EXJMrUyqRgR+mhDhPK8LZ4PttFOBvCYDbX3sUoUig==} + engines: {node: ^10.12.0 || >=12.0.0} + peerDependencies: + eslint: ^7.32.0 || ^8.2.0 + eslint-plugin-import: ^2.25.2 + + eslint-config-prettier@10.1.8: + resolution: {integrity: sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==} + hasBin: true + peerDependencies: + eslint: '>=7.0.0' + + eslint-import-resolver-node@0.3.10: + resolution: {integrity: sha512-tRrKqFyCaKict5hOd244sL6EQFNycnMQnBe+j8uqGNXYzsImGbGUU4ibtoaBmv5FLwJwcFJNeg1GeVjQfbMrDQ==} + + eslint-module-utils@2.14.0: + resolution: {integrity: sha512-W2WCRZ9Dqntd+2u8jJcVMV2PKulc6RdLgUUoh/yQr3uB6lo/ZOeGx11sv60/8S4QFFKNslAlWhr9u0Ef7ZW6Ig==} + engines: {node: '>=4'} + peerDependencies: + '@typescript-eslint/parser': '*' + eslint: '*' + eslint-import-resolver-node: '*' + eslint-import-resolver-typescript: '*' + eslint-import-resolver-webpack: '*' + peerDependenciesMeta: + '@typescript-eslint/parser': + optional: true + eslint: + optional: true + eslint-import-resolver-node: + optional: true + eslint-import-resolver-typescript: + optional: true + eslint-import-resolver-webpack: + optional: true + + eslint-plugin-import@2.32.0: + resolution: {integrity: sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==} + engines: {node: '>=4'} + peerDependencies: + '@typescript-eslint/parser': '*' + eslint: ^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8 || ^9 + peerDependenciesMeta: + '@typescript-eslint/parser': + optional: true + + eslint-plugin-prettier@5.5.5: + resolution: {integrity: sha512-hscXkbqUZ2sPithAuLm5MXL+Wph+U7wHngPBv9OMWwlP8iaflyxpjTYZkmdgB4/vPIhemRlBEoLrH7UC1n7aUw==} + engines: {node: ^14.18.0 || >=16.0.0} + peerDependencies: + '@types/eslint': '>=8.0.0' + eslint: '>=8.0.0' + eslint-config-prettier: '>= 7.0.0 <10.0.0 || >=10.1.0' + prettier: '>=3.0.0' + peerDependenciesMeta: + '@types/eslint': + optional: true + eslint-config-prettier: + optional: true + + eslint-scope@7.2.2: + resolution: {integrity: sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + eslint-visitor-keys@3.4.3: + resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + eslint@8.57.1: + resolution: {integrity: sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + deprecated: This version is no longer supported. Please see https://eslint.org/version-support for other options. + hasBin: true + + espree@9.6.1: + resolution: {integrity: sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + esquery@1.7.0: + resolution: {integrity: sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==} + engines: {node: '>=0.10'} + + esrecurse@4.3.0: + resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==} + engines: {node: '>=4.0'} + + estraverse@5.3.0: + resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} + engines: {node: '>=4.0'} + estree-walker@3.0.3: resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + esutils@2.0.3: + resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} + engines: {node: '>=0.10.0'} + etag@1.8.1: resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} engines: {node: '>= 0.6'} @@ -2645,10 +2988,19 @@ packages: fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + fast-diff@1.3.0: + resolution: {integrity: sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==} + fast-glob@3.3.3: resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} engines: {node: '>=8.6.0'} + fast-json-stable-stringify@2.1.0: + resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} + + fast-levenshtein@2.0.6: + resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + fast-safe-stringify@2.1.1: resolution: {integrity: sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==} @@ -2674,6 +3026,10 @@ packages: resolution: {integrity: sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==} engines: {node: ^12.20 || >= 14.13} + file-entry-cache@6.0.1: + resolution: {integrity: sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==} + engines: {node: ^10.12.0 || >=12.0.0} + file-uri-to-path@1.0.0: resolution: {integrity: sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==} @@ -2685,6 +3041,17 @@ packages: resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==} engines: {node: '>= 18.0.0'} + find-up@5.0.0: + resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} + engines: {node: '>=10'} + + flat-cache@3.2.0: + resolution: {integrity: sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==} + engines: {node: ^10.12.0 || >=12.0.0} + + flatted@3.4.4: + resolution: {integrity: sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==} + follow-redirects@1.16.0: resolution: {integrity: sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==} engines: {node: '>=4.0'} @@ -2694,6 +3061,10 @@ packages: debug: optional: true + for-each@0.3.5: + resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==} + engines: {node: '>= 0.4'} + form-data@4.0.6: resolution: {integrity: sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==} engines: {node: '>= 6'} @@ -2718,6 +3089,9 @@ packages: fs-constants@1.0.0: resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==} + fs.realpath@1.0.0: + resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -2726,6 +3100,13 @@ packages: function-bind@1.1.2: resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + function.prototype.name@1.2.0: + resolution: {integrity: sha512-jObKIik1P2QjPHP5nz5BaOtUlfgS0fWo8IUByNXkM+o+02sJOi94em77GwJKQSJ3gfPHdgzLNrHc1uokV4P/ew==} + engines: {node: '>= 0.4'} + + functions-have-names@1.2.3: + resolution: {integrity: sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==} + gaxios@7.3.1: resolution: {integrity: sha512-kB3rzJV7d9juLZh8/56QTXCwQfxyhdOMdyYk1HdQKFtF8TJTDTZQJtixWIwXdE9Jji91mC41DUNpjleo4L4eAQ==} engines: {node: '>=18'} @@ -2734,6 +3115,10 @@ packages: resolution: {integrity: sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==} engines: {node: '>=18'} + generator-function@2.0.1: + resolution: {integrity: sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==} + engines: {node: '>= 0.4'} + get-caller-file@2.0.5: resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} engines: {node: 6.* || 8.* || >= 10.*} @@ -2754,6 +3139,10 @@ packages: resolution: {integrity: sha512-GMat4EJ5161kIy2HevLlr4luNjBgvmj413KaQA7jt4V8B4RDsfpHk7WQ9GVqfYyyx8OS/L66Kox+rJRNklLK7w==} engines: {node: '>=6'} + get-symbol-description@1.1.0: + resolution: {integrity: sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==} + engines: {node: '>= 0.4'} + get-tsconfig@4.14.3: resolution: {integrity: sha512-++QEw4DIY7WGoukz+/+A/8dGYPT9l9yIadnmSgZ8Rjr3YVSVDipQSO9CdnJo9ePqFqUUqh+wk9uIaoiAwsiPkA==} @@ -2767,14 +3156,34 @@ packages: resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} engines: {node: '>= 6'} + glob-parent@6.0.2: + resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} + engines: {node: '>=10.13.0'} + glob@13.0.6: resolution: {integrity: sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==} engines: {node: 18 || 20 || >=22} + glob@7.2.3: + resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + global-directory@4.0.1: resolution: {integrity: sha512-wHTUcDUoZ1H5/0iVqEudYW4/kAlN5cZ3j/bXn0Dpbizl9iaUVeWSHqiOjsgk6OW2bkLclbBjzewBz6weQ1zA2Q==} engines: {node: '>=18'} + globals@13.24.0: + resolution: {integrity: sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==} + engines: {node: '>=8'} + + globalthis@1.0.4: + resolution: {integrity: sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==} + engines: {node: '>= 0.4'} + + globby@11.1.0: + resolution: {integrity: sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==} + engines: {node: '>=10'} + google-auth-library@10.9.1: resolution: {integrity: sha512-i1ydyHrqcIxXkWh/uBmVkzCvIuq5yiK2ATndIe5XxKholrG/MTYP9xGYka4sQhrbIAgGjL2B6NOE7rFaiF3fXw==} engines: {node: '>=18'} @@ -2793,15 +3202,29 @@ packages: graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + graphemer@1.4.0: + resolution: {integrity: sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==} + handlebars@4.7.9: resolution: {integrity: sha512-4E71E0rpOaQuJR2A3xDZ+GM1HyWYv1clR58tC8emQNeQe3RH7MAzSbat+V0wG78LQBo6m6bzSG/L4pBuCsgnUQ==} engines: {node: '>=0.4.7'} hasBin: true + has-bigints@1.1.0: + resolution: {integrity: sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==} + engines: {node: '>= 0.4'} + has-flag@4.0.0: resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} engines: {node: '>=8'} + has-property-descriptors@1.0.2: + resolution: {integrity: sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==} + + has-proto@1.2.0: + resolution: {integrity: sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==} + engines: {node: '>= 0.4'} + has-symbols@1.1.0: resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} engines: {node: '>= 0.4'} @@ -2854,6 +3277,10 @@ packages: ieee754@1.2.1: resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} + ignore@5.3.2: + resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} + engines: {node: '>= 4'} + ignore@7.0.5: resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==} engines: {node: '>= 4'} @@ -2865,6 +3292,18 @@ packages: immediate@3.0.6: resolution: {integrity: sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==} + import-fresh@3.3.1: + resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} + engines: {node: '>=6'} + + imurmurhash@0.1.4: + resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} + engines: {node: '>=0.8.19'} + + inflight@1.0.6: + resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==} + deprecated: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful. + inherits@2.0.4: resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} @@ -2875,6 +3314,10 @@ packages: resolution: {integrity: sha512-QQnnxNyfvmHFIsj7gkPcYymR8Jdw/o7mp5ZFihxn6h8Ci6fh3Dx4E1gPjpQEpIuPo9XVNY/ZUwh4BPMjGyL01g==} engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + internal-slot@1.1.0: + resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==} + engines: {node: '>= 0.4'} + interpret@1.4.0: resolution: {integrity: sha512-agE4QfB2Lkp9uICn7BAqoscw4SZP9kTE2hxiFI3jBPmXJfdqiahTbUuKGsMoN2GtqL9AxhYioAcVvgsb1HvRbA==} engines: {node: '>= 0.10'} @@ -2891,18 +3334,58 @@ packages: resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} engines: {node: '>= 0.10'} + is-array-buffer@3.0.5: + resolution: {integrity: sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==} + engines: {node: '>= 0.4'} + + is-async-function@2.1.1: + resolution: {integrity: sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==} + engines: {node: '>= 0.4'} + + is-bigint@1.1.0: + resolution: {integrity: sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==} + engines: {node: '>= 0.4'} + + is-boolean-object@1.2.2: + resolution: {integrity: sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==} + engines: {node: '>= 0.4'} + + is-callable@1.2.7: + resolution: {integrity: sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==} + engines: {node: '>= 0.4'} + is-core-module@2.17.0: resolution: {integrity: sha512-J/vG0zBCbIKOQFfufSwyXdMrsohyJIUNkrnmo6WZGzoM7tr/lsbfW5b2BvisL6zsyMzK9UxV9L6c7AoFbyXHOA==} engines: {node: '>= 0.4'} + is-data-view@1.0.2: + resolution: {integrity: sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==} + engines: {node: '>= 0.4'} + + is-date-object@1.1.0: + resolution: {integrity: sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==} + engines: {node: '>= 0.4'} + + is-document.all@1.0.0: + resolution: {integrity: sha512-+XSoyS05OdBbhFuELhgTCpFNHkpBOJqtsZfUFFpe5QTw+9Sjbh8zitxhQkYAo6wV7e1Vb8cAPvpCk9jGam/82g==} + engines: {node: '>= 0.4'} + is-extglob@2.1.1: resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} engines: {node: '>=0.10.0'} + is-finalizationregistry@1.1.1: + resolution: {integrity: sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==} + engines: {node: '>= 0.4'} + is-fullwidth-code-point@3.0.0: resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} engines: {node: '>=8'} + is-generator-function@1.1.2: + resolution: {integrity: sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==} + engines: {node: '>= 0.4'} + is-glob@4.0.3: resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} engines: {node: '>=0.10.0'} @@ -2911,10 +3394,26 @@ packages: resolution: {integrity: sha512-K55T22lfpQ63N4KEN57jZUAaAYqYHEe8veb/TycJRk9DdSCLLcovXz/mL6mOnhQaZsQGwPhuFopdQIlqGSEjiQ==} engines: {node: '>=18'} + is-map@2.0.3: + resolution: {integrity: sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==} + engines: {node: '>= 0.4'} + + is-negative-zero@2.0.3: + resolution: {integrity: sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==} + engines: {node: '>= 0.4'} + + is-number-object@1.1.1: + resolution: {integrity: sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==} + engines: {node: '>= 0.4'} + is-number@7.0.0: resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} engines: {node: '>=0.12.0'} + is-path-inside@3.0.3: + resolution: {integrity: sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==} + engines: {node: '>=8'} + is-path-inside@4.0.0: resolution: {integrity: sha512-lJJV/5dYS+RcL8uQdBDW9c9uWFLLBNRyFhnAKXw5tVqLlKZ4RMGZKv+YQ/IA3OhD+RpbJa1LLFM1FQPGyIXvOA==} engines: {node: '>=12'} @@ -2922,13 +3421,52 @@ packages: is-promise@4.0.0: resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==} + is-regex@1.2.1: + resolution: {integrity: sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==} + engines: {node: '>= 0.4'} + + is-set@2.0.3: + resolution: {integrity: sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==} + engines: {node: '>= 0.4'} + + is-shared-array-buffer@1.0.4: + resolution: {integrity: sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==} + engines: {node: '>= 0.4'} + is-stream@1.1.0: resolution: {integrity: sha512-uQPm8kcs47jx38atAcWTVxyltQYoPT68y9aWYdV6yWXSyW8mzSat0TL6CiWdZeCdF3KrAvpVtnHbTv4RN+rqdQ==} engines: {node: '>=0.10.0'} + is-string@1.1.1: + resolution: {integrity: sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==} + engines: {node: '>= 0.4'} + + is-symbol@1.1.1: + resolution: {integrity: sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==} + engines: {node: '>= 0.4'} + + is-typed-array@1.1.15: + resolution: {integrity: sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==} + engines: {node: '>= 0.4'} + + is-weakmap@2.0.2: + resolution: {integrity: sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==} + engines: {node: '>= 0.4'} + + is-weakref@1.1.1: + resolution: {integrity: sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==} + engines: {node: '>= 0.4'} + + is-weakset@2.0.4: + resolution: {integrity: sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==} + engines: {node: '>= 0.4'} + isarray@1.0.0: resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==} + isarray@2.0.5: + resolution: {integrity: sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==} + isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} @@ -3013,16 +3551,29 @@ packages: json-bigint@1.0.0: resolution: {integrity: sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==} + json-buffer@3.0.1: + resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} + json-schema-to-ts@3.1.1: resolution: {integrity: sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==} engines: {node: '>=16'} + json-schema-traverse@0.4.1: + resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} + json-schema-traverse@1.0.0: resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} json-schema-typed@8.0.2: resolution: {integrity: sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==} + json-stable-stringify-without-jsonify@1.0.1: + resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} + + json5@1.0.2: + resolution: {integrity: sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==} + hasBin: true + json5@2.2.3: resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} engines: {node: '>=6'} @@ -3037,6 +3588,9 @@ packages: jws@4.0.1: resolution: {integrity: sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==} + keyv@4.5.4: + resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + kleur@3.0.3: resolution: {integrity: sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==} engines: {node: '>=6'} @@ -3050,9 +3604,17 @@ packages: resolution: {integrity: sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==} engines: {node: '>= 0.6.3'} + levn@0.4.1: + resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} + engines: {node: '>= 0.8.0'} + lie@3.3.0: resolution: {integrity: sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==} + locate-path@6.0.0: + resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} + engines: {node: '>=10'} + lodash.identity@3.0.0: resolution: {integrity: sha512-AupTIzdLQxJS5wIYUQlgGyk2XRTfGXA+MCghDHqZk0pzUNYvd3EESS6dkChNauNYVIutcb0dfHw1ri9Q1yPV8Q==} @@ -3128,6 +3690,13 @@ packages: resolution: {integrity: sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==} engines: {node: 18 || 20 || >=22} + minimatch@3.1.5: + resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==} + + minimatch@9.0.9: + resolution: {integrity: sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==} + engines: {node: '>=16 || 14 >=14.17'} + minimist@1.2.8: resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} @@ -3156,6 +3725,9 @@ packages: napi-build-utils@2.0.0: resolution: {integrity: sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==} + natural-compare@1.4.0: + resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + negotiator@1.1.0: resolution: {integrity: sha512-NMPBRMJgiQHjbd8phG3Vebdx4kZ1H121rbl5IkMqeOsahptB9BKo/d7oJ3zTXqTgagn2bWlNSXkh0QUGM31RYg==} engines: {node: '>=18'} @@ -3178,6 +3750,10 @@ packages: engines: {node: '>=10.5.0'} deprecated: Use your platform's native DOMException instead + node-exports-info@1.6.2: + resolution: {integrity: sha512-kXs9Go0cah0qHVV2v389IXQLdLCeE1xfFtjOAF+iobu0OIoG1pje8At2vMHyaPMiPMnG/LWP50twML21eMcAag==} + engines: {node: '>= 0.4'} + node-fetch@3.3.2: resolution: {integrity: sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} @@ -3201,11 +3777,35 @@ packages: resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} engines: {node: '>= 0.4'} - obug@2.2.1: - resolution: {integrity: sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==} - engines: {node: '>=12.20.0'} + object-keys@1.1.1: + resolution: {integrity: sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==} + engines: {node: '>= 0.4'} - on-exit-leak-free@2.1.2: + object.assign@4.1.7: + resolution: {integrity: sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==} + engines: {node: '>= 0.4'} + + object.entries@1.1.9: + resolution: {integrity: sha512-8u/hfXFRBD1O0hPUjioLhoWFHRmt6tKA4/vZPyckBr18l1KE9uHrFaFaUi8MDRTpi4uak2goyPTSNJLXX2k2Hw==} + engines: {node: '>= 0.4'} + + object.fromentries@2.0.8: + resolution: {integrity: sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ==} + engines: {node: '>= 0.4'} + + object.groupby@1.0.3: + resolution: {integrity: sha512-+Lhy3TQTuzXI5hevh8sBGqbmurHbbIjAi0Z4S63nthVLmLxfbj4T54a4CfZrXIrt9iP4mVAPYMo/v99taj3wjQ==} + engines: {node: '>= 0.4'} + + object.values@1.2.1: + resolution: {integrity: sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==} + engines: {node: '>= 0.4'} + + obug@2.2.1: + resolution: {integrity: sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==} + engines: {node: '>=12.20.0'} + + on-exit-leak-free@2.1.2: resolution: {integrity: sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==} engines: {node: '>=14.0.0'} @@ -3228,6 +3828,14 @@ packages: zod: optional: true + optionator@0.9.4: + resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} + engines: {node: '>= 0.8.0'} + + own-keys@1.0.2: + resolution: {integrity: sha512-19YVAg7T+WTrxggPukVq7DjTv6+PJ867TmhCvBsYwmbFCsZd344rq2Ld1p0wo8f8Qrrhgp82c6FJRqdXWtSEhg==} + engines: {node: '>= 0.4'} + oxc-parser@0.148.0: resolution: {integrity: sha512-syxUKHeUll89RIABQADcI7sikYrwyssvA6gj4phSSIPezKVM8yMaLAiLLSc7fmzVvrwybfFGFbW5zme9sX87rg==} engines: {node: ^20.19.0 || >=22.12.0} @@ -3239,6 +3847,14 @@ packages: resolution: {integrity: sha512-LICb2p9CB7FS+0eR1oqWnHhp0FljGLZCWBE9aix0Uye9W8LTQPwMTYVGWQWIw9RdQiDg4+epXQODwIYJtSJaow==} engines: {node: '>=4'} + p-limit@3.1.0: + resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} + engines: {node: '>=10'} + + p-locate@5.0.0: + resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} + engines: {node: '>=10'} + p-retry@4.6.2: resolution: {integrity: sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ==} engines: {node: '>=8'} @@ -3249,6 +3865,10 @@ packages: pako@1.0.11: resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==} + parent-module@1.0.1: + resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} + engines: {node: '>=6'} + parse5-htmlparser2-tree-adapter@6.0.1: resolution: {integrity: sha512-qPuWvbLgvDGilKc5BoicRovlT4MtYT6JfJyBOMDsKoiT+GiuP5qyrPCnR9HcPECIJJmZh5jRndyNThnhhb/vlA==} @@ -3265,6 +3885,14 @@ packages: partial-json@0.1.7: resolution: {integrity: sha512-Njv/59hHaokb/hRUjce3Hdv12wd60MtM9Z5Olmn+nehe0QDAsRtRbJPvJ0Z91TusF0SuZRIvnM+S4l6EIP8leA==} + path-exists@4.0.0: + resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} + engines: {node: '>=8'} + + path-is-absolute@1.0.1: + resolution: {integrity: sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==} + engines: {node: '>=0.10.0'} + path-key@2.0.1: resolution: {integrity: sha512-fEHGKCSmUSDPv4uoj8AlD+joPlq3peND+HRYyxFz4KPw4z926S/b8rIuFs2FYJg3BwsxJf6A9/3eIdLaYC+9Dw==} engines: {node: '>=4'} @@ -3283,6 +3911,10 @@ packages: path-to-regexp@8.4.2: resolution: {integrity: sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==} + path-type@4.0.0: + resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} + engines: {node: '>=8'} + pathe@2.0.3: resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} @@ -3322,6 +3954,10 @@ packages: resolution: {integrity: sha512-TRzzuFRRmEoSW/p1KVAmiOgPco2Irlah+bGFCeNfJXxxYGwSw7YwAOAcd7X28K/m5bjBWKsC29KyoMfHbypayg==} engines: {node: '>=12.13.0'} + possible-typed-array-names@1.1.0: + resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} + engines: {node: '>= 0.4'} + postcss@8.5.28: resolution: {integrity: sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==} engines: {node: ^10 || ^12 || >=14} @@ -3332,6 +3968,14 @@ packages: deprecated: No longer maintained. Please contact the author of the relevant native addon; alternatives are available. hasBin: true + prelude-ls@1.2.1: + resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} + engines: {node: '>= 0.8.0'} + + prettier-linter-helpers@1.0.1: + resolution: {integrity: sha512-SxToR7P8Y2lWmv/kTzVLC1t/GDI2WGjMwNhLLE9qtH8Q13C+aEmuRlzDst4Up4s0Wc8sF2M+J57iB3cMLqftfg==} + engines: {node: '>=6.0.0'} + prettier@3.9.6: resolution: {integrity: sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g==} engines: {node: '>=14'} @@ -3369,6 +4013,10 @@ packages: pump@3.0.4: resolution: {integrity: sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==} + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + qs@6.16.0: resolution: {integrity: sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==} engines: {node: '>=0.6'} @@ -3417,10 +4065,18 @@ packages: resolution: {integrity: sha512-/vxpCXddiX8NGfGO/mTafwjq4aFa/71pvamip0++IQk3zG8cbCj0fifNPrjjF1XMXUne91jL9OoxmdykoEtifQ==} engines: {node: '>= 10.13.0'} + reflect.getprototypeof@1.0.10: + resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==} + engines: {node: '>= 0.4'} + regexp-tree@0.1.27: resolution: {integrity: sha512-iETxpjK6YoRWJG5o6hXLwvjYAoW+FEZn9os0PD/b6AP6xQwsa/Y7lCVgIixBbUPMfhu+i2LtdeAqVTgGlQarfA==} hasBin: true + regexp.prototype.flags@1.5.4: + resolution: {integrity: sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==} + engines: {node: '>= 0.4'} + require-directory@2.1.1: resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} engines: {node: '>=0.10.0'} @@ -3429,6 +4085,10 @@ packages: resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} engines: {node: '>=0.10.0'} + resolve-from@4.0.0: + resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} + engines: {node: '>=4'} + resolve-pkg-maps@1.0.0: resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} @@ -3437,6 +4097,11 @@ packages: engines: {node: '>= 0.4'} hasBin: true + resolve@2.0.0-next.7: + resolution: {integrity: sha512-tqt+NBWwyaMgw3zDsnygx4CByWjQEJHOPMdslYhppaQSJUtL/D4JO9CcBBlhPoI8lz9oJIDXkwXfhF4aWqP8xQ==} + engines: {node: '>= 0.4'} + hasBin: true + retry@0.12.0: resolution: {integrity: sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==} engines: {node: '>= 4'} @@ -3449,6 +4114,11 @@ packages: resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + rimraf@3.0.2: + resolution: {integrity: sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==} + deprecated: Rimraf versions prior to v4 are no longer supported + hasBin: true + rollup@4.63.1: resolution: {integrity: sha512-3Df9jsstwhccuEfmAMi9l8XUh/GOkVObmFTU7CCVBysEbcOZLl84jCtaAZMcPiMz2EGKsATzQcU+Xr3n/wU6cg==} engines: {node: '>=18.0.0', npm: '>=8.0.0'} @@ -3461,12 +4131,24 @@ packages: run-parallel@1.2.0: resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} + safe-array-concat@1.1.4: + resolution: {integrity: sha512-wtZlHyOje6OZTGqAoaDKxFkgRtkF9CnHAVnCHKfuj200wAgL+bSJhdsCD2l0Qx/2ekEXjPWcyKkfGb5CPboslg==} + engines: {node: '>=0.4'} + safe-buffer@5.1.2: resolution: {integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==} safe-buffer@5.2.1: resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} + safe-push-apply@1.0.0: + resolution: {integrity: sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==} + engines: {node: '>= 0.4'} + + safe-regex-test@1.1.0: + resolution: {integrity: sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==} + engines: {node: '>= 0.4'} + safe-regex@2.1.1: resolution: {integrity: sha512-rx+x8AMzKb5Q5lQ95Zoi6ZbJqwCLkqi3XuJXp5P3rT8OEc6sZCJG5AE5dU3lsgRr/F4Bs31jSlVN+j5KrsGu9A==} @@ -3488,6 +4170,10 @@ packages: resolution: {integrity: sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==} hasBin: true + semver@6.3.1: + resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} + hasBin: true + semver@7.8.5: resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} engines: {node: '>=10'} @@ -3501,6 +4187,18 @@ packages: resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==} engines: {node: '>= 18'} + set-function-length@1.2.2: + resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==} + engines: {node: '>= 0.4'} + + set-function-name@2.0.2: + resolution: {integrity: sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==} + engines: {node: '>= 0.4'} + + set-proto@1.0.0: + resolution: {integrity: sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==} + engines: {node: '>= 0.4'} + setimmediate@1.0.5: resolution: {integrity: sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==} @@ -3568,6 +4266,10 @@ packages: sisteransi@1.0.5: resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==} + slash@3.0.0: + resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==} + engines: {node: '>=8'} + smol-toml@1.7.1: resolution: {integrity: sha512-PPlsspAZ4jbMBu5DMFhfUGDQLu/vrL4SyBROVS37x8ynnVmFIs1VPBz1Co8Xks3TvpIaZXmU85y4DrQ+UyVFoQ==} engines: {node: '>= 18'} @@ -3601,10 +4303,26 @@ packages: std-env@4.2.0: resolution: {integrity: sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==} + stop-iteration-iterator@1.1.0: + resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==} + engines: {node: '>= 0.4'} + string-width@4.2.3: resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} engines: {node: '>=8'} + string.prototype.trim@1.2.11: + resolution: {integrity: sha512-PwvK7BU+CMTJGYQCTZb5RWXIML92lftJLhQz1tBzgKiqGxJaMlBAa48POXaNAC2s4y8jr3EFqrkF9+44neS46w==} + engines: {node: '>= 0.4'} + + string.prototype.trimend@1.0.10: + resolution: {integrity: sha512-2+3aDAOmPTmuFwjDnmJG2ctEkQKVki7vOSqaxkv42Mowj1V6PnvuwFCRrR5lChUux1TBskPjfkeTOhqczDMxTw==} + engines: {node: '>= 0.4'} + + string.prototype.trimstart@1.0.8: + resolution: {integrity: sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==} + engines: {node: '>= 0.4'} + string_decoder@1.1.1: resolution: {integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==} @@ -3643,6 +4361,10 @@ packages: resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} engines: {node: '>= 0.4'} + synckit@0.11.13: + resolution: {integrity: sha512-eNRKgb3z66Yp3D2CixVujOUvXLFUTij/zVnV8KRyvFdQwpz7I5DS8UfRkTeLzb64u+dkzDSdelE24izu+zSSUg==} + engines: {node: ^14.18.0 || >=16.0.0} + tapable@2.3.3: resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==} engines: {node: '>=6'} @@ -3658,6 +4380,9 @@ packages: resolution: {integrity: sha512-MFO/QzvtAOmJbkhOaCTvbGcFN9L9b+JunIsDwaKljSOdcLMea3NJ1k9Usz/rjdfSXTq4dfzfeS7W4p4YOAAHeA==} engines: {node: '>=18'} + text-table@0.2.0: + resolution: {integrity: sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==} + thenify-all@1.6.0: resolution: {integrity: sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==} engines: {node: '>=0.8'} @@ -3695,10 +4420,19 @@ packages: ts-algebra@2.0.0: resolution: {integrity: sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==} + ts-api-utils@1.4.3: + resolution: {integrity: sha512-i3eMG77UTMD0hZhgRS562pv83RC6ukSAC2GMNWc+9dieh/+jDM5u5YG+NHX6VNDRHQcHwmsTHctP9LhbC3WxVw==} + engines: {node: '>=16'} + peerDependencies: + typescript: '>=4.2.0' + tsconfig-paths-webpack-plugin@4.2.0: resolution: {integrity: sha512-zbem3rfRS8BgeNK50Zz5SIQgXzLafiHjOwUAvk/38/o1jHn/V5QAgVUcz884or7WYcPaH3N2CIfUc2u0ul7UcA==} engines: {node: '>=10.13.0'} + tsconfig-paths@3.15.0: + resolution: {integrity: sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==} + tsconfig-paths@4.2.0: resolution: {integrity: sha512-NoZ4roiN7LnbKn9QqE1amc9DJfzvZXxF4xDavcOWt1BPkdx+m+0gJuPM+S0vCe7zTJMYUP0R8pO2XMr+Y8oLIg==} engines: {node: '>=6'} @@ -3714,6 +4448,14 @@ packages: tunnel-agent@0.6.0: resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==} + type-check@0.4.0: + resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} + engines: {node: '>= 0.8.0'} + + type-fest@0.20.2: + resolution: {integrity: sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==} + engines: {node: '>=10'} + type-is@2.1.0: resolution: {integrity: sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==} engines: {node: '>= 18'} @@ -3721,6 +4463,22 @@ packages: typebox@1.1.38: resolution: {integrity: sha512-pZ0aQPmMmXoUvSbeuWf/Hzsc+avNw/Zd6VeE8CFgkVGWyuHPJvqeJJDeJqLve+K70LvjYIoleGcoJHPT17cWoA==} + typed-array-buffer@1.0.3: + resolution: {integrity: sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==} + engines: {node: '>= 0.4'} + + typed-array-byte-length@1.0.3: + resolution: {integrity: sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==} + engines: {node: '>= 0.4'} + + typed-array-byte-offset@1.0.5: + resolution: {integrity: sha512-0FHJvLPqZ7KJzp17O13jfsAjsqazgrxBu2zEK95PmUz8lv2+GjRuxUInCr2Rk9Dms3ihN21zJ929ZO43yJ95QQ==} + engines: {node: '>= 0.4'} + + typed-array-length@1.0.8: + resolution: {integrity: sha512-phPGCwqr2+Qo0fwniCE8e4pKnGu/yFb5nD5Y8bf0EEeiI5GklnACYA9GFy/DrAeRrKHXvHn+1SUsOWgJp6RO+g==} + engines: {node: '>= 0.4'} + typescript@5.9.3: resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} engines: {node: '>=14.17'} @@ -3735,6 +4493,10 @@ packages: resolution: {integrity: sha512-FoSOKV7NEofQSkAefMVHam4ZPKYMxjAydxiV72UFEDNV/YofxjGfiZ2A9pZjdL/lRJzTjcu4PABo1JYJX8N5iQ==} engines: {node: '>=14'} + unbox-primitive@1.1.0: + resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==} + engines: {node: '>= 0.4'} + undici-types@6.21.0: resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} @@ -3758,6 +4520,9 @@ packages: resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} engines: {node: '>= 0.8'} + uri-js@4.4.1: + resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} @@ -3859,6 +4624,22 @@ packages: resolution: {integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==} engines: {node: '>= 8'} + which-boxed-primitive@1.1.1: + resolution: {integrity: sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==} + engines: {node: '>= 0.4'} + + which-builtin-type@1.2.1: + resolution: {integrity: sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q==} + engines: {node: '>= 0.4'} + + which-collection@1.0.2: + resolution: {integrity: sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==} + engines: {node: '>= 0.4'} + + which-typed-array@1.1.24: + resolution: {integrity: sha512-wk4Mf4pR5mRP7eYuuTBCIQ9d0ud2Fv2jRLQpfgnRjbOxAFHmjKFValgTpitVKzJJS8ajnYQV2Du1SZ8j6b/EUQ==} + engines: {node: '>= 0.4'} + which@1.3.1: resolution: {integrity: sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==} hasBin: true @@ -3873,6 +4654,10 @@ packages: engines: {node: '>=8'} hasBin: true + word-wrap@1.2.5: + resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} + engines: {node: '>=0.10.0'} + wordwrap@1.0.0: resolution: {integrity: sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==} @@ -3919,6 +4704,10 @@ packages: resolution: {integrity: sha512-Nt9ZJjXTv5R8MHbqby/wXQ6Gi0Bb3TcYZkR1bzuL4yB2OxWPkXknz513gEF0GoA6tn00UpbPvERW8rzCuWCA6w==} engines: {node: '>=10'} + yocto-queue@0.1.0: + resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} + engines: {node: '>=10'} + zod-to-json-schema@3.25.2: resolution: {integrity: sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==} peerDependencies: @@ -4267,6 +5056,29 @@ snapshots: '@esbuild/win32-x64@0.28.2': optional: true + '@eslint-community/eslint-utils@4.10.1(eslint@8.57.1)': + dependencies: + eslint: 8.57.1 + eslint-visitor-keys: 3.4.3 + + '@eslint-community/regexpp@4.12.2': {} + + '@eslint/eslintrc@2.1.4': + dependencies: + ajv: 6.15.0 + debug: 4.4.3 + espree: 9.6.1 + globals: 13.24.0 + ignore: 5.3.2 + import-fresh: 3.3.1 + js-yaml: 4.3.2 + minimatch: 3.1.5 + strip-json-comments: 3.1.1 + transitivePeerDependencies: + - supports-color + + '@eslint/js@8.57.1': {} + '@google/genai@1.52.0(@modelcontextprotocol/sdk@1.30.0(zod@3.25.76))': dependencies: google-auth-library: 10.9.1 @@ -4284,6 +5096,18 @@ snapshots: dependencies: hono: 4.13.5 + '@humanwhocodes/config-array@0.13.0': + dependencies: + '@humanwhocodes/object-schema': 2.0.3 + debug: 4.4.3 + minimatch: 3.1.5 + transitivePeerDependencies: + - supports-color + + '@humanwhocodes/module-importer@1.0.1': {} + + '@humanwhocodes/object-schema@2.0.3': {} + '@isaacs/fs-minipass@4.0.1': dependencies: minipass: 7.1.3 @@ -4561,6 +5385,8 @@ snapshots: '@pinojs/redact@0.4.0': {} + '@pkgr/core@0.3.6': {} + '@pondwader/socks5-server@1.0.10': {} '@protobufjs/aspromise@1.1.2': {} @@ -4658,6 +5484,8 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.63.1': optional: true + '@rtsao/scc@1.1.0': {} + '@silvia-odwyer/photon-node@0.3.4': {} '@sinclair/typebox@0.34.52': {} @@ -4741,6 +5569,8 @@ snapshots: '@types/js-yaml@4.0.9': {} + '@types/json5@0.0.29': {} + '@types/lazystream@1.0.0': dependencies: '@types/node': 20.19.43 @@ -4782,6 +5612,87 @@ snapshots: dependencies: '@types/node': 20.19.43 + '@typescript-eslint/eslint-plugin@7.18.0(@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1)(typescript@5.9.3)': + dependencies: + '@eslint-community/regexpp': 4.12.2 + '@typescript-eslint/parser': 7.18.0(eslint@8.57.1)(typescript@5.9.3) + '@typescript-eslint/scope-manager': 7.18.0 + '@typescript-eslint/type-utils': 7.18.0(eslint@8.57.1)(typescript@5.9.3) + '@typescript-eslint/utils': 7.18.0(eslint@8.57.1)(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 7.18.0 + eslint: 8.57.1 + graphemer: 1.4.0 + ignore: 5.3.2 + natural-compare: 1.4.0 + ts-api-utils: 1.4.3(typescript@5.9.3) + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.9.3)': + dependencies: + '@typescript-eslint/scope-manager': 7.18.0 + '@typescript-eslint/types': 7.18.0 + '@typescript-eslint/typescript-estree': 7.18.0(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 7.18.0 + debug: 4.4.3 + eslint: 8.57.1 + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/scope-manager@7.18.0': + dependencies: + '@typescript-eslint/types': 7.18.0 + '@typescript-eslint/visitor-keys': 7.18.0 + + '@typescript-eslint/type-utils@7.18.0(eslint@8.57.1)(typescript@5.9.3)': + dependencies: + '@typescript-eslint/typescript-estree': 7.18.0(typescript@5.9.3) + '@typescript-eslint/utils': 7.18.0(eslint@8.57.1)(typescript@5.9.3) + debug: 4.4.3 + eslint: 8.57.1 + ts-api-utils: 1.4.3(typescript@5.9.3) + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/types@7.18.0': {} + + '@typescript-eslint/typescript-estree@7.18.0(typescript@5.9.3)': + dependencies: + '@typescript-eslint/types': 7.18.0 + '@typescript-eslint/visitor-keys': 7.18.0 + debug: 4.4.3 + globby: 11.1.0 + is-glob: 4.0.3 + minimatch: 9.0.9 + semver: 7.8.5 + ts-api-utils: 1.4.3(typescript@5.9.3) + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/utils@7.18.0(eslint@8.57.1)(typescript@5.9.3)': + dependencies: + '@eslint-community/eslint-utils': 4.10.1(eslint@8.57.1) + '@typescript-eslint/scope-manager': 7.18.0 + '@typescript-eslint/types': 7.18.0 + '@typescript-eslint/typescript-estree': 7.18.0(typescript@5.9.3) + eslint: 8.57.1 + transitivePeerDependencies: + - supports-color + - typescript + + '@typescript-eslint/visitor-keys@7.18.0': + dependencies: + '@typescript-eslint/types': 7.18.0 + eslint-visitor-keys: 3.4.3 + '@typescript/native-preview-darwin-arm64@7.0.0-dev.20260707.2': optional: true @@ -4813,6 +5724,8 @@ snapshots: '@typescript/native-preview-win32-arm64': 7.0.0-dev.20260707.2 '@typescript/native-preview-win32-x64': 7.0.0-dev.20260707.2 + '@ungap/structured-clone@1.4.0': {} + '@vitest/coverage-v8@4.1.11(vitest@4.1.11)': dependencies: '@bcoe/v8-coverage': 1.0.2 @@ -4967,6 +5880,13 @@ snapshots: optionalDependencies: ajv: 8.18.0 + ajv@6.15.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-json-stable-stringify: 2.1.0 + json-schema-traverse: 0.4.1 + uri-js: 4.4.1 + ajv@8.18.0: dependencies: fast-deep-equal: 3.1.3 @@ -4984,6 +5904,58 @@ snapshots: argparse@2.0.1: {} + array-buffer-byte-length@1.0.2: + dependencies: + call-bound: 1.0.4 + is-array-buffer: 3.0.5 + + array-includes@3.2.0: + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-abstract: 1.24.2 + es-object-atoms: 1.1.2 + es-shim-unscopables: 1.1.0 + is-string: 1.1.1 + math-intrinsics: 1.1.0 + + array-union@2.1.0: {} + + array.prototype.findlastindex@1.2.6: + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-abstract: 1.24.2 + es-errors: 1.3.0 + es-object-atoms: 1.1.2 + es-shim-unscopables: 1.1.0 + + array.prototype.flat@1.3.3: + dependencies: + call-bind: 1.0.9 + define-properties: 1.2.1 + es-abstract: 1.24.2 + es-shim-unscopables: 1.1.0 + + array.prototype.flatmap@1.3.3: + dependencies: + call-bind: 1.0.9 + define-properties: 1.2.1 + es-abstract: 1.24.2 + es-shim-unscopables: 1.1.0 + + arraybuffer.prototype.slice@1.0.4: + dependencies: + array-buffer-byte-length: 1.0.2 + call-bind: 1.0.9 + define-properties: 1.2.1 + es-abstract: 1.24.2 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + is-array-buffer: 3.0.5 + assertion-error@2.0.1: {} ast-v8-to-istanbul@1.0.6: @@ -4992,11 +5964,17 @@ snapshots: estree-walker: 3.0.3 js-tokens: 10.0.0 + async-function@1.0.0: {} + asynckit@0.4.0: optional: true atomic-sleep@1.0.0: {} + available-typed-arrays@1.0.7: + dependencies: + possible-typed-array-names: 1.1.0 + axios@1.20.0: dependencies: follow-redirects: 1.16.0 @@ -5008,6 +5986,8 @@ snapshots: - supports-color optional: true + balanced-match@1.0.2: {} + balanced-match@4.0.4: {} base64-js@1.5.1: {} @@ -5047,6 +6027,15 @@ snapshots: bowser@2.14.1: {} + brace-expansion@1.1.21: + dependencies: + balanced-match: 1.0.2 + concat-map: 0.0.1 + + brace-expansion@2.1.7: + dependencies: + balanced-match: 1.0.2 + brace-expansion@5.0.9: dependencies: balanced-match: 4.0.4 @@ -5074,11 +6063,20 @@ snapshots: es-errors: 1.3.0 function-bind: 1.1.2 + call-bind@1.0.9: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + get-intrinsic: 1.3.0 + set-function-length: 1.2.2 + call-bound@1.0.4: dependencies: call-bind-apply-helpers: 1.0.2 get-intrinsic: 1.3.0 + callsites@3.1.0: {} + canvas@3.2.3: dependencies: node-addon-api: 7.1.1 @@ -5129,6 +6127,10 @@ snapshots: commander@15.0.0: {} + concat-map@0.0.1: {} + + confusing-browser-globals@1.0.11: {} + content-disposition@1.1.0: {} content-type@1.0.5: {} @@ -5166,8 +6168,30 @@ snapshots: data-uri-to-buffer@4.0.1: {} + data-view-buffer@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-data-view: 1.0.2 + + data-view-byte-length@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-data-view: 1.0.2 + + data-view-byte-offset@1.0.1: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-data-view: 1.0.2 + dateformat@4.6.3: {} + debug@3.2.7: + dependencies: + ms: 2.1.3 + debug@4.4.3: dependencies: ms: 2.1.3 @@ -5178,6 +6202,20 @@ snapshots: deep-extend@0.6.0: {} + deep-is@0.1.4: {} + + define-data-property@1.1.4: + dependencies: + es-define-property: 1.0.1 + es-errors: 1.3.0 + gopd: 1.2.0 + + define-properties@1.2.1: + dependencies: + define-data-property: 1.1.4 + has-property-descriptors: 1.0.2 + object-keys: 1.1.1 + delayed-stream@1.0.0: optional: true @@ -5208,6 +6246,18 @@ snapshots: diff@8.0.4: {} + dir-glob@3.0.1: + dependencies: + path-type: 4.0.0 + + doctrine@2.1.0: + dependencies: + esutils: 2.0.3 + + doctrine@3.0.0: + dependencies: + esutils: 2.0.3 + drizzle-orm@0.45.2(@opentelemetry/api@1.9.0)(better-sqlite3@12.11.1): optionalDependencies: '@opentelemetry/api': 1.9.0 @@ -5238,6 +6288,70 @@ snapshots: graceful-fs: 4.2.11 tapable: 2.3.3 + es-abstract-get@1.0.0: + dependencies: + es-errors: 1.3.0 + es-object-atoms: 1.1.2 + is-callable: 1.2.7 + object-inspect: 1.13.4 + + es-abstract@1.24.2: + dependencies: + array-buffer-byte-length: 1.0.2 + arraybuffer.prototype.slice: 1.0.4 + available-typed-arrays: 1.0.7 + call-bind: 1.0.9 + call-bound: 1.0.4 + data-view-buffer: 1.0.2 + data-view-byte-length: 1.0.2 + data-view-byte-offset: 1.0.1 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.2 + es-set-tostringtag: 2.1.0 + es-to-primitive: 1.3.4 + function.prototype.name: 1.2.0 + get-intrinsic: 1.3.0 + get-proto: 1.0.1 + get-symbol-description: 1.1.0 + globalthis: 1.0.4 + gopd: 1.2.0 + has-property-descriptors: 1.0.2 + has-proto: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.4 + internal-slot: 1.1.0 + is-array-buffer: 3.0.5 + is-callable: 1.2.7 + is-data-view: 1.0.2 + is-negative-zero: 2.0.3 + is-regex: 1.2.1 + is-set: 2.0.3 + is-shared-array-buffer: 1.0.4 + is-string: 1.1.1 + is-typed-array: 1.1.15 + is-weakref: 1.1.1 + math-intrinsics: 1.1.0 + object-inspect: 1.13.4 + object-keys: 1.1.1 + object.assign: 4.1.7 + own-keys: 1.0.2 + regexp.prototype.flags: 1.5.4 + safe-array-concat: 1.1.4 + safe-push-apply: 1.0.0 + safe-regex-test: 1.1.0 + set-proto: 1.0.0 + stop-iteration-iterator: 1.1.0 + string.prototype.trim: 1.2.11 + string.prototype.trimend: 1.0.10 + string.prototype.trimstart: 1.0.8 + typed-array-buffer: 1.0.3 + typed-array-byte-length: 1.0.3 + typed-array-byte-offset: 1.0.5 + typed-array-length: 1.0.8 + unbox-primitive: 1.1.0 + which-typed-array: 1.1.24 + es-define-property@1.0.1: {} es-errors@1.3.0: {} @@ -5254,7 +6368,19 @@ snapshots: get-intrinsic: 1.3.0 has-tostringtag: 1.0.2 hasown: 2.0.4 - optional: true + + es-shim-unscopables@1.1.0: + dependencies: + hasown: 2.0.4 + + es-to-primitive@1.3.4: + dependencies: + es-abstract-get: 1.0.0 + es-define-property: 1.0.1 + es-errors: 1.3.0 + is-callable: 1.2.7 + is-date-object: 1.1.0 + is-symbol: 1.1.1 esbuild@0.28.2: optionalDependencies: @@ -5289,10 +6415,149 @@ snapshots: escape-html@1.0.3: {} - estree-walker@3.0.3: + escape-string-regexp@4.0.0: {} + + eslint-config-airbnb-base@15.0.0(eslint-plugin-import@2.32.0(@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1): + dependencies: + confusing-browser-globals: 1.0.11 + eslint: 8.57.1 + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1) + object.assign: 4.1.7 + object.entries: 1.1.9 + semver: 6.3.1 + + eslint-config-prettier@10.1.8(eslint@8.57.1): + dependencies: + eslint: 8.57.1 + + eslint-import-resolver-node@0.3.10: + dependencies: + debug: 3.2.7 + is-core-module: 2.17.0 + resolve: 2.0.0-next.7 + transitivePeerDependencies: + - supports-color + + eslint-module-utils@2.14.0(@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint@8.57.1): + dependencies: + debug: 3.2.7 + optionalDependencies: + '@typescript-eslint/parser': 7.18.0(eslint@8.57.1)(typescript@5.9.3) + eslint: 8.57.1 + eslint-import-resolver-node: 0.3.10 + transitivePeerDependencies: + - supports-color + + eslint-plugin-import@2.32.0(@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1): + dependencies: + '@rtsao/scc': 1.1.0 + array-includes: 3.2.0 + array.prototype.findlastindex: 1.2.6 + array.prototype.flat: 1.3.3 + array.prototype.flatmap: 1.3.3 + debug: 3.2.7 + doctrine: 2.1.0 + eslint: 8.57.1 + eslint-import-resolver-node: 0.3.10 + eslint-module-utils: 2.14.0(@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint@8.57.1) + hasown: 2.0.4 + is-core-module: 2.17.0 + is-glob: 4.0.3 + minimatch: 3.1.5 + object.fromentries: 2.0.8 + object.groupby: 1.0.3 + object.values: 1.2.1 + semver: 6.3.1 + string.prototype.trimend: 1.0.10 + tsconfig-paths: 3.15.0 + optionalDependencies: + '@typescript-eslint/parser': 7.18.0(eslint@8.57.1)(typescript@5.9.3) + transitivePeerDependencies: + - eslint-import-resolver-typescript + - eslint-import-resolver-webpack + - supports-color + + eslint-plugin-prettier@5.5.5(eslint-config-prettier@10.1.8(eslint@8.57.1))(eslint@8.57.1)(prettier@3.9.6): + dependencies: + eslint: 8.57.1 + prettier: 3.9.6 + prettier-linter-helpers: 1.0.1 + synckit: 0.11.13 + optionalDependencies: + eslint-config-prettier: 10.1.8(eslint@8.57.1) + + eslint-scope@7.2.2: + dependencies: + esrecurse: 4.3.0 + estraverse: 5.3.0 + + eslint-visitor-keys@3.4.3: {} + + eslint@8.57.1: + dependencies: + '@eslint-community/eslint-utils': 4.10.1(eslint@8.57.1) + '@eslint-community/regexpp': 4.12.2 + '@eslint/eslintrc': 2.1.4 + '@eslint/js': 8.57.1 + '@humanwhocodes/config-array': 0.13.0 + '@humanwhocodes/module-importer': 1.0.1 + '@nodelib/fs.walk': 1.2.8 + '@ungap/structured-clone': 1.4.0 + ajv: 6.15.0 + chalk: 4.1.2 + cross-spawn: 7.0.6 + debug: 4.4.3 + doctrine: 3.0.0 + escape-string-regexp: 4.0.0 + eslint-scope: 7.2.2 + eslint-visitor-keys: 3.4.3 + espree: 9.6.1 + esquery: 1.7.0 + esutils: 2.0.3 + fast-deep-equal: 3.1.3 + file-entry-cache: 6.0.1 + find-up: 5.0.0 + glob-parent: 6.0.2 + globals: 13.24.0 + graphemer: 1.4.0 + ignore: 5.3.2 + imurmurhash: 0.1.4 + is-glob: 4.0.3 + is-path-inside: 3.0.3 + js-yaml: 4.3.2 + json-stable-stringify-without-jsonify: 1.0.1 + levn: 0.4.1 + lodash.merge: 4.6.2 + minimatch: 3.1.5 + natural-compare: 1.4.0 + optionator: 0.9.4 + strip-ansi: 6.0.1 + text-table: 0.2.0 + transitivePeerDependencies: + - supports-color + + espree@9.6.1: + dependencies: + acorn: 8.18.0 + acorn-jsx: 5.3.2(acorn@8.18.0) + eslint-visitor-keys: 3.4.3 + + esquery@1.7.0: + dependencies: + estraverse: 5.3.0 + + esrecurse@4.3.0: + dependencies: + estraverse: 5.3.0 + + estraverse@5.3.0: {} + + estree-walker@3.0.3: dependencies: '@types/estree': 1.0.9 + esutils@2.0.3: {} + etag@1.8.1: {} event-target-shim@5.0.1: {} @@ -5366,6 +6631,8 @@ snapshots: fast-deep-equal@3.1.3: {} + fast-diff@1.3.0: {} + fast-glob@3.3.3: dependencies: '@nodelib/fs.stat': 2.0.5 @@ -5374,6 +6641,10 @@ snapshots: merge2: 1.4.1 micromatch: 4.0.8 + fast-json-stable-stringify@2.1.0: {} + + fast-levenshtein@2.0.6: {} + fast-safe-stringify@2.1.1: {} fast-uri@3.1.6: {} @@ -5395,6 +6666,10 @@ snapshots: node-domexception: 1.0.0 web-streams-polyfill: 3.3.3 + file-entry-cache@6.0.1: + dependencies: + flat-cache: 3.2.0 + file-uri-to-path@1.0.0: {} fill-range@7.1.1: @@ -5412,9 +6687,26 @@ snapshots: transitivePeerDependencies: - supports-color + find-up@5.0.0: + dependencies: + locate-path: 6.0.0 + path-exists: 4.0.0 + + flat-cache@3.2.0: + dependencies: + flatted: 3.4.4 + keyv: 4.5.4 + rimraf: 3.0.2 + + flatted@3.4.4: {} + follow-redirects@1.16.0: optional: true + for-each@0.3.5: + dependencies: + is-callable: 1.2.7 + form-data@4.0.6: dependencies: asynckit: 0.4.0 @@ -5439,11 +6731,27 @@ snapshots: fs-constants@1.0.0: {} + fs.realpath@1.0.0: {} + fsevents@2.3.3: optional: true function-bind@1.1.2: {} + function.prototype.name@1.2.0: + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + es-define-property: 1.0.1 + es-errors: 1.3.0 + functions-have-names: 1.2.3 + has-property-descriptors: 1.0.2 + hasown: 2.0.4 + is-callable: 1.2.7 + is-document.all: 1.0.0 + + functions-have-names@1.2.3: {} + gaxios@7.3.1: dependencies: extend: 3.0.2 @@ -5460,6 +6768,8 @@ snapshots: transitivePeerDependencies: - supports-color + generator-function@2.0.1: {} + get-caller-file@2.0.5: {} get-east-asian-width@1.6.0: {} @@ -5486,6 +6796,12 @@ snapshots: dependencies: pump: 3.0.4 + get-symbol-description@1.1.0: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + get-tsconfig@4.14.3: dependencies: resolve-pkg-maps: 1.0.0 @@ -5498,16 +6814,47 @@ snapshots: dependencies: is-glob: 4.0.3 + glob-parent@6.0.2: + dependencies: + is-glob: 4.0.3 + glob@13.0.6: dependencies: minimatch: 10.2.6 minipass: 7.1.3 path-scurry: 2.0.2 + glob@7.2.3: + dependencies: + fs.realpath: 1.0.0 + inflight: 1.0.6 + inherits: 2.0.4 + minimatch: 3.1.5 + once: 1.4.0 + path-is-absolute: 1.0.1 + global-directory@4.0.1: dependencies: ini: 4.1.1 + globals@13.24.0: + dependencies: + type-fest: 0.20.2 + + globalthis@1.0.4: + dependencies: + define-properties: 1.2.1 + gopd: 1.2.0 + + globby@11.1.0: + dependencies: + array-union: 2.1.0 + dir-glob: 3.0.1 + fast-glob: 3.3.3 + ignore: 5.3.2 + merge2: 1.4.1 + slash: 3.0.0 + google-auth-library@10.9.1: dependencies: base64-js: 1.5.1 @@ -5527,6 +6874,8 @@ snapshots: graceful-fs@4.2.11: {} + graphemer@1.4.0: {} + handlebars@4.7.9: dependencies: minimist: 1.2.8 @@ -5536,14 +6885,23 @@ snapshots: optionalDependencies: uglify-js: 3.19.3 + has-bigints@1.1.0: {} + has-flag@4.0.0: {} + has-property-descriptors@1.0.2: + dependencies: + es-define-property: 1.0.1 + + has-proto@1.2.0: + dependencies: + dunder-proto: 1.0.1 + has-symbols@1.1.0: {} has-tostringtag@1.0.2: dependencies: has-symbols: 1.1.0 - optional: true hasown@2.0.4: dependencies: @@ -5597,18 +6955,38 @@ snapshots: ieee754@1.2.1: {} + ignore@5.3.2: {} + ignore@7.0.5: {} ignore@7.0.9: {} immediate@3.0.6: {} + import-fresh@3.3.1: + dependencies: + parent-module: 1.0.1 + resolve-from: 4.0.0 + + imurmurhash@0.1.4: {} + + inflight@1.0.6: + dependencies: + once: 1.4.0 + wrappy: 1.0.2 + inherits@2.0.4: {} ini@1.3.8: {} ini@4.1.1: {} + internal-slot@1.1.0: + dependencies: + es-errors: 1.3.0 + hasown: 2.0.4 + side-channel: 1.1.1 + interpret@1.4.0: {} interpret@3.1.1: {} @@ -5617,14 +6995,66 @@ snapshots: ipaddr.js@1.9.1: {} + is-array-buffer@3.0.5: + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + get-intrinsic: 1.3.0 + + is-async-function@2.1.1: + dependencies: + async-function: 1.0.0 + call-bound: 1.0.4 + get-proto: 1.0.1 + has-tostringtag: 1.0.2 + safe-regex-test: 1.1.0 + + is-bigint@1.1.0: + dependencies: + has-bigints: 1.1.0 + + is-boolean-object@1.2.2: + dependencies: + call-bound: 1.0.4 + has-tostringtag: 1.0.2 + + is-callable@1.2.7: {} + is-core-module@2.17.0: dependencies: hasown: 2.0.4 + is-data-view@1.0.2: + dependencies: + call-bound: 1.0.4 + get-intrinsic: 1.3.0 + is-typed-array: 1.1.15 + + is-date-object@1.1.0: + dependencies: + call-bound: 1.0.4 + has-tostringtag: 1.0.2 + + is-document.all@1.0.0: + dependencies: + call-bound: 1.0.4 + is-extglob@2.1.1: {} + is-finalizationregistry@1.1.1: + dependencies: + call-bound: 1.0.4 + is-fullwidth-code-point@3.0.0: {} + is-generator-function@1.1.2: + dependencies: + call-bound: 1.0.4 + generator-function: 2.0.1 + get-proto: 1.0.1 + has-tostringtag: 1.0.2 + safe-regex-test: 1.1.0 + is-glob@4.0.3: dependencies: is-extglob: 2.1.1 @@ -5634,16 +7064,68 @@ snapshots: global-directory: 4.0.1 is-path-inside: 4.0.0 + is-map@2.0.3: {} + + is-negative-zero@2.0.3: {} + + is-number-object@1.1.1: + dependencies: + call-bound: 1.0.4 + has-tostringtag: 1.0.2 + is-number@7.0.0: {} + is-path-inside@3.0.3: {} + is-path-inside@4.0.0: {} is-promise@4.0.0: {} + is-regex@1.2.1: + dependencies: + call-bound: 1.0.4 + gopd: 1.2.0 + has-tostringtag: 1.0.2 + hasown: 2.0.4 + + is-set@2.0.3: {} + + is-shared-array-buffer@1.0.4: + dependencies: + call-bound: 1.0.4 + is-stream@1.1.0: {} + is-string@1.1.1: + dependencies: + call-bound: 1.0.4 + has-tostringtag: 1.0.2 + + is-symbol@1.1.1: + dependencies: + call-bound: 1.0.4 + has-symbols: 1.1.0 + safe-regex-test: 1.1.0 + + is-typed-array@1.1.15: + dependencies: + which-typed-array: 1.1.24 + + is-weakmap@2.0.2: {} + + is-weakref@1.1.1: + dependencies: + call-bound: 1.0.4 + + is-weakset@2.0.4: + dependencies: + call-bound: 1.0.4 + get-intrinsic: 1.3.0 + isarray@1.0.0: {} + isarray@2.0.5: {} + isexe@2.0.0: {} istanbul-lib-coverage@3.2.2: {} @@ -5712,15 +7194,25 @@ snapshots: dependencies: bignumber.js: 9.3.1 + json-buffer@3.0.1: {} + json-schema-to-ts@3.1.1: dependencies: '@babel/runtime': 7.29.7 ts-algebra: 2.0.0 + json-schema-traverse@0.4.1: {} + json-schema-traverse@1.0.0: {} json-schema-typed@8.0.2: {} + json-stable-stringify-without-jsonify@1.0.1: {} + + json5@1.0.2: + dependencies: + minimist: 1.2.8 + json5@2.2.3: {} jszip@3.10.2: @@ -5741,6 +7233,10 @@ snapshots: jwa: 2.0.1 safe-buffer: 5.2.1 + keyv@4.5.4: + dependencies: + json-buffer: 3.0.1 + kleur@3.0.3: {} knip@6.35.1: @@ -5763,15 +7259,23 @@ snapshots: dependencies: readable-stream: 2.3.8 + levn@0.4.1: + dependencies: + prelude-ls: 1.2.1 + type-check: 0.4.0 + lie@3.3.0: dependencies: immediate: 3.0.6 + locate-path@6.0.0: + dependencies: + p-locate: 5.0.0 + lodash.identity@3.0.0: optional: true - lodash.merge@4.6.2: - optional: true + lodash.merge@4.6.2: {} lodash.pickby@4.6.0: optional: true @@ -5829,6 +7333,14 @@ snapshots: dependencies: brace-expansion: 5.0.9 + minimatch@3.1.5: + dependencies: + brace-expansion: 1.1.21 + + minimatch@9.0.9: + dependencies: + brace-expansion: 2.1.7 + minimist@1.2.8: {} minipass@7.1.3: {} @@ -5851,6 +7363,8 @@ snapshots: napi-build-utils@2.0.0: {} + natural-compare@1.4.0: {} + negotiator@1.1.0: dependencies: content-type: 2.1.0 @@ -5867,6 +7381,13 @@ snapshots: node-domexception@1.0.0: {} + node-exports-info@1.6.2: + dependencies: + array.prototype.flatmap: 1.3.3 + es-errors: 1.3.0 + object.entries: 1.1.9 + semver: 6.3.1 + node-fetch@3.3.2: dependencies: data-uri-to-buffer: 4.0.1 @@ -5887,6 +7408,44 @@ snapshots: object-inspect@1.13.4: {} + object-keys@1.1.1: {} + + object.assign@4.1.7: + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-object-atoms: 1.1.2 + has-symbols: 1.1.0 + object-keys: 1.1.1 + + object.entries@1.1.9: + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-object-atoms: 1.1.2 + + object.fromentries@2.0.8: + dependencies: + call-bind: 1.0.9 + define-properties: 1.2.1 + es-abstract: 1.24.2 + es-object-atoms: 1.1.2 + + object.groupby@1.0.3: + dependencies: + call-bind: 1.0.9 + define-properties: 1.2.1 + es-abstract: 1.24.2 + + object.values@1.2.1: + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-object-atoms: 1.1.2 + obug@2.2.1: {} on-exit-leak-free@2.1.2: {} @@ -5904,6 +7463,22 @@ snapshots: ws: 8.21.3 zod: 3.25.76 + optionator@0.9.4: + dependencies: + deep-is: 0.1.4 + fast-levenshtein: 2.0.6 + levn: 0.4.1 + prelude-ls: 1.2.1 + type-check: 0.4.0 + word-wrap: 1.2.5 + + own-keys@1.0.2: + dependencies: + call-bound: 1.0.4 + get-intrinsic: 1.3.0 + object-keys: 1.1.1 + safe-push-apply: 1.0.0 + oxc-parser@0.148.0: dependencies: '@oxc-project/types': 0.148.0 @@ -5952,6 +7527,14 @@ snapshots: p-finally@1.0.0: {} + p-limit@3.1.0: + dependencies: + yocto-queue: 0.1.0 + + p-locate@5.0.0: + dependencies: + p-limit: 3.1.0 + p-retry@4.6.2: dependencies: '@types/retry': 0.12.0 @@ -5961,6 +7544,10 @@ snapshots: pako@1.0.11: {} + parent-module@1.0.1: + dependencies: + callsites: 3.1.0 + parse5-htmlparser2-tree-adapter@6.0.1: dependencies: parse5: 6.0.1 @@ -5973,6 +7560,10 @@ snapshots: partial-json@0.1.7: {} + path-exists@4.0.0: {} + + path-is-absolute@1.0.1: {} + path-key@2.0.1: {} path-key@3.1.1: {} @@ -5986,6 +7577,8 @@ snapshots: path-to-regexp@8.4.2: {} + path-type@4.0.0: {} + pathe@2.0.3: {} picocolors@1.1.1: {} @@ -6039,6 +7632,8 @@ snapshots: pngjs@6.0.0: {} + possible-typed-array-names@1.1.0: {} + postcss@8.5.28: dependencies: nanoid: 3.3.19 @@ -6060,6 +7655,12 @@ snapshots: tar-fs: 2.1.5 tunnel-agent: 0.6.0 + prelude-ls@1.2.1: {} + + prettier-linter-helpers@1.0.1: + dependencies: + fast-diff: 1.3.0 + prettier@3.9.6: {} process-nextick-args@2.0.1: {} @@ -6106,6 +7707,8 @@ snapshots: end-of-stream: 1.4.5 once: 1.4.0 + punycode@2.3.1: {} + qs@6.16.0: dependencies: es-define-property: 1.0.1 @@ -6167,12 +7770,34 @@ snapshots: dependencies: resolve: 1.22.12 + reflect.getprototypeof@1.0.10: + dependencies: + call-bind: 1.0.9 + define-properties: 1.2.1 + es-abstract: 1.24.2 + es-errors: 1.3.0 + es-object-atoms: 1.1.2 + get-intrinsic: 1.3.0 + get-proto: 1.0.1 + which-builtin-type: 1.2.1 + regexp-tree@0.1.27: {} + regexp.prototype.flags@1.5.4: + dependencies: + call-bind: 1.0.9 + define-properties: 1.2.1 + es-errors: 1.3.0 + get-proto: 1.0.1 + gopd: 1.2.0 + set-function-name: 2.0.2 + require-directory@2.1.1: {} require-from-string@2.0.2: {} + resolve-from@4.0.0: {} + resolve-pkg-maps@1.0.0: {} resolve@1.22.12: @@ -6182,12 +7807,25 @@ snapshots: path-parse: 1.0.7 supports-preserve-symlinks-flag: 1.0.0 + resolve@2.0.0-next.7: + dependencies: + es-errors: 1.3.0 + is-core-module: 2.17.0 + node-exports-info: 1.6.2 + object-keys: 1.1.1 + path-parse: 1.0.7 + supports-preserve-symlinks-flag: 1.0.0 + retry@0.12.0: {} retry@0.13.1: {} reusify@1.1.0: {} + rimraf@3.0.2: + dependencies: + glob: 7.2.3 + rollup@4.63.1: dependencies: '@types/estree': 1.0.9 @@ -6234,10 +7872,29 @@ snapshots: dependencies: queue-microtask: 1.2.3 + safe-array-concat@1.1.4: + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + get-intrinsic: 1.3.0 + has-symbols: 1.1.0 + isarray: 2.0.5 + safe-buffer@5.1.2: {} safe-buffer@5.2.1: {} + safe-push-apply@1.0.0: + dependencies: + es-errors: 1.3.0 + isarray: 2.0.5 + + safe-regex-test@1.1.0: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-regex: 1.2.1 + safe-regex@2.1.1: dependencies: regexp-tree: 0.1.27 @@ -6254,6 +7911,8 @@ snapshots: semver@5.7.2: {} + semver@6.3.1: {} + semver@7.8.5: {} send@1.2.1: @@ -6281,6 +7940,28 @@ snapshots: transitivePeerDependencies: - supports-color + set-function-length@1.2.2: + dependencies: + define-data-property: 1.1.4 + es-errors: 1.3.0 + function-bind: 1.1.2 + get-intrinsic: 1.3.0 + gopd: 1.2.0 + has-property-descriptors: 1.0.2 + + set-function-name@2.0.2: + dependencies: + define-data-property: 1.1.4 + es-errors: 1.3.0 + functions-have-names: 1.2.3 + has-property-descriptors: 1.0.2 + + set-proto@1.0.0: + dependencies: + dunder-proto: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.2 + setimmediate@1.0.5: {} setprototypeof@1.2.0: {} @@ -6353,6 +8034,8 @@ snapshots: sisteransi@1.0.5: {} + slash@3.0.0: {} + smol-toml@1.7.1: {} smol-toml@1.8.0: {} @@ -6373,12 +8056,41 @@ snapshots: std-env@4.2.0: {} + stop-iteration-iterator@1.1.0: + dependencies: + es-errors: 1.3.0 + internal-slot: 1.1.0 + string-width@4.2.3: dependencies: emoji-regex: 8.0.0 is-fullwidth-code-point: 3.0.0 strip-ansi: 6.0.1 + string.prototype.trim@1.2.11: + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + define-data-property: 1.1.4 + define-properties: 1.2.1 + es-abstract: 1.24.2 + es-object-atoms: 1.1.2 + has-property-descriptors: 1.0.2 + safe-regex-test: 1.1.0 + + string.prototype.trimend@1.0.10: + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-object-atoms: 1.1.2 + + string.prototype.trimstart@1.0.8: + dependencies: + call-bind: 1.0.9 + define-properties: 1.2.1 + es-object-atoms: 1.1.2 + string_decoder@1.1.1: dependencies: safe-buffer: 5.1.2 @@ -6407,6 +8119,10 @@ snapshots: supports-preserve-symlinks-flag@1.0.0: {} + synckit@0.11.13: + dependencies: + '@pkgr/core': 0.3.6 + tapable@2.3.3: {} tar-fs@2.1.5: @@ -6432,6 +8148,8 @@ snapshots: minizlib: 3.1.0 yallist: 5.0.0 + text-table@0.2.0: {} + thenify-all@1.6.0: dependencies: thenify: 3.3.1 @@ -6463,6 +8181,10 @@ snapshots: ts-algebra@2.0.0: {} + ts-api-utils@1.4.3(typescript@5.9.3): + dependencies: + typescript: 5.9.3 + tsconfig-paths-webpack-plugin@4.2.0: dependencies: chalk: 4.1.2 @@ -6470,6 +8192,13 @@ snapshots: tapable: 2.3.3 tsconfig-paths: 4.2.0 + tsconfig-paths@3.15.0: + dependencies: + '@types/json5': 0.0.29 + json5: 1.0.2 + minimist: 1.2.8 + strip-bom: 3.0.0 + tsconfig-paths@4.2.0: dependencies: json5: 2.2.3 @@ -6488,6 +8217,12 @@ snapshots: dependencies: safe-buffer: 5.2.1 + type-check@0.4.0: + dependencies: + prelude-ls: 1.2.1 + + type-fest@0.20.2: {} + type-is@2.1.0: dependencies: content-type: 2.1.0 @@ -6496,6 +8231,38 @@ snapshots: typebox@1.1.38: {} + typed-array-buffer@1.0.3: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-typed-array: 1.1.15 + + typed-array-byte-length@1.0.3: + dependencies: + call-bind: 1.0.9 + for-each: 0.3.5 + gopd: 1.2.0 + has-proto: 1.2.0 + is-typed-array: 1.1.15 + + typed-array-byte-offset@1.0.5: + dependencies: + available-typed-arrays: 1.0.7 + call-bind: 1.0.9 + for-each: 0.3.5 + gopd: 1.2.0 + is-typed-array: 1.1.15 + reflect.getprototypeof: 1.0.10 + + typed-array-length@1.0.8: + dependencies: + call-bind: 1.0.9 + for-each: 0.3.5 + gopd: 1.2.0 + is-typed-array: 1.1.15 + possible-typed-array-names: 1.1.0 + reflect.getprototypeof: 1.0.10 + typescript@5.9.3: {} uglify-js@3.19.3: @@ -6503,6 +8270,13 @@ snapshots: unbash@4.0.11: {} + unbox-primitive@1.1.0: + dependencies: + call-bound: 1.0.4 + has-bigints: 1.1.0 + has-symbols: 1.1.0 + which-boxed-primitive: 1.1.1 + undici-types@6.21.0: {} undici-types@7.16.0: {} @@ -6513,6 +8287,10 @@ snapshots: unpipe@1.0.0: {} + uri-js@4.4.1: + dependencies: + punycode: 2.3.1 + util-deprecate@1.0.2: {} vary@1.1.2: {} @@ -6611,6 +8389,47 @@ snapshots: web-streams-polyfill@3.3.3: {} + which-boxed-primitive@1.1.1: + dependencies: + is-bigint: 1.1.0 + is-boolean-object: 1.2.2 + is-number-object: 1.1.1 + is-string: 1.1.1 + is-symbol: 1.1.1 + + which-builtin-type@1.2.1: + dependencies: + call-bound: 1.0.4 + function.prototype.name: 1.2.0 + has-tostringtag: 1.0.2 + is-async-function: 2.1.1 + is-date-object: 1.1.0 + is-finalizationregistry: 1.1.1 + is-generator-function: 1.1.2 + is-regex: 1.2.1 + is-weakref: 1.1.1 + isarray: 2.0.5 + which-boxed-primitive: 1.1.1 + which-collection: 1.0.2 + which-typed-array: 1.1.24 + + which-collection@1.0.2: + dependencies: + is-map: 2.0.3 + is-set: 2.0.3 + is-weakmap: 2.0.2 + is-weakset: 2.0.4 + + which-typed-array@1.1.24: + dependencies: + available-typed-arrays: 1.0.7 + call-bind: 1.0.9 + call-bound: 1.0.4 + for-each: 0.3.5 + get-proto: 1.0.1 + gopd: 1.2.0 + has-tostringtag: 1.0.2 + which@1.3.1: dependencies: isexe: 2.0.0 @@ -6624,6 +8443,8 @@ snapshots: siginfo: 2.0.0 stackback: 0.0.2 + word-wrap@1.2.5: {} + wordwrap@1.0.0: {} wrap-ansi@7.0.0: @@ -6656,6 +8477,8 @@ snapshots: y18n: 5.0.8 yargs-parser: 20.2.9 + yocto-queue@0.1.0: {} + zod-to-json-schema@3.25.2(zod@3.25.76): dependencies: zod: 3.25.76 diff --git a/release/dependency-licenses.json b/release/dependency-licenses.json index 854c5200..95f6e0f9 100644 --- a/release/dependency-licenses.json +++ b/release/dependency-licenses.json @@ -266,6 +266,38 @@ "license": "MIT", "homepage": "https://github.com/evanw/esbuild#readme" }, + { + "name": "@eslint-community/eslint-utils", + "versions": [ + "4.10.1" + ], + "license": "MIT", + "homepage": "https://github.com/eslint-community/eslint-utils#readme" + }, + { + "name": "@eslint-community/regexpp", + "versions": [ + "4.12.2" + ], + "license": "MIT", + "homepage": "https://github.com/eslint-community/regexpp#readme" + }, + { + "name": "@eslint/eslintrc", + "versions": [ + "2.1.4" + ], + "license": "MIT", + "homepage": "https://github.com/eslint/eslintrc#readme" + }, + { + "name": "@eslint/js", + "versions": [ + "8.57.1" + ], + "license": "MIT", + "homepage": "https://eslint.org" + }, { "name": "@google/genai", "versions": [ @@ -282,6 +314,30 @@ "license": "MIT", "homepage": "https://github.com/honojs/node-server" }, + { + "name": "@humanwhocodes/config-array", + "versions": [ + "0.13.0" + ], + "license": "Apache-2.0", + "homepage": "https://github.com/humanwhocodes/config-array#readme" + }, + { + "name": "@humanwhocodes/module-importer", + "versions": [ + "1.0.1" + ], + "license": "Apache-2.0", + "homepage": "https://github.com/humanwhocodes/module-importer#readme" + }, + { + "name": "@humanwhocodes/object-schema", + "versions": [ + "2.0.3" + ], + "license": "BSD-3-Clause", + "homepage": "https://github.com/humanwhocodes/object-schema#readme" + }, { "name": "@isaacs/fs-minipass", "versions": [ @@ -434,6 +490,14 @@ "license": "MIT", "homepage": "https://github.com/pinojs/redact#readme" }, + { + "name": "@pkgr/core", + "versions": [ + "0.3.6" + ], + "license": "MIT", + "homepage": "https://github.com/un-ts/pkgr/blob/master/packages/core" + }, { "name": "@pondwader/socks5-server", "versions": [ @@ -522,6 +586,14 @@ "license": "MIT", "homepage": "https://rollupjs.org/" }, + { + "name": "@rtsao/scc", + "versions": [ + "1.1.0" + ], + "license": "MIT", + "homepage": "https://github.com/rtsao/scc#readme" + }, { "name": "@silvia-odwyer/photon-node", "versions": [ @@ -675,6 +747,14 @@ "license": "MIT", "homepage": "https://github.com/DefinitelyTyped/DefinitelyTyped/tree/master/types/js-yaml" }, + { + "name": "@types/json5", + "versions": [ + "0.0.29" + ], + "license": "MIT", + "homepage": "https://github.com/DefinitelyTyped/DefinitelyTyped#readme" + }, { "name": "@types/lazystream", "versions": [ @@ -757,6 +837,70 @@ "license": "MIT", "homepage": "https://github.com/DefinitelyTyped/DefinitelyTyped/tree/master/types/ws" }, + { + "name": "@typescript-eslint/eslint-plugin", + "versions": [ + "7.18.0" + ], + "license": "MIT", + "homepage": "https://typescript-eslint.io/packages/eslint-plugin" + }, + { + "name": "@typescript-eslint/parser", + "versions": [ + "7.18.0" + ], + "license": "BSD-2-Clause", + "homepage": "https://typescript-eslint.io/packages/parser" + }, + { + "name": "@typescript-eslint/scope-manager", + "versions": [ + "7.18.0" + ], + "license": "MIT", + "homepage": "https://typescript-eslint.io/packages/scope-manager" + }, + { + "name": "@typescript-eslint/type-utils", + "versions": [ + "7.18.0" + ], + "license": "MIT", + "homepage": "https://typescript-eslint.io" + }, + { + "name": "@typescript-eslint/types", + "versions": [ + "7.18.0" + ], + "license": "MIT", + "homepage": "https://typescript-eslint.io" + }, + { + "name": "@typescript-eslint/typescript-estree", + "versions": [ + "7.18.0" + ], + "license": "BSD-2-Clause", + "homepage": "https://typescript-eslint.io/packages/typescript-estree" + }, + { + "name": "@typescript-eslint/utils", + "versions": [ + "7.18.0" + ], + "license": "MIT", + "homepage": "https://typescript-eslint.io/packages/utils" + }, + { + "name": "@typescript-eslint/visitor-keys", + "versions": [ + "7.18.0" + ], + "license": "MIT", + "homepage": "https://typescript-eslint.io" + }, { "name": "@typescript/native-preview", "versions": [ @@ -773,6 +917,14 @@ "license": "Apache-2.0", "homepage": "https://www.typescriptlang.org/" }, + { + "name": "@ungap/structured-clone", + "versions": [ + "1.4.0" + ], + "license": "ISC", + "homepage": "https://github.com/ungap/structured-clone#readme" + }, { "name": "@vitest/coverage-v8", "versions": [ @@ -929,6 +1081,7 @@ { "name": "ajv", "versions": [ + "6.15.0", "8.18.0" ], "license": "MIT", @@ -974,6 +1127,62 @@ "license": "Python-2.0", "homepage": "https://github.com/nodeca/argparse#readme" }, + { + "name": "array-buffer-byte-length", + "versions": [ + "1.0.2" + ], + "license": "MIT", + "homepage": "https://github.com/inspect-js/array-buffer-byte-length#readme" + }, + { + "name": "array-includes", + "versions": [ + "3.2.0" + ], + "license": "MIT", + "homepage": "https://github.com/es-shims/array-includes#readme" + }, + { + "name": "array-union", + "versions": [ + "2.1.0" + ], + "license": "MIT", + "homepage": "https://github.com/sindresorhus/array-union#readme" + }, + { + "name": "array.prototype.findlastindex", + "versions": [ + "1.2.6" + ], + "license": "MIT", + "homepage": "https://github.com/es-shims/Array.prototype.findLastIndex#readme" + }, + { + "name": "array.prototype.flat", + "versions": [ + "1.3.3" + ], + "license": "MIT", + "homepage": "https://github.com/es-shims/Array.prototype.flat#readme" + }, + { + "name": "array.prototype.flatmap", + "versions": [ + "1.3.3" + ], + "license": "MIT", + "homepage": "https://github.com/es-shims/Array.prototype.flatMap#readme" + }, + { + "name": "arraybuffer.prototype.slice", + "versions": [ + "1.0.4" + ], + "license": "MIT", + "homepage": "https://github.com/es-shims/ArrayBuffer.prototype.slice#readme" + }, { "name": "assertion-error", "versions": [ @@ -990,6 +1199,14 @@ "license": "MIT", "homepage": "https://github.com/AriPerkkio/ast-v8-to-istanbul" }, + { + "name": "async-function", + "versions": [ + "1.0.0" + ], + "license": "MIT", + "homepage": "https://github.com/ljharb/async-function#readme" + }, { "name": "asynckit", "versions": [ @@ -1006,6 +1223,14 @@ "license": "MIT", "homepage": "https://github.com/davidmarkclements/atomic-sleep#readme" }, + { + "name": "available-typed-arrays", + "versions": [ + "1.0.7" + ], + "license": "MIT", + "homepage": "https://github.com/inspect-js/available-typed-arrays#readme" + }, { "name": "axios", "versions": [ @@ -1017,6 +1242,7 @@ { "name": "balanced-match", "versions": [ + "1.0.2", "4.0.4" ], "license": "MIT", @@ -1089,6 +1315,8 @@ { "name": "brace-expansion", "versions": [ + "1.1.21", + "2.1.7", "5.0.9" ], "license": "MIT", @@ -1127,6 +1355,14 @@ "license": "MIT", "homepage": "https://github.com/visionmedia/bytes.js#readme" }, + { + "name": "call-bind", + "versions": [ + "1.0.9" + ], + "license": "MIT", + "homepage": "https://github.com/ljharb/call-bind#readme" + }, { "name": "call-bind-apply-helpers", "versions": [ @@ -1143,6 +1379,14 @@ "license": "MIT", "homepage": "https://github.com/ljharb/call-bound#readme" }, + { + "name": "callsites", + "versions": [ + "3.1.0" + ], + "license": "MIT", + "homepage": "https://github.com/sindresorhus/callsites#readme" + }, { "name": "canvas", "versions": [ @@ -1241,6 +1485,22 @@ "license": "MIT", "homepage": "https://github.com/tj/commander.js#readme" }, + { + "name": "concat-map", + "versions": [ + "0.0.1" + ], + "license": "MIT", + "homepage": "https://github.com/substack/node-concat-map#readme" + }, + { + "name": "confusing-browser-globals", + "versions": [ + "1.0.11" + ], + "license": "MIT", + "homepage": "https://github.com/facebook/create-react-app#readme" + }, { "name": "content-disposition", "versions": [ @@ -1323,6 +1583,30 @@ "license": "MIT", "homepage": "https://github.com/TooTallNate/node-data-uri-to-buffer" }, + { + "name": "data-view-buffer", + "versions": [ + "1.0.2" + ], + "license": "MIT", + "homepage": "https://github.com/inspect-js/data-view-buffer#readme" + }, + { + "name": "data-view-byte-length", + "versions": [ + "1.0.2" + ], + "license": "MIT", + "homepage": "https://github.com/inspect-js/data-view-byte-length#readme" + }, + { + "name": "data-view-byte-offset", + "versions": [ + "1.0.1" + ], + "license": "MIT", + "homepage": "https://github.com/inspect-js/data-view-byte-offset#readme" + }, { "name": "dateformat", "versions": [ @@ -1334,6 +1618,7 @@ { "name": "debug", "versions": [ + "3.2.7", "4.4.3" ], "license": "MIT", @@ -1355,6 +1640,30 @@ "license": "MIT", "homepage": "https://github.com/unclechu/node-deep-extend" }, + { + "name": "deep-is", + "versions": [ + "0.1.4" + ], + "license": "MIT", + "homepage": "https://github.com/thlorenz/deep-is#readme" + }, + { + "name": "define-data-property", + "versions": [ + "1.1.4" + ], + "license": "MIT", + "homepage": "https://github.com/ljharb/define-data-property#readme" + }, + { + "name": "define-properties", + "versions": [ + "1.2.1" + ], + "license": "MIT", + "homepage": "https://github.com/ljharb/define-properties#readme" + }, { "name": "delayed-stream", "versions": [ @@ -1374,7 +1683,7 @@ { "name": "dependency-cruiser", "versions": [ - "18.2.0" + "18.3.1" ], "license": "MIT", "homepage": "https://github.com/sverweij/dependency-cruiser" @@ -1395,6 +1704,23 @@ "license": "BSD-3-Clause", "homepage": "https://github.com/kpdecker/jsdiff#readme" }, + { + "name": "dir-glob", + "versions": [ + "3.0.1" + ], + "license": "MIT", + "homepage": "https://github.com/kevva/dir-glob#readme" + }, + { + "name": "doctrine", + "versions": [ + "2.1.0", + "3.0.0" + ], + "license": "Apache-2.0", + "homepage": "https://github.com/eslint/doctrine" + }, { "name": "drizzle-orm", "versions": [ @@ -1454,11 +1780,27 @@ { "name": "enhanced-resolve", "versions": [ - "5.24.5" + "5.25.1" ], "license": "MIT", "homepage": "https://github.com/webpack/enhanced-resolve#readme" }, + { + "name": "es-abstract", + "versions": [ + "1.24.2" + ], + "license": "MIT", + "homepage": "https://github.com/ljharb/es-abstract#readme" + }, + { + "name": "es-abstract-get", + "versions": [ + "1.0.0" + ], + "license": "MIT", + "homepage": "https://github.com/ljharb/es-abstract-get#readme" + }, { "name": "es-define-property", "versions": [ @@ -1499,6 +1841,22 @@ "license": "MIT", "homepage": "https://github.com/es-shims/es-set-tostringtag#readme" }, + { + "name": "es-shim-unscopables", + "versions": [ + "1.1.0" + ], + "license": "MIT", + "homepage": "https://github.com/ljharb/es-shim-unscopables#readme" + }, + { + "name": "es-to-primitive", + "versions": [ + "1.3.4" + ], + "license": "MIT", + "homepage": "https://github.com/ljharb/es-to-primitive#readme" + }, { "name": "esbuild", "versions": [ @@ -1524,31 +1882,151 @@ "homepage": "https://github.com/component/escape-html#readme" }, { - "name": "estree-walker", + "name": "escape-string-regexp", "versions": [ - "3.0.3" + "4.0.0" ], "license": "MIT", - "homepage": "https://github.com/Rich-Harris/estree-walker#readme" + "homepage": "https://github.com/sindresorhus/escape-string-regexp#readme" }, { - "name": "etag", + "name": "eslint", "versions": [ - "1.8.1" + "8.57.1" ], "license": "MIT", - "homepage": "https://github.com/jshttp/etag#readme" + "homepage": "https://eslint.org" }, { - "name": "event-target-shim", + "name": "eslint-config-airbnb-base", "versions": [ - "5.0.1" + "15.0.0" ], "license": "MIT", - "homepage": "https://github.com/mysticatea/event-target-shim" + "homepage": "https://github.com/airbnb/javascript" }, { - "name": "events", + "name": "eslint-config-prettier", + "versions": [ + "10.1.8" + ], + "license": "MIT", + "homepage": "https://github.com/prettier/eslint-config-prettier#readme" + }, + { + "name": "eslint-import-resolver-node", + "versions": [ + "0.3.10" + ], + "license": "MIT", + "homepage": "https://github.com/import-js/eslint-plugin-import" + }, + { + "name": "eslint-module-utils", + "versions": [ + "2.14.0" + ], + "license": "MIT", + "homepage": "https://github.com/import-js/eslint-plugin-import#readme" + }, + { + "name": "eslint-plugin-import", + "versions": [ + "2.32.0" + ], + "license": "MIT", + "homepage": "https://github.com/import-js/eslint-plugin-import" + }, + { + "name": "eslint-plugin-prettier", + "versions": [ + "5.5.5" + ], + "license": "MIT", + "homepage": "https://github.com/prettier/eslint-plugin-prettier#readme" + }, + { + "name": "eslint-scope", + "versions": [ + "7.2.2" + ], + "license": "BSD-2-Clause", + "homepage": "http://github.com/eslint/eslint-scope" + }, + { + "name": "eslint-visitor-keys", + "versions": [ + "3.4.3" + ], + "license": "Apache-2.0", + "homepage": "https://github.com/eslint/eslint-visitor-keys#readme" + }, + { + "name": "espree", + "versions": [ + "9.6.1" + ], + "license": "BSD-2-Clause", + "homepage": "https://github.com/eslint/espree" + }, + { + "name": "esquery", + "versions": [ + "1.7.0" + ], + "license": "BSD-3-Clause", + "homepage": "https://github.com/estools/esquery/" + }, + { + "name": "esrecurse", + "versions": [ + "4.3.0" + ], + "license": "BSD-2-Clause", + "homepage": "https://github.com/estools/esrecurse" + }, + { + "name": "estraverse", + "versions": [ + "5.3.0" + ], + "license": "BSD-2-Clause", + "homepage": "https://github.com/estools/estraverse" + }, + { + "name": "estree-walker", + "versions": [ + "3.0.3" + ], + "license": "MIT", + "homepage": "https://github.com/Rich-Harris/estree-walker#readme" + }, + { + "name": "esutils", + "versions": [ + "2.0.3" + ], + "license": "BSD-2-Clause", + "homepage": "https://github.com/estools/esutils" + }, + { + "name": "etag", + "versions": [ + "1.8.1" + ], + "license": "MIT", + "homepage": "https://github.com/jshttp/etag#readme" + }, + { + "name": "event-target-shim", + "versions": [ + "5.0.1" + ], + "license": "MIT", + "homepage": "https://github.com/mysticatea/event-target-shim" + }, + { + "name": "events", "versions": [ "3.3.0" ], @@ -1635,6 +2113,14 @@ "license": "MIT", "homepage": "https://github.com/epoberezkin/fast-deep-equal#readme" }, + { + "name": "fast-diff", + "versions": [ + "1.3.0" + ], + "license": "Apache-2.0", + "homepage": "https://github.com/jhchen/fast-diff#readme" + }, { "name": "fast-glob", "versions": [ @@ -1643,6 +2129,22 @@ "license": "MIT", "homepage": "https://github.com/mrmlnc/fast-glob#readme" }, + { + "name": "fast-json-stable-stringify", + "versions": [ + "2.1.0" + ], + "license": "MIT", + "homepage": "https://github.com/epoberezkin/fast-json-stable-stringify" + }, + { + "name": "fast-levenshtein", + "versions": [ + "2.0.6" + ], + "license": "MIT", + "homepage": "https://github.com/hiddentao/fast-levenshtein#readme" + }, { "name": "fast-safe-stringify", "versions": [ @@ -1691,6 +2193,14 @@ "license": "MIT", "homepage": "https://github.com/node-fetch/fetch-blob#readme" }, + { + "name": "file-entry-cache", + "versions": [ + "6.0.1" + ], + "license": "MIT", + "homepage": "https://github.com/royriojas/file-entry-cache#readme" + }, { "name": "file-uri-to-path", "versions": [ @@ -1715,6 +2225,30 @@ "license": "MIT", "homepage": "https://github.com/pillarjs/finalhandler#readme" }, + { + "name": "find-up", + "versions": [ + "5.0.0" + ], + "license": "MIT", + "homepage": "https://github.com/sindresorhus/find-up#readme" + }, + { + "name": "flat-cache", + "versions": [ + "3.2.0" + ], + "license": "MIT", + "homepage": "https://github.com/jaredwray/flat-cache#readme" + }, + { + "name": "flatted", + "versions": [ + "3.4.4" + ], + "license": "ISC", + "homepage": "https://github.com/WebReflection/flatted#readme" + }, { "name": "follow-redirects", "versions": [ @@ -1723,6 +2257,14 @@ "license": "MIT", "homepage": "https://github.com/follow-redirects/follow-redirects" }, + { + "name": "for-each", + "versions": [ + "0.3.5" + ], + "license": "MIT", + "homepage": "https://github.com/Raynos/for-each" + }, { "name": "form-data", "versions": [ @@ -1771,6 +2313,14 @@ "license": "MIT", "homepage": "https://github.com/mafintosh/fs-constants" }, + { + "name": "fs.realpath", + "versions": [ + "1.0.0" + ], + "license": "ISC", + "homepage": "https://github.com/isaacs/fs.realpath#readme" + }, { "name": "fsevents", "versions": [ @@ -1787,6 +2337,22 @@ "license": "MIT", "homepage": "https://github.com/Raynos/function-bind" }, + { + "name": "function.prototype.name", + "versions": [ + "1.2.0" + ], + "license": "MIT", + "homepage": "https://github.com/es-shims/Function.prototype.name#readme" + }, + { + "name": "functions-have-names", + "versions": [ + "1.2.3" + ], + "license": "MIT", + "homepage": "https://github.com/inspect-js/functions-have-names#readme" + }, { "name": "gaxios", "versions": [ @@ -1803,6 +2369,14 @@ "license": "Apache-2.0", "homepage": "https://github.com/googleapis/google-cloud-node-core/tree/main/packages/gcp-metadata" }, + { + "name": "generator-function", + "versions": [ + "2.0.1" + ], + "license": "MIT", + "homepage": "https://github.com/TimothyGu/generator-function#readme" + }, { "name": "get-caller-file", "versions": [ @@ -1843,6 +2417,14 @@ "license": "MIT", "homepage": "https://github.com/sindresorhus/get-stream#readme" }, + { + "name": "get-symbol-description", + "versions": [ + "1.1.0" + ], + "license": "MIT", + "homepage": "https://github.com/inspect-js/get-symbol-description#readme" + }, { "name": "get-tsconfig", "versions": [ @@ -1867,6 +2449,14 @@ "license": "MIT", "homepage": "https://github.com/substack/github-from-package" }, + { + "name": "glob", + "versions": [ + "7.2.3" + ], + "license": "ISC", + "homepage": "https://github.com/isaacs/node-glob#readme" + }, { "name": "glob", "versions": [ @@ -1878,7 +2468,8 @@ { "name": "glob-parent", "versions": [ - "5.1.2" + "5.1.2", + "6.0.2" ], "license": "ISC", "homepage": "https://github.com/gulpjs/glob-parent#readme" @@ -1891,6 +2482,30 @@ "license": "MIT", "homepage": "https://github.com/sindresorhus/global-directory#readme" }, + { + "name": "globals", + "versions": [ + "13.24.0" + ], + "license": "MIT", + "homepage": "https://github.com/sindresorhus/globals#readme" + }, + { + "name": "globalthis", + "versions": [ + "1.0.4" + ], + "license": "MIT", + "homepage": "https://github.com/ljharb/System.global#readme" + }, + { + "name": "globby", + "versions": [ + "11.1.0" + ], + "license": "MIT", + "homepage": "https://github.com/sindresorhus/globby#readme" + }, { "name": "google-auth-library", "versions": [ @@ -1931,6 +2546,14 @@ "license": "ISC", "homepage": "https://github.com/isaacs/node-graceful-fs#readme" }, + { + "name": "graphemer", + "versions": [ + "1.4.0" + ], + "license": "MIT", + "homepage": "https://github.com/flmnt/graphemer" + }, { "name": "handlebars", "versions": [ @@ -1939,6 +2562,14 @@ "license": "MIT", "homepage": "https://handlebarsjs.com/" }, + { + "name": "has-bigints", + "versions": [ + "1.1.0" + ], + "license": "MIT", + "homepage": "https://github.com/ljharb/has-bigints#readme" + }, { "name": "has-flag", "versions": [ @@ -1947,6 +2578,22 @@ "license": "MIT", "homepage": "https://github.com/sindresorhus/has-flag#readme" }, + { + "name": "has-property-descriptors", + "versions": [ + "1.0.2" + ], + "license": "MIT", + "homepage": "https://github.com/inspect-js/has-property-descriptors#readme" + }, + { + "name": "has-proto", + "versions": [ + "1.2.0" + ], + "license": "MIT", + "homepage": "https://github.com/inspect-js/has-proto#readme" + }, { "name": "has-symbols", "versions": [ @@ -2055,8 +2702,9 @@ { "name": "ignore", "versions": [ + "5.3.2", "7.0.5", - "7.0.6" + "7.0.9" ], "license": "MIT", "homepage": "https://github.com/kaelzhang/node-ignore#readme" @@ -2069,6 +2717,30 @@ "license": "MIT", "homepage": "https://github.com/calvinmetcalf/immediate#readme" }, + { + "name": "import-fresh", + "versions": [ + "3.3.1" + ], + "license": "MIT", + "homepage": "https://github.com/sindresorhus/import-fresh#readme" + }, + { + "name": "imurmurhash", + "versions": [ + "0.1.4" + ], + "license": "MIT", + "homepage": "https://github.com/jensyt/imurmurhash-js" + }, + { + "name": "inflight", + "versions": [ + "1.0.6" + ], + "license": "ISC", + "homepage": "https://github.com/isaacs/inflight" + }, { "name": "inherits", "versions": [ @@ -2086,6 +2758,14 @@ "license": "ISC", "homepage": "https://github.com/npm/ini#readme" }, + { + "name": "internal-slot", + "versions": [ + "1.1.0" + ], + "license": "MIT", + "homepage": "https://github.com/ljharb/internal-slot#readme" + }, { "name": "interpret", "versions": [ @@ -2111,14 +2791,78 @@ "license": "MIT", "homepage": "https://github.com/whitequark/ipaddr.js#readme" }, + { + "name": "is-array-buffer", + "versions": [ + "3.0.5" + ], + "license": "MIT", + "homepage": "https://github.com/inspect-js/is-array-buffer#readme" + }, + { + "name": "is-async-function", + "versions": [ + "2.1.1" + ], + "license": "MIT", + "homepage": "https://github.com/inspect-js/is-async-function#readme" + }, + { + "name": "is-bigint", + "versions": [ + "1.1.0" + ], + "license": "MIT", + "homepage": "https://github.com/inspect-js/is-bigint#readme" + }, + { + "name": "is-boolean-object", + "versions": [ + "1.2.2" + ], + "license": "MIT", + "homepage": "https://github.com/inspect-js/is-boolean-object#readme" + }, + { + "name": "is-callable", + "versions": [ + "1.2.7" + ], + "license": "MIT", + "homepage": "https://github.com/inspect-js/is-callable#readme" + }, { "name": "is-core-module", "versions": [ - "2.16.2" + "2.17.0" ], "license": "MIT", "homepage": "https://github.com/inspect-js/is-core-module" }, + { + "name": "is-data-view", + "versions": [ + "1.0.2" + ], + "license": "MIT", + "homepage": "https://github.com/inspect-js/is-data-view#readme" + }, + { + "name": "is-date-object", + "versions": [ + "1.1.0" + ], + "license": "MIT", + "homepage": "https://github.com/inspect-js/is-date-object#readme" + }, + { + "name": "is-document.all", + "versions": [ + "1.0.0" + ], + "license": "MIT", + "homepage": "https://github.com/inspect-js/is-document.all#readme" + }, { "name": "is-extglob", "versions": [ @@ -2127,6 +2871,14 @@ "license": "MIT", "homepage": "https://github.com/jonschlinkert/is-extglob" }, + { + "name": "is-finalizationregistry", + "versions": [ + "1.1.1" + ], + "license": "MIT", + "homepage": "https://github.com/inspect-js/is-finalizationregistry#readme" + }, { "name": "is-fullwidth-code-point", "versions": [ @@ -2135,6 +2887,14 @@ "license": "MIT", "homepage": "https://github.com/sindresorhus/is-fullwidth-code-point#readme" }, + { + "name": "is-generator-function", + "versions": [ + "1.1.2" + ], + "license": "MIT", + "homepage": "https://github.com/inspect-js/is-generator-function#readme" + }, { "name": "is-glob", "versions": [ @@ -2151,6 +2911,22 @@ "license": "MIT", "homepage": "https://github.com/sindresorhus/is-installed-globally#readme" }, + { + "name": "is-map", + "versions": [ + "2.0.3" + ], + "license": "MIT", + "homepage": "https://github.com/inspect-js/is-map#readme" + }, + { + "name": "is-negative-zero", + "versions": [ + "2.0.3" + ], + "license": "MIT", + "homepage": "https://github.com/inspect-js/is-negative-zero" + }, { "name": "is-number", "versions": [ @@ -2159,9 +2935,18 @@ "license": "MIT", "homepage": "https://github.com/jonschlinkert/is-number" }, + { + "name": "is-number-object", + "versions": [ + "1.1.1" + ], + "license": "MIT", + "homepage": "https://github.com/inspect-js/is-number-object#readme" + }, { "name": "is-path-inside", "versions": [ + "3.0.3", "4.0.0" ], "license": "MIT", @@ -2175,6 +2960,30 @@ "license": "MIT", "homepage": "https://github.com/then/is-promise#readme" }, + { + "name": "is-regex", + "versions": [ + "1.2.1" + ], + "license": "MIT", + "homepage": "https://github.com/inspect-js/is-regex" + }, + { + "name": "is-set", + "versions": [ + "2.0.3" + ], + "license": "MIT", + "homepage": "https://github.com/inspect-js/is-set#readme" + }, + { + "name": "is-shared-array-buffer", + "versions": [ + "1.0.4" + ], + "license": "MIT", + "homepage": "https://github.com/inspect-js/is-shared-array-buffer#readme" + }, { "name": "is-stream", "versions": [ @@ -2183,10 +2992,59 @@ "license": "MIT", "homepage": "https://github.com/sindresorhus/is-stream#readme" }, + { + "name": "is-string", + "versions": [ + "1.1.1" + ], + "license": "MIT", + "homepage": "https://github.com/inspect-js/is-string#readme" + }, + { + "name": "is-symbol", + "versions": [ + "1.1.1" + ], + "license": "MIT", + "homepage": "https://github.com/inspect-js/is-symbol#readme" + }, + { + "name": "is-typed-array", + "versions": [ + "1.1.15" + ], + "license": "MIT", + "homepage": "https://github.com/inspect-js/is-typed-array#readme" + }, + { + "name": "is-weakmap", + "versions": [ + "2.0.2" + ], + "license": "MIT", + "homepage": "https://github.com/inspect-js/is-weakmap#readme" + }, + { + "name": "is-weakref", + "versions": [ + "1.1.1" + ], + "license": "MIT", + "homepage": "https://github.com/inspect-js/is-weakref#readme" + }, + { + "name": "is-weakset", + "versions": [ + "2.0.4" + ], + "license": "MIT", + "homepage": "https://github.com/inspect-js/is-weakset#readme" + }, { "name": "isarray", "versions": [ - "1.0.0" + "1.0.0", + "2.0.5" ], "license": "MIT", "homepage": "https://github.com/juliangruber/isarray" @@ -2274,7 +3132,7 @@ { "name": "jscpd", "versions": [ - "5.2.0" + "5.2.1" ], "license": "MIT", "homepage": "https://jscpd.dev" @@ -2282,7 +3140,7 @@ { "name": "jscpd-darwin-arm64", "versions": [ - "5.2.0" + "5.2.1" ], "license": "MIT", "homepage": "https://github.com/kucherenko/jscpd#readme" @@ -2295,6 +3153,14 @@ "license": "MIT", "homepage": "https://github.com/sidorares/json-bigint#readme" }, + { + "name": "json-buffer", + "versions": [ + "3.0.1" + ], + "license": "MIT", + "homepage": "https://github.com/dominictarr/json-buffer" + }, { "name": "json-schema-to-ts", "versions": [ @@ -2306,6 +3172,7 @@ { "name": "json-schema-traverse", "versions": [ + "0.4.1", "1.0.0" ], "license": "MIT", @@ -2319,9 +3186,18 @@ "license": "BSD-2-Clause", "homepage": "https://github.com/RemyRylan/json-schema-typed/tree/main/dist/node" }, + { + "name": "json-stable-stringify-without-jsonify", + "versions": [ + "1.0.1" + ], + "license": "MIT", + "homepage": "https://github.com/samn/json-stable-stringify" + }, { "name": "json5", "versions": [ + "1.0.2", "2.2.3" ], "license": "MIT", @@ -2351,6 +3227,14 @@ "license": "MIT", "homepage": "https://github.com/brianloveswords/node-jws#readme" }, + { + "name": "keyv", + "versions": [ + "4.5.4" + ], + "license": "MIT", + "homepage": "https://github.com/jaredwray/keyv" + }, { "name": "kleur", "versions": [ @@ -2375,6 +3259,14 @@ "license": "MIT", "homepage": "https://github.com/jpommerening/node-lazystream" }, + { + "name": "levn", + "versions": [ + "0.4.1" + ], + "license": "MIT", + "homepage": "https://github.com/gkz/levn" + }, { "name": "lie", "versions": [ @@ -2383,6 +3275,14 @@ "license": "MIT", "homepage": "https://github.com/calvinmetcalf/lie#readme" }, + { + "name": "locate-path", + "versions": [ + "6.0.0" + ], + "license": "MIT", + "homepage": "https://github.com/sindresorhus/locate-path#readme" + }, { "name": "lodash.identity", "versions": [ @@ -2521,6 +3421,15 @@ "license": "MIT", "homepage": "https://github.com/sindresorhus/mimic-response#readme" }, + { + "name": "minimatch", + "versions": [ + "3.1.5", + "9.0.9" + ], + "license": "ISC", + "homepage": "https://github.com/isaacs/minimatch#readme" + }, { "name": "minimatch", "versions": [ @@ -2593,6 +3502,14 @@ "license": "MIT", "homepage": "https://github.com/inspiredware/napi-build-utils#readme" }, + { + "name": "natural-compare", + "versions": [ + "1.4.0" + ], + "license": "MIT", + "homepage": "https://github.com/litejs/natural-compare-lite#readme" + }, { "name": "negotiator", "versions": [ @@ -2641,6 +3558,14 @@ "license": "MIT", "homepage": "https://github.com/jimmywarting/node-domexception#readme" }, + { + "name": "node-exports-info", + "versions": [ + "1.6.2" + ], + "license": "MIT", + "homepage": "https://github.com/inspect-js/node-exports-info#readme" + }, { "name": "node-fetch", "versions": [ @@ -2684,10 +3609,58 @@ { "name": "object-inspect", "versions": [ - "1.13.4" + "1.13.4" + ], + "license": "MIT", + "homepage": "https://github.com/inspect-js/object-inspect" + }, + { + "name": "object-keys", + "versions": [ + "1.1.1" + ], + "license": "MIT", + "homepage": "https://github.com/ljharb/object-keys#readme" + }, + { + "name": "object.assign", + "versions": [ + "4.1.7" + ], + "license": "MIT", + "homepage": "https://github.com/ljharb/object.assign#readme" + }, + { + "name": "object.entries", + "versions": [ + "1.1.9" + ], + "license": "MIT", + "homepage": "https://github.com/es-shims/Object.entries#readme" + }, + { + "name": "object.fromentries", + "versions": [ + "2.0.8" + ], + "license": "MIT", + "homepage": "https://github.com/es-shims/Object.fromEntries#readme" + }, + { + "name": "object.groupby", + "versions": [ + "1.0.3" + ], + "license": "MIT", + "homepage": "https://github.com/es-shims/Object.groupBy#readme" + }, + { + "name": "object.values", + "versions": [ + "1.2.1" ], "license": "MIT", - "homepage": "https://github.com/inspect-js/object-inspect" + "homepage": "https://github.com/es-shims/Object.values#readme" }, { "name": "obug", @@ -2729,6 +3702,22 @@ "license": "Apache-2.0", "homepage": "https://github.com/openai/openai-node#readme" }, + { + "name": "optionator", + "versions": [ + "0.9.4" + ], + "license": "MIT", + "homepage": "https://github.com/gkz/optionator" + }, + { + "name": "own-keys", + "versions": [ + "1.0.2" + ], + "license": "MIT", + "homepage": "https://github.com/ljharb/own-keys#readme" + }, { "name": "oxc-parser", "versions": [ @@ -2753,6 +3742,22 @@ "license": "MIT", "homepage": "https://github.com/sindresorhus/p-finally#readme" }, + { + "name": "p-limit", + "versions": [ + "3.1.0" + ], + "license": "MIT", + "homepage": "https://github.com/sindresorhus/p-limit#readme" + }, + { + "name": "p-locate", + "versions": [ + "5.0.0" + ], + "license": "MIT", + "homepage": "https://github.com/sindresorhus/p-locate#readme" + }, { "name": "p-retry", "versions": [ @@ -2777,6 +3782,14 @@ "license": "(MIT AND Zlib)", "homepage": "https://github.com/nodeca/pako" }, + { + "name": "parent-module", + "versions": [ + "1.0.1" + ], + "license": "MIT", + "homepage": "https://github.com/sindresorhus/parent-module#readme" + }, { "name": "parse5", "versions": [ @@ -2810,6 +3823,22 @@ "license": "MIT", "homepage": "https://promplate.dev/partial-json-parser" }, + { + "name": "path-exists", + "versions": [ + "4.0.0" + ], + "license": "MIT", + "homepage": "https://github.com/sindresorhus/path-exists#readme" + }, + { + "name": "path-is-absolute", + "versions": [ + "1.0.1" + ], + "license": "MIT", + "homepage": "https://github.com/sindresorhus/path-is-absolute#readme" + }, { "name": "path-key", "versions": [ @@ -2843,6 +3872,14 @@ "license": "MIT", "homepage": "https://github.com/pillarjs/path-to-regexp#readme" }, + { + "name": "path-type", + "versions": [ + "4.0.0" + ], + "license": "MIT", + "homepage": "https://github.com/sindresorhus/path-type#readme" + }, { "name": "pathe", "versions": [ @@ -2863,7 +3900,6 @@ "name": "picomatch", "versions": [ "2.3.2", - "4.0.5", "4.0.7" ], "license": "MIT", @@ -2918,6 +3954,14 @@ "license": "MIT", "homepage": "https://github.com/lukeapage/pngjs" }, + { + "name": "possible-typed-array-names", + "versions": [ + "1.1.0" + ], + "license": "MIT", + "homepage": "https://github.com/ljharb/possible-typed-array-names#readme" + }, { "name": "postcss", "versions": [ @@ -2934,6 +3978,14 @@ "license": "MIT", "homepage": "https://github.com/prebuild/prebuild-install" }, + { + "name": "prelude-ls", + "versions": [ + "1.2.1" + ], + "license": "MIT", + "homepage": "http://preludels.com" + }, { "name": "prettier", "versions": [ @@ -2942,6 +3994,14 @@ "license": "MIT", "homepage": "https://prettier.io" }, + { + "name": "prettier-linter-helpers", + "versions": [ + "1.0.1" + ], + "license": "MIT", + "homepage": "https://github.com/prettier/prettier-linter-helpers#readme" + }, { "name": "process", "versions": [ @@ -3014,6 +4074,14 @@ "license": "MIT", "homepage": "https://github.com/mafintosh/pump#readme" }, + { + "name": "punycode", + "versions": [ + "2.3.1" + ], + "license": "MIT", + "homepage": "https://mths.be/punycode" + }, { "name": "qs", "versions": [ @@ -3090,6 +4158,14 @@ "license": "MIT", "homepage": "https://github.com/gulpjs/rechoir#readme" }, + { + "name": "reflect.getprototypeof", + "versions": [ + "1.0.10" + ], + "license": "MIT", + "homepage": "https://github.com/es-shims/Reflect.getPrototypeOf" + }, { "name": "regexp-tree", "versions": [ @@ -3098,6 +4174,14 @@ "license": "MIT", "homepage": "https://github.com/DmitrySoshnikov/regexp-tree" }, + { + "name": "regexp.prototype.flags", + "versions": [ + "1.5.4" + ], + "license": "MIT", + "homepage": "https://github.com/es-shims/RegExp.prototype.flags#readme" + }, { "name": "require-directory", "versions": [ @@ -3117,11 +4201,20 @@ { "name": "resolve", "versions": [ - "1.22.12" + "1.22.12", + "2.0.0-next.7" ], "license": "MIT", "homepage": "https://github.com/browserify/resolve#readme" }, + { + "name": "resolve-from", + "versions": [ + "4.0.0" + ], + "license": "MIT", + "homepage": "https://github.com/sindresorhus/resolve-from#readme" + }, { "name": "resolve-pkg-maps", "versions": [ @@ -3147,6 +4240,14 @@ "license": "MIT", "homepage": "https://github.com/mcollina/reusify#readme" }, + { + "name": "rimraf", + "versions": [ + "3.0.2" + ], + "license": "ISC", + "homepage": "https://github.com/isaacs/rimraf#readme" + }, { "name": "rollup", "versions": [ @@ -3171,6 +4272,14 @@ "license": "MIT", "homepage": "https://github.com/feross/run-parallel" }, + { + "name": "safe-array-concat", + "versions": [ + "1.1.4" + ], + "license": "MIT", + "homepage": "https://github.com/ljharb/safe-array-concat#readme" + }, { "name": "safe-buffer", "versions": [ @@ -3180,6 +4289,14 @@ "license": "MIT", "homepage": "https://github.com/feross/safe-buffer" }, + { + "name": "safe-push-apply", + "versions": [ + "1.0.0" + ], + "license": "MIT", + "homepage": "https://github.com/ljharb/safe-push-apply#readme" + }, { "name": "safe-regex", "versions": [ @@ -3188,6 +4305,14 @@ "license": "MIT", "homepage": "https://github.com/davisjam/safe-regex" }, + { + "name": "safe-regex-test", + "versions": [ + "1.1.0" + ], + "license": "MIT", + "homepage": "https://github.com/ljharb/safe-regex-test#readme" + }, { "name": "safe-stable-stringify", "versions": [ @@ -3224,6 +4349,7 @@ "name": "semver", "versions": [ "5.7.2", + "6.3.1", "7.8.5" ], "license": "ISC", @@ -3245,6 +4371,30 @@ "license": "MIT", "homepage": "https://github.com/expressjs/serve-static#readme" }, + { + "name": "set-function-length", + "versions": [ + "1.2.2" + ], + "license": "MIT", + "homepage": "https://github.com/ljharb/set-function-length#readme" + }, + { + "name": "set-function-name", + "versions": [ + "2.0.2" + ], + "license": "MIT", + "homepage": "https://github.com/ljharb/set-function-name#readme" + }, + { + "name": "set-proto", + "versions": [ + "1.0.0" + ], + "license": "MIT", + "homepage": "https://github.com/ljharb/set-proto#readme" + }, { "name": "setimmediate", "versions": [ @@ -3375,6 +4525,14 @@ "license": "MIT", "homepage": "https://github.com/terkelg/sisteransi#readme" }, + { + "name": "slash", + "versions": [ + "3.0.0" + ], + "license": "MIT", + "homepage": "https://github.com/sindresorhus/slash#readme" + }, { "name": "smol-toml", "versions": [ @@ -3440,6 +4598,14 @@ "license": "MIT", "homepage": "https://github.com/unjs/std-env#readme" }, + { + "name": "stop-iteration-iterator", + "versions": [ + "1.1.0" + ], + "license": "MIT", + "homepage": "https://github.com/ljharb/stop-iteration-iterator#readme" + }, { "name": "string_decoder", "versions": [ @@ -3457,6 +4623,30 @@ "license": "MIT", "homepage": "https://github.com/sindresorhus/string-width#readme" }, + { + "name": "string.prototype.trim", + "versions": [ + "1.2.11" + ], + "license": "MIT", + "homepage": "https://github.com/es-shims/String.prototype.trim#readme" + }, + { + "name": "string.prototype.trimend", + "versions": [ + "1.0.10" + ], + "license": "MIT", + "homepage": "https://github.com/es-shims/String.prototype.trimEnd#readme" + }, + { + "name": "string.prototype.trimstart", + "versions": [ + "1.0.8" + ], + "license": "MIT", + "homepage": "https://github.com/es-shims/String.prototype.trimStart#readme" + }, { "name": "strip-ansi", "versions": [ @@ -3507,6 +4697,14 @@ "license": "MIT", "homepage": "https://github.com/inspect-js/node-supports-preserve-symlinks-flag#readme" }, + { + "name": "synckit", + "versions": [ + "0.11.13" + ], + "license": "MIT", + "homepage": "https://github.com/un-ts/synckit#readme" + }, { "name": "tapable", "versions": [ @@ -3539,6 +4737,14 @@ "license": "MIT", "homepage": "https://github.com/mafintosh/tar-stream" }, + { + "name": "text-table", + "versions": [ + "0.2.0" + ], + "license": "MIT", + "homepage": "https://github.com/substack/text-table" + }, { "name": "thenify", "versions": [ @@ -3619,9 +4825,18 @@ "license": "MIT", "homepage": "https://github.com/ThomasAribart/ts-algebra#readme" }, + { + "name": "ts-api-utils", + "versions": [ + "1.4.3" + ], + "license": "MIT", + "homepage": "https://github.com/JoshuaKGoldberg/ts-api-utils#readme" + }, { "name": "tsconfig-paths", "versions": [ + "3.15.0", "4.2.0" ], "license": "MIT", @@ -3659,6 +4874,22 @@ "license": "Apache-2.0", "homepage": "https://github.com/mikeal/tunnel-agent#readme" }, + { + "name": "type-check", + "versions": [ + "0.4.0" + ], + "license": "MIT", + "homepage": "https://github.com/gkz/type-check" + }, + { + "name": "type-fest", + "versions": [ + "0.20.2" + ], + "license": "(MIT OR CC0-1.0)", + "homepage": "https://github.com/sindresorhus/type-fest#readme" + }, { "name": "type-is", "versions": [ @@ -3675,6 +4906,38 @@ "license": "MIT", "homepage": "https://github.com/sinclairzx81/typebox#readme" }, + { + "name": "typed-array-buffer", + "versions": [ + "1.0.3" + ], + "license": "MIT", + "homepage": "https://github.com/inspect-js/typed-array-buffer#readme" + }, + { + "name": "typed-array-byte-length", + "versions": [ + "1.0.3" + ], + "license": "MIT", + "homepage": "https://github.com/inspect-js/typed-array-byte-length#readme" + }, + { + "name": "typed-array-byte-offset", + "versions": [ + "1.0.5" + ], + "license": "MIT", + "homepage": "https://github.com/inspect-js/typed-array-byte-offset#readme" + }, + { + "name": "typed-array-length", + "versions": [ + "1.0.8" + ], + "license": "MIT", + "homepage": "https://github.com/inspect-js/typed-array-length#readme" + }, { "name": "typescript", "versions": [ @@ -3699,6 +4962,14 @@ "license": "ISC", "homepage": "https://github.com/webpro-nl/unbash#readme" }, + { + "name": "unbox-primitive", + "versions": [ + "1.1.0" + ], + "license": "MIT", + "homepage": "https://github.com/ljharb/unbox-primitive#readme" + }, { "name": "undici", "versions": [ @@ -3732,6 +5003,14 @@ "license": "MIT", "homepage": "https://github.com/stream-utils/unpipe#readme" }, + { + "name": "uri-js", + "versions": [ + "4.4.1" + ], + "license": "BSD-2-Clause", + "homepage": "https://github.com/garycourt/uri-js" + }, { "name": "util-deprecate", "versions": [ @@ -3797,6 +5076,38 @@ "license": "ISC", "homepage": "https://github.com/isaacs/node-which#readme" }, + { + "name": "which-boxed-primitive", + "versions": [ + "1.1.1" + ], + "license": "MIT", + "homepage": "https://github.com/inspect-js/which-boxed-primitive#readme" + }, + { + "name": "which-builtin-type", + "versions": [ + "1.2.1" + ], + "license": "MIT", + "homepage": "https://github.com/inspect-js/which-builtin-type#readme" + }, + { + "name": "which-collection", + "versions": [ + "1.0.2" + ], + "license": "MIT", + "homepage": "https://github.com/inspect-js/which-collection#readme" + }, + { + "name": "which-typed-array", + "versions": [ + "1.1.24" + ], + "license": "MIT", + "homepage": "https://github.com/inspect-js/which-typed-array#readme" + }, { "name": "why-is-node-running", "versions": [ @@ -3805,6 +5116,14 @@ "license": "MIT", "homepage": "https://github.com/mafintosh/why-is-node-running" }, + { + "name": "word-wrap", + "versions": [ + "1.2.5" + ], + "license": "MIT", + "homepage": "https://github.com/jonschlinkert/word-wrap" + }, { "name": "wordwrap", "versions": [ @@ -3885,6 +5204,14 @@ "license": "ISC", "homepage": "https://github.com/yargs/yargs-parser#readme" }, + { + "name": "yocto-queue", + "versions": [ + "0.1.0" + ], + "license": "MIT", + "homepage": "https://github.com/sindresorhus/yocto-queue#readme" + }, { "name": "zod", "versions": [ diff --git a/release/public-source.json b/release/public-source.json index 9748e819..0154e4bc 100644 --- a/release/public-source.json +++ b/release/public-source.json @@ -6,6 +6,7 @@ ".agents/skills/retro/SKILL.md", ".agents/skills/testing-workflow/SKILL.md", ".agents/skills/verify-all-runtime-sinks/SKILL.md", + ".eslintrc.cjs", ".gitattributes", ".github/CODEOWNERS", ".github/ISSUE_TEMPLATE/01-bug-report.yml", diff --git a/scripts/verify.mjs b/scripts/verify.mjs index 18dbd74d..0ca4761d 100644 --- a/scripts/verify.mjs +++ b/scripts/verify.mjs @@ -48,6 +48,7 @@ const steps = [ command: ["scripts/export-source-preview.mjs", "--out", preview], }, { name: "test:release-tools", script: "test:release-tools", docs: true, windows: true }, + { name: "lint:tui", script: "lint:tui", windows: true }, // Compiler inputs are identical across the matrix. One Linux job runs this; // all platforms still build and validate native artifacts on their own platform. { name: "typecheck", script: "typecheck", fullOnly: true }, diff --git a/test/source-sync.test.mjs b/test/source-sync.test.mjs index a6a39d01..17806964 100644 --- a/test/source-sync.test.mjs +++ b/test/source-sync.test.mjs @@ -927,6 +927,7 @@ test('Windows contract profile selects focused gates', () => { 'check:tsconfig', 'export source preview', 'test:release-tools', + 'lint:tui', 'build', 'check:standalone', 'test:artifact', @@ -1116,3 +1117,40 @@ test('source imports preserve vendored Office schema bytes through Git staging', assert.deepEqual(git('show', `:${schema}`), bytes); } }); + +test('TUI lint rejects semantic regressions in source and tests while preserving engine exceptions', async () => { + const { ESLint } = await import('eslint'); + const root = fileURLToPath(new URL('../', import.meta.url)); + const eslint = new ESLint({ cwd: root }); + const rulesFor = async (source, filePath) => { + const [result] = await eslint.lintText(source, { filePath }); + assert.equal(result.fatalErrorCount, 0, JSON.stringify(result.messages)); + return result.messages.filter(message => message.severity === 2).map(message => message.ruleId); + }; + const shadow = 'const value = 1; export function sample(value: number) { return value; }\n'; + for (const file of ['packages/tui/src/lint-probe.ts', 'packages/tui/test/unit/lint-probe.test.ts']) { + assert.ok((await rulesFor(shadow, file)).includes('@typescript-eslint/no-shadow'), file); + assert.ok((await rulesFor('export const compare = (value: number) => value == 1;\n', file)).includes('eqeqeq'), file); + } + assert.ok((await rulesFor('export const compare = (value) => value == 1;\n', 'packages/tui/test/lint-probe.mjs')).includes('eqeqeq')); + assert.ok(!(await rulesFor(shadow, 'packages/tui/src/tui/engine/lint-probe.ts')).includes('@typescript-eslint/no-shadow')); + assert.ok((await rulesFor(shadow, 'packages/tui/src/tui/engine/public.ts')).includes('@typescript-eslint/no-shadow')); + assert.ok((await rulesFor('export const compare = (value: number) => value == 1;\n', 'packages/tui/src/tui/engine/lint-probe.ts')).includes('eqeqeq')); + const [formatting] = await eslint.lintText('export const label = "synthetic";\n', { filePath: 'packages/tui/src/lint-probe.ts' }); + assert.ok(formatting.messages.some(message => message.ruleId === 'prettier/prettier' && message.severity === 1)); + assert.equal(await eslint.isPathIgnored('packages/tui/test/unit/lint-probe.test.ts'), false); + assert.equal(await eslint.isPathIgnored('packages/tui/test/pi-084-upstream/lint-probe.test.ts'), true); + assert.equal(await eslint.isPathIgnored('third_party/pi-mono/packages/tui/src/lint-probe.ts'), true); +}); + +test('TUI lint failure stops full and platform verification before compilation or build', t => { + const fixture = verificationFixture(t); + for (const profile of ['full', 'platform', 'archive']) { + const result = fixture.run(['--profile', profile], { VERIFY_FIXTURE_FAIL: 'lint:tui' }); + assert.equal(result.status, 1, result.stderr); + const report = fixture.report(); + assert.equal(report.gates.find(gate => gate.name === 'lint:tui').status, 'FAIL'); + assert.equal(report.gates.find(gate => gate.name === 'lint:tui').exitCode, 17); + assert.equal(report.gates.find(gate => gate.name === 'build').status, 'NOT_RUN'); + } +}); From 61c4c31b9cfdaf508b281ee4d510ce63f307464a Mon Sep 17 00:00:00 2001 From: muyu Date: Wed, 23 Sep 2026 20:22:05 +0800 Subject: [PATCH 08/27] feat: improve local Bash execution contracts --- docs/tui-capabilities.md | 19 + .../agent-tools/src/desktop/builtin-defs.ts | 35 +- packages/agent-tools/src/desktop/index.ts | 5 + .../src/desktop/local-bash-contract.ts | 83 ++ .../src/desktop/local-bash-input.ts | 14 + .../src/desktop/local-bash-result.ts | 99 ++ .../src/desktop/local-bash-timing.ts | 62 ++ .../agent-tools/src/desktop/local-pi-tools.ts | 235 +++-- .../src/desktop/local-task-control.ts | 52 +- .../agent-tools/src/desktop/output-limit.ts | 4 +- packages/agent-tools/src/desktop/types.ts | 3 + .../test/desktop/local-bash-output.test.ts | 207 ++++ .../test/desktop/local-bash-timeout.test.ts | 506 +++++++++ .../desktop/local-task-output-polling.test.ts | 445 ++++++++ .../agents/_default/prompt-base-all.md.hbs | 7 +- .../agents/_default/prompt-base-windows.md | 47 +- .../_default/prompt-base-windows.md.hbs | 47 +- .../service/background-bash/executor.test.ts | 233 +++++ .../src/service/background-bash/executor.ts | 60 +- .../assembly/local-turn-tool-catalog.ts | 23 + .../agent-host/execution/executor.test.ts | 21 +- .../agent-host/execution/executor.ts | 6 +- .../test/unit/agent/builtin/catalog.test.ts | 24 + .../src/background-task/bash-output-writer.ts | 25 +- .../src/background-task/bash-runner.ts | 184 ++-- .../background-task/bash-task-settlement.ts | 172 ++++ .../src/background-task/service.ts | 55 +- .../src/background-task/task-stop.ts | 66 ++ .../test/unit/child-bash-lifecycle.test.ts | 32 +- ...background-bash-review-regressions.test.ts | 430 ++++++++ .../unit/local-background-bash-runner.test.ts | 959 ++++++++++++++++++ .../local-background-task-stop-races.test.ts | 119 +++ .../transcript/presentation/tool-summary.ts | 1 + .../tui/src/tui/transcript/tool-evidence.ts | 9 +- .../tui/test/unit/tui-tool-evidence.test.ts | 264 +++++ .../tui/test/unit/tui-tool-summary.test.ts | 54 + release/public-source.json | 15 + test/vitest-suites.json | 11 +- third_party/pi-mono/MINIMAX_CHANGES.md | 8 + .../coding-agent/src/core/tools/bash.ts | 215 +++- .../coding-agent/src/core/tools/index.ts | 2 + .../src/core/tools/output-accumulator.ts | 48 +- .../packages/coding-agent/src/index.ts | 2 + .../coding-agent/src/utils/child-process.ts | 27 +- .../test/bash-process-observation.test.ts | 61 +- .../packages/coding-agent/test/tools.test.ts | 37 + 46 files changed, 4605 insertions(+), 428 deletions(-) create mode 100644 packages/agent-tools/src/desktop/local-bash-contract.ts create mode 100644 packages/agent-tools/src/desktop/local-bash-input.ts create mode 100644 packages/agent-tools/src/desktop/local-bash-result.ts create mode 100644 packages/agent-tools/src/desktop/local-bash-timing.ts create mode 100644 packages/agent-tools/test/desktop/local-bash-output.test.ts create mode 100644 packages/agent-tools/test/desktop/local-bash-timeout.test.ts create mode 100644 packages/agent-tools/test/desktop/local-task-output-polling.test.ts create mode 100644 packages/local-runtime-v2/src/service/background-bash/executor.test.ts create mode 100644 packages/local-runtime/src/background-task/bash-task-settlement.ts create mode 100644 packages/local-runtime/src/background-task/task-stop.ts create mode 100644 packages/local-runtime/test/unit/local-background-bash-review-regressions.test.ts create mode 100644 packages/local-runtime/test/unit/local-background-bash-runner.test.ts create mode 100644 packages/local-runtime/test/unit/local-background-task-stop-races.test.ts create mode 100644 packages/tui/test/unit/tui-tool-evidence.test.ts create mode 100644 packages/tui/test/unit/tui-tool-summary.test.ts diff --git a/docs/tui-capabilities.md b/docs/tui-capabilities.md index 9ced4ea3..85e1b3aa 100644 --- a/docs/tui-capabilities.md +++ b/docs/tui-capabilities.md @@ -21,6 +21,25 @@ The evidence column summarizes the historical TUI 0.3.11 restoration record from | Files, shell, subagents, sessions, headless, ACP | Actual runtime retained | BYOK, file reads, session resume, ACP, sandbox, and status protocol tests | | Built-in skills, MCP, plugin tools | Original TUI assets and activation conditions retained | Asset build, plugin, and MCP tests; no claim that every skill has passed a real task | +## Local Bash execution + +When the current turn includes native `task_output`, foreground Bash waits up to +60 seconds before returning the same command's background task ID. Its total +command timeout defaults to 600 seconds and is capped at 600 seconds; a shorter +requested timeout applies. Backgrounding and output reads preserve the original +deadline. Without native `task_output`, Bash stays in the foreground with a +120-second default and a 300-second cap, and its schema omits `run_in_background`. +Explicit background commands use the requested timeout; when omitted, the +existing 30-minute runtime watchdog applies. + +Only exit code zero is success. Results retain available exit, signal, timeout, +cancellation, and partial-output facts. Large output keeps its original beginning +and end within a 24 KiB first-response text budget, with a full-log reference when +persistence succeeds. `task_output` reads use byte offsets; a successful read can +report a failed command. Stop failures and incomplete logs are reported separately. +An optional `description` supplies the TUI summary while execution and permission +checks continue to use the original command. + ## Skill directory links Workspace `.agents/skills`, `.claude/skills`, and `.minimax/skills` support diff --git a/packages/agent-tools/src/desktop/builtin-defs.ts b/packages/agent-tools/src/desktop/builtin-defs.ts index ebc13739..24fb62a7 100644 --- a/packages/agent-tools/src/desktop/builtin-defs.ts +++ b/packages/agent-tools/src/desktop/builtin-defs.ts @@ -4,6 +4,7 @@ import type { ToolDefinition } from '@mavis/agent-core/tools'; import { createMavisOperationClassifier } from '../shared/mavis-operation-classifier.js'; import { LOCAL_MAVIS_COMMANDS } from './local-mavis-commands.js'; +import { BashDescriptionSchema } from './local-bash-input.js'; import { prepareAskUserArguments } from './prepare-ask-user-arguments.js'; import { @@ -102,33 +103,17 @@ export const LocalEditToolDef = { } as const satisfies ToolDefinition; export type LocalEditToolInput = Static; -const _isWindows = process.platform === 'win32'; -const MAX_BACKGROUND_BASH_TIMEOUT_SECONDS = 2_147_483; - export const LocalBashToolDef = { name: 'bash', executionMode: 'sequential', description: [ - 'Executes a command in a fresh local shell and returns its output.', + 'Executes a shell command and returns its output.', '', - '- Each call starts in the session workspace. `cd` and shell state do not persist between calls; use absolute paths or change directories within the same command.', - '- Use dedicated `read`, `write`, `edit`, `grep`, and `glob` tools for file operations. Do not use shell commands for file reading, searching, or modification unless the user explicitly requests it or you have verified that the dedicated tools cannot perform the required operation. Use `bash` for processes, git, package managers, builds, tests, and necessary pipelines.', - '- The shell is non-interactive: no TTY or stdin prompts. Run non-interactive commands yourself; check `--help` for suitable flags before asking the user to run one. Leave physical authorization (OAuth consent, MFA, hardware keys) to the user.', - '- Use `run_in_background` for long commands. Do not increase timeouts to mask hung commands. A returned task id refers to the original process; do not rerun it. Use `task_query` or `task_output` to inspect progress and `task_stop` to cancel.', + '- Each call starts in the session workspace. Changes to the working directory and shell state (variables and functions) do not persist between calls. If a command requires a different working directory, change directories within the same call.', + '- Use dedicated `read`, `write`, `edit`, `grep`, and `glob` tools for file operations. Do not use shell commands for file reading, searching, or modification unless the user explicitly requests it or you have verified that the dedicated tools cannot perform the required operation. Use `bash` for processes, git, package managers, builds, tests, and pipelines.', + '- The shell is non-interactive: no TTY or stdin prompts. For commands that require interaction, check `--help` for non-interactive options before asking the user to run them. Leave physical authorization (OAuth consent, MFA, hardware keys) to the user.', '- For file or directory deletion, use one top-level `rm -- ...`; the local runtime routes it through recoverable deletion.', '- Do not bypass recoverable deletion with absolute paths to deletion commands or inline scripts. If it fails, report the failure instead of falling back to permanent deletion. Permission checks still apply.', - ...(_isWindows - ? [ - '', - 'Windows shell:', - '- PowerShell is preferred; Bash is a fallback when PowerShell is unavailable. Match the selected shell syntax.', - '- For PowerShell, use `$env:VAR`; do not use `export`, `/dev/null`, heredocs, `sed -i`, or `&&`. Do not wrap commands in `powershell -Command`.', - "- Start multi-statement PowerShell scripts with `$ErrorActionPreference = 'Stop'`. Use single quotes for literals and regex patterns. Do NOT use Bash-style backslash escapes.", - '- For native program failures in PowerShell, check `$LASTEXITCODE` or enable `$PSNativeCommandUseErrorActionPreference` when available.', - '- Avoid `Get-Content | ... | Set-Content` file-editing pipelines. Prefer file tools or a script with explicit UTF-8 encoding for batch changes. If Get-Content/Set-Content are needed, specify `-Encoding UTF8`; PowerShell 5.1 writes a BOM.', - '- If a CLI is missing, check `.cmd` or `.ps1` wrappers. If `bash` opens WSL or produces garbled output, switch to direct `node`/`python` execution or a verified Git Bash path. After two failures with the same approach, change strategy.', - ] - : []), '', '# Git', '- Interactive flags (`-i`, e.g. `git rebase -i`, `git add -i`) are not supported in this environment.', @@ -137,19 +122,19 @@ export const LocalBashToolDef = { ].join('\n'), schema: Type.Object({ command: Type.String({ - description: 'Shell command line to execute locally.', + description: 'The command to execute', }), + description: BashDescriptionSchema, timeout: Type.Optional( Type.Number({ - maximum: MAX_BACKGROUND_BASH_TIMEOUT_SECONDS, + exclusiveMinimum: 0, description: - 'Timeout in seconds. Foreground: default 120s, max 300s. Use run_in_background for longer commands. Background tasks use the requested timeout, subject to a runtime watchdog.', + 'Total command timeout in seconds. Foreground-only: default 120s, max 300s. Foreground with automatic backgrounding: default/max 600s, including foreground time. Explicit background: uses the specified timeout, or a 30-minute limit if omitted.', }), ), run_in_background: Type.Optional( Type.Boolean({ - description: - 'Start in the background and return a task id immediately. Foreground commands may also return a task id after 15s without restarting the process.', + description: 'Set to true to run this command in the background.', }), ), }), diff --git a/packages/agent-tools/src/desktop/index.ts b/packages/agent-tools/src/desktop/index.ts index 5b289f55..7d8b8875 100644 --- a/packages/agent-tools/src/desktop/index.ts +++ b/packages/agent-tools/src/desktop/index.ts @@ -49,6 +49,10 @@ export * from './local-ask-user.js'; export * from './local-feature-enable.js'; export * from './local-browser.js'; export * from './local-pi-tools.js'; +export * from './local-bash-result.js'; +export * from './local-bash-timing.js'; +export * from './local-bash-input.js'; +export * from './local-bash-contract.js'; export { executeLocalHostTrashIfRequested } from './host-trash-executor.js'; export type { LocalHostTrashRuntime } from './host-trash-executor.js'; export * from './local-glob.js'; @@ -198,3 +202,4 @@ export { CloudSessionReadError, type CloudSessionReaderOptions, } from './cloud-session-reader.js'; +export { DESKTOP_BASH_PREVIEW_BYTES } from './output-limit.js'; diff --git a/packages/agent-tools/src/desktop/local-bash-contract.ts b/packages/agent-tools/src/desktop/local-bash-contract.ts new file mode 100644 index 00000000..fd1e210a --- /dev/null +++ b/packages/agent-tools/src/desktop/local-bash-contract.ts @@ -0,0 +1,83 @@ +import { getShellConfig } from '@earendil-works/pi-coding-agent/shell'; +import type { ToolDefinition } from '@mavis/agent-core/tools'; +import { Clone, Type } from '@sinclair/typebox'; + +import { LocalBashToolDef } from './builtin-defs.js'; +import { + DEFAULT_FOREGROUND_BASH_SOFT_YIELD_MS, + DEFAULT_FOREGROUND_BASH_TIMEOUT_SECONDS, + MAX_FOREGROUND_BASH_TIMEOUT_SECONDS, + MAX_MANAGED_BASH_TIMEOUT_SECONDS, +} from './local-bash-timing.js'; + +export interface LocalBashTurnCapabilities { + readonly background: boolean; + readonly shell: ReturnType['type'] | 'unavailable'; +} + +export function resolveLocalBashShell(): LocalBashTurnCapabilities['shell'] { + try { + return getShellConfig().type; + } catch { + return 'unavailable'; + } +} + +/** A fresh definition for the final admitted Turn inventory. */ +export function createLocalBashToolDefinition( + capabilities: LocalBashTurnCapabilities, +): ToolDefinition { + const base = Clone(LocalBashToolDef.schema); + const schema = capabilities.background ? base : Type.Omit(base, ['run_in_background']); + schema.properties.timeout.description = capabilities.background + ? `Total command timeout in seconds. Foreground with automatic backgrounding: default/max ${MAX_MANAGED_BASH_TIMEOUT_SECONDS}s, including foreground time. Explicit background: uses the specified timeout, or a 30-minute limit if omitted.` + : `Timeout in seconds. Foreground: default ${DEFAULT_FOREGROUND_BASH_TIMEOUT_SECONDS}s; values above ${MAX_FOREGROUND_BASH_TIMEOUT_SECONDS}s are capped.`; + return { + ...LocalBashToolDef, + schema, + description: `${LocalBashToolDef.description}\n\n${renderBashUsage(capabilities)}`, + }; +} + +function renderBashUsage(capabilities: LocalBashTurnCapabilities): string { + return [ + ...(capabilities.background + ? [ + `- Foreground calls wait up to ${DEFAULT_FOREGROUND_BASH_SOFT_YIELD_MS / 1000}s, then return a task_id if unfinished. The same command continues in the background; do not start it again.`, + '- Set run_in_background=true to return a task_id without waiting for completion.', + '- Background completion notifies you automatically. Continue independent work; use task_output for current output and task_stop, when available, to cancel.', + '- Backgrounding and reading output do not reset the command timeout.', + ] + : ['- This Turn supports foreground execution only.']), + "- Large output preserves the beginning and end. Follow the result's instructions to read omitted output.", + ...shellRules(capabilities.shell), + ].join('\n'); +} + +function shellRules(shell: LocalBashTurnCapabilities['shell']): string[] { + if (shell === 'unavailable') + return [ + '- No local shell was resolved. Do not assume Bash or PowerShell syntax is executable.', + ]; + if (shell === 'bash') + return [ + '- Selected shell: Bash. Use Bash syntax, including on Windows when Bash is the selected fallback.', + ]; + if (shell === 'sh') + return [ + '- Selected shell: POSIX sh. Use POSIX shell syntax; Bash arrays, [[ ... ]] and process substitution are unavailable.', + ]; + return [ + shell === 'pwsh' + ? '- Selected shell: PowerShell 7 (pwsh). Pipeline-chain operators && and || are supported; use PowerShell quoting and variable syntax.' + : '- Selected shell: Windows PowerShell 5.1. The && and || operators are unsupported; use separate statements and explicit success checks.', + '- Use $env:VAR and single quotes for literals/regex. Do not use Bash export, /dev/null, heredocs, sed -i, or backslash quoting. Do not wrap ordinary commands in powershell -Command or cmd /c.', + "- Start multi-statement scripts with $ErrorActionPreference = 'Stop'. Check $LASTEXITCODE after native programs, or enable $PSNativeCommandUseErrorActionPreference when available.", + `- Prefer dedicated file tools. If shell file I/O is necessary, specify UTF-8 and avoid Get-Content | ... | Set-Content editing pipelines.${ + shell === 'powershell' + ? ' PowerShell 5.1 -Encoding UTF8 writes a BOM.' + : ' PowerShell 7 defaults to UTF-8 without BOM.' + }`, + '- If a CLI is missing, check .cmd/.ps1 wrappers. Change strategy after repeated syntax failures.', + ]; +} diff --git a/packages/agent-tools/src/desktop/local-bash-input.ts b/packages/agent-tools/src/desktop/local-bash-input.ts new file mode 100644 index 00000000..aba2d6f8 --- /dev/null +++ b/packages/agent-tools/src/desktop/local-bash-input.ts @@ -0,0 +1,14 @@ +import { Type } from '@sinclair/typebox'; + +export const BashDescriptionSchema = Type.Optional( + Type.String({ + description: 'A short description of what this command does.', + }), +); + +export function resolveBashDescription(description: unknown, command: string): string { + if (description !== undefined && typeof description !== 'string') { + throw new Error('Bash description must be a string.'); + } + return description || command; +} diff --git a/packages/agent-tools/src/desktop/local-bash-result.ts b/packages/agent-tools/src/desktop/local-bash-result.ts new file mode 100644 index 00000000..ca29c048 --- /dev/null +++ b/packages/agent-tools/src/desktop/local-bash-result.ts @@ -0,0 +1,99 @@ +import { + BashExecutionError, + type BashExecutionOutcome, + type BashToolDetails, +} from '@earendil-works/pi-coding-agent/tools'; +import type { AgentToolResult } from '@earendil-works/pi-agent-core'; + +import type { LocalBackgroundBashExecutorResult } from './types.js'; + +export interface LocalBashExecutionOutcome extends Omit { + reason: BashExecutionOutcome['reason'] | 'preflight_failed' | 'watchdog_timeout'; +} + +export function localBashResultFromPi( + result: AgentToolResult, +): LocalBackgroundBashExecutorResult { + const details = (result.details ?? {}) as Record; + const execution = readBashExecutionOutcome(details); + return { + text: result.content + .filter((part): part is { type: 'text'; text: string } => part.type === 'text') + .map((part) => part.text) + .join('\n'), + details, + ...(execution && execution.status !== 'succeeded' ? { isError: true } : {}), + }; +} + +export function localBashResultFromError( + error: unknown, + signal?: AbortSignal, +): LocalBackgroundBashExecutorResult { + const details: BashToolDetails = error instanceof BashExecutionError ? error.details : {}; + const cause = error instanceof BashExecutionError ? error.cause : error; + const causeRecord = + cause && typeof cause === 'object' ? (cause as Record) : undefined; + const errorCode = typeof causeRecord?.code === 'string' ? causeRecord.code : undefined; + const preflight = + errorCode?.startsWith('SANDBOX_') && + (causeRecord?.stage === undefined || + ['init', 'parse', 'invocation', 'pre-spawn'].includes(String(causeRecord.stage))); + const text = error instanceof Error ? error.message : String(error); + const execution: LocalBashExecutionOutcome = { + status: signal?.aborted ? 'canceled' : 'failed', + reason: signal?.aborted ? 'canceled' : 'unknown', + exitCode: null, + message: text, + ...(errorCode ? { errorCode } : {}), + ...details.execution, + ...(preflight ? { reason: 'preflight_failed' as const } : {}), + }; + if (execution.status === 'canceled' && !execution.cancellationReason && signal?.aborted) { + execution.cancellationReason = + signal.reason instanceof Error + ? signal.reason.message + : typeof signal.reason === 'string' + ? signal.reason + : undefined; + } + return { text, details: { ...details, execution }, isError: true }; +} + +export function readBashExecutionOutcome(details: unknown): LocalBashExecutionOutcome | undefined { + if (!details || typeof details !== 'object' || !('execution' in details)) return undefined; + const execution = details.execution; + if (!execution || typeof execution !== 'object') return undefined; + const value = execution as Record; + if ( + typeof value.status !== 'string' || + !['succeeded', 'failed', 'canceled'].includes(value.status) || + typeof value.reason !== 'string' || + ![ + 'exited', + 'signaled', + 'command_timeout', + 'canceled', + 'spawn_failed', + 'unknown', + 'preflight_failed', + 'watchdog_timeout', + ].includes(value.reason) || + !( + value.exitCode === null || + (typeof value.exitCode === 'number' && Number.isInteger(value.exitCode)) + ) || + [value.signal, value.errorCode, value.message, value.cancellationReason].some( + (field) => field !== undefined && typeof field !== 'string', + ) + ) + return undefined; + return execution as LocalBashExecutionOutcome; +} + +export function formatBashExecutionOutcome(execution: LocalBashExecutionOutcome): string { + if (execution.message) return execution.message; + if (execution.signal) return `Command terminated by signal ${execution.signal}`; + if (execution.exitCode !== null) return `Command exited with code ${execution.exitCode}`; + return `Command ${execution.status} (${execution.reason})`; +} diff --git a/packages/agent-tools/src/desktop/local-bash-timing.ts b/packages/agent-tools/src/desktop/local-bash-timing.ts new file mode 100644 index 00000000..57710fff --- /dev/null +++ b/packages/agent-tools/src/desktop/local-bash-timing.ts @@ -0,0 +1,62 @@ +import type { ToolResult } from '@mavis/agent-core/tools'; + +export const DEFAULT_FOREGROUND_BASH_TIMEOUT_SECONDS = 120; +export const MAX_FOREGROUND_BASH_TIMEOUT_SECONDS = 300; +export const MAX_MANAGED_BASH_TIMEOUT_SECONDS = 600; +export const MAX_BASH_TIMEOUT_SECONDS = 2_147_483; +export const DEFAULT_FOREGROUND_BASH_SOFT_YIELD_MS = 60_000; + +export interface LocalBashTiming { + commandTimeoutSeconds?: number; + requestedTimeoutSeconds?: number; + commandTimerStartedAt?: number; + commandDeadlineAt?: number; +} + +export function resolveLocalBashTiming( + timeout: number | undefined, + mode: 'direct_foreground' | 'managed_foreground' | 'explicit_background', +): LocalBashTiming { + if ( + timeout !== undefined && + (!Number.isFinite(timeout) || timeout <= 0 || timeout > MAX_BASH_TIMEOUT_SECONDS) + ) { + throw new Error( + `timeout must be a finite positive number of seconds, at most ${MAX_BASH_TIMEOUT_SECONDS}.`, + ); + } + const commandTimeoutSeconds = + mode === 'explicit_background' + ? timeout + : mode === 'managed_foreground' + ? Math.min(timeout ?? MAX_MANAGED_BASH_TIMEOUT_SECONDS, MAX_MANAGED_BASH_TIMEOUT_SECONDS) + : Math.min( + timeout ?? DEFAULT_FOREGROUND_BASH_TIMEOUT_SECONDS, + MAX_FOREGROUND_BASH_TIMEOUT_SECONDS, + ); + return { + commandTimeoutSeconds, + ...(timeout !== undefined && timeout !== commandTimeoutSeconds + ? { requestedTimeoutSeconds: timeout } + : {}), + }; +} + +export function validateBashCommand(command: string): void { + if (typeof command !== 'string' || command.trim().length === 0) { + throw new Error('command must be a non-empty shell command.'); + } +} + +export function withLocalBashTiming(result: ToolResult, timing: LocalBashTiming): ToolResult { + const text = + timing.requestedTimeoutSeconds !== undefined + ? `${result.text}\n\n[bash timeout: requested ${timing.requestedTimeoutSeconds}s; effective ${timing.commandTimeoutSeconds}s command limit.]` + : result.text; + return { + ...result, + text, + content: [{ type: 'text', text }], + details: { ...result.details, timing: { ...(result.details?.timing as object), ...timing } }, + }; +} diff --git a/packages/agent-tools/src/desktop/local-pi-tools.ts b/packages/agent-tools/src/desktop/local-pi-tools.ts index 81c0c3ed..a206248c 100644 --- a/packages/agent-tools/src/desktop/local-pi-tools.ts +++ b/packages/agent-tools/src/desktop/local-pi-tools.ts @@ -21,6 +21,19 @@ import { isAbsolute, resolve as resolvePath } from 'node:path'; import { homedir, release } from 'node:os'; import { fileURLToPath } from 'node:url'; import { randomUUID } from 'node:crypto'; +import { resolveBashDescription } from './local-bash-input.js'; +import { + DEFAULT_FOREGROUND_BASH_SOFT_YIELD_MS, + resolveLocalBashTiming, + validateBashCommand, + withLocalBashTiming, + type LocalBashTiming, +} from './local-bash-timing.js'; +import { + localBashResultFromError, + localBashResultFromPi, + readBashExecutionOutcome, +} from './local-bash-result.js'; import { LocalBashToolDef, @@ -65,6 +78,7 @@ import { buildWriteToolResult, createCapturingWriteOperations } from '../shared/ import { applyDesktopTextLimit, DESKTOP_BASH_MAX_BYTES, + DESKTOP_BASH_PREVIEW_BYTES, DESKTOP_READ_TEXT_MAX_BYTES, limitDesktopHeadTailLines, limitDesktopPrefixLines, @@ -372,6 +386,11 @@ export class LocalBashTool implements ToolImpl< // The background executor and the pi-turn-runner fallback apply the same // hook — keep the three spawn sites in sync. this.tool = createBashTool(workspaceRoot, { + output: { + strategy: 'head_tail', + maxBytes: DESKTOP_BASH_PREVIEW_BYTES, + maxLines: Number.MAX_SAFE_INTEGER, + }, spawnHook: (ctx) => { const { env, removed } = sanitizeBashSubprocessEnv(ctx.env, this.envPolicy); this.lastEnvRemoved = removed; @@ -386,6 +405,49 @@ export class LocalBashTool implements ToolImpl< signal?: AbortSignal, ): Promise { if (signal?.aborted) throw new Error('Operation aborted'); + let timing: LocalBashTiming; + let description: string; + try { + description = resolveBashDescription(input.description, input.command); + validateBashCommand(input.command); + timing = resolveLocalBashTiming( + input.timeout, + input.run_in_background + ? 'explicit_background' + : ctx.canConsumeBackgroundBashOutput === true && + ctx.allowBashAutoPromotion !== false && + this.backgroundAdapter?.runManagedForeground + ? 'managed_foreground' + : 'direct_foreground', + ); + } catch (error) { + const text = error instanceof Error ? error.message : String(error); + return { + tool_name: LocalBashToolDef.name, + text, + content: [{ type: 'text', text }], + isError: true, + details: { status: 'invalid_input', error_code: 'BASH_INVALID_INPUT' }, + }; + } + try { + const result = await this.executeValidated(ctx, { ...input, description }, timing, signal); + return { ...result, details: { ...result.details, description } }; + } catch (error) { + if (error instanceof Error) { + const details = (error as Error & { details?: Record }).details; + Object.assign(error, { details: { ...details, description } }); + } + throw error; + } + } + + private async executeValidated( + ctx: LocalRuntimeToolContext, + input: LocalBashToolInput, + timing: LocalBashTiming, + signal?: AbortSignal, + ): Promise { if (input.run_in_background === true && ctx.canConsumeBackgroundBashOutput !== true) { return unavailableBackgroundBashOutputResult(); } @@ -426,16 +488,24 @@ export class LocalBashTool implements ToolImpl< return backgroundBashToolResult( started.taskId, 'started', - this.consumeEnvSanitizationHint(started.details), + this.consumeEnvSanitizationHint({ + ...started.details, + description: input.description, + timing: { ...timing, ...(started.details?.timing as object) }, + }), ); } const { run_in_background: _runInBackground, ...rest } = input; - const foregroundInput = { ...rest, timeout: resolveForegroundTimeout(rest) }; - if (ctx.allowBashAutoPromotion !== false && this.backgroundAdapter?.runManagedForeground) { + const foregroundInput = { ...rest, timeout: timing.commandTimeoutSeconds }; + if ( + ctx.canConsumeBackgroundBashOutput === true && + ctx.allowBashAutoPromotion !== false && + this.backgroundAdapter?.runManagedForeground + ) { const managed = await this.backgroundAdapter.runManagedForeground( ctx, - foregroundInput, + rest, DEFAULT_FOREGROUND_BASH_SOFT_YIELD_MS, signal, ); @@ -444,7 +514,11 @@ export class LocalBashTool implements ToolImpl< return backgroundBashToolResult( managed.taskId, 'auto_promoted', - this.consumeEnvSanitizationHint(managed.details), + this.consumeEnvSanitizationHint({ + ...managed.details, + description: input.description, + timing: { ...timing, ...(managed.details?.timing as object) }, + }), ); } const text = `${managed.errorMessage ?? 'Local managed bash failed to start'}`; @@ -460,27 +534,36 @@ export class LocalBashTool implements ToolImpl< typeof managed.details?.fullOutputPath === 'string' ? managed.details.fullOutputPath : undefined; - const limited = limitDesktopBashOutput(managed.text, managedFullOutputPath); + const managedDetails = this.consumeEnvSanitizationHint(managed.details); - return applyDesktopTextLimit( - { + const result = withLocalBashTiming( + withCompatibleBashToolResponseFromPiDetails({ tool_name: LocalBashToolDef.name, text: managed.text, content: [{ type: 'text', text: managed.text }], details: { ...managedDetails, - status: 'completed', + status: readBashExecutionOutcome(managedDetails)?.status ?? 'completed', task_id: managed.taskId, }, ...(managed.isError ? { isError: true } : {}), - }, - limited, + }), + timing, + ); + return applyDesktopTextLimit( + result, + limitDesktopBashOutput(result.text, managedFullOutputPath, result.details, managed.taskId), ); } let result: ToolResult; try { const tool = this.sandboxOperationsFactory ? createBashTool(this.workspaceRoot, { + output: { + strategy: 'head_tail', + maxBytes: DESKTOP_BASH_PREVIEW_BYTES, + maxLines: Number.MAX_SAFE_INTEGER, + }, operations: this.sandboxOperationsFactory.create({ identity: directForegroundIdentity(ctx), workspaceRoot: this.workspaceRoot, @@ -493,28 +576,29 @@ export class LocalBashTool implements ToolImpl< }) : this.tool; const res = await tool.execute('', foregroundInput, signal); - result = withCompatibleBashToolResponseFromPiDetails( - toToolResult(LocalBashToolDef.name, res), - ); + const completed = localBashResultFromPi(res); + result = withCompatibleBashToolResponseFromPiDetails({ + tool_name: LocalBashToolDef.name, + ...completed, + content: [{ type: 'text', text: completed.text }], + }); } catch (error) { if (signal?.aborted || (error instanceof Error && error.name === 'AbortError')) { throw error; } - const text = error instanceof Error ? error.message : String(error); - const fullOutputPath = extractBashFullOutputPath(text); - result = { + const failed = localBashResultFromError(error, signal); + result = withCompatibleBashToolResponseFromPiDetails({ tool_name: LocalBashToolDef.name, - text, - content: [{ type: 'text', text }], - ...(fullOutputPath ? { details: { fullOutputPath } } : {}), - isError: true, - }; + ...failed, + content: [{ type: 'text', text: failed.text }], + }); } + result = withLocalBashTiming(result, timing); const fullOutputPath = typeof result.details?.fullOutputPath === 'string' ? result.details.fullOutputPath : undefined; - const limited = limitDesktopBashOutput(result.text, fullOutputPath); + const limited = limitDesktopBashOutput(result.text, fullOutputPath, result.details); result = applyDesktopTextLimit(result, limited); result.details = this.consumeEnvSanitizationHint({ ...(result.details ?? {}), @@ -550,57 +634,67 @@ function directForegroundIdentity(ctx: LocalRuntimeToolContext): LocalSandboxInv }; } -function limitDesktopBashOutput(text: string, fullOutputPath?: string) { - const limited = limitDesktopHeadTailLines(text, { +function limitDesktopBashOutput( + text: string, + fullOutputPath?: string, + details?: Record, + taskId?: string, +) { + const output = details?.output as { rawBytes?: number; persistenceError?: string } | undefined; + let previewText = text; + if (!taskId) { + if ((details?.truncation as { truncated?: boolean } | undefined)?.truncated) { + previewText += `\n\n[desktop bash output truncated: original_bytes=${output?.rawBytes}; original head+tail shown.${fullOutputPath ? ` Full output: ${fullOutputPath}` : ''}]`; + } + if (output?.persistenceError) { + previewText += `\n\n[Output persistence failed; complete output is unavailable: ${output.persistenceError}]`; + } + } + const limited = limitDesktopHeadTailLines(previewText, { maxBytes: DESKTOP_BASH_MAX_BYTES, notice: ({ originalBytes }) => fullOutputPath ? `[desktop bash output truncated: original_bytes=${originalBytes}; head+tail shown; boundary lines may be UTF-8 byte-truncated; Full output: ${fullOutputPath}]` - : `[desktop bash output truncated: original_bytes=${originalBytes}; head+tail shown; boundary lines may be UTF-8 byte-truncated; rerun with a narrower command or redirect output to a file and use read.]`, + : `[desktop bash output truncated: original_bytes=${originalBytes}; head+tail shown; boundary lines may be UTF-8 byte-truncated.]`, }); + if ( + limited.truncation || + (details?.truncation as { truncated?: boolean } | undefined)?.truncated + ) { + limited.truncation = { + truncated: true, + has_more: true, + strategy: 'head_tail_lines', + original_bytes: + output?.rawBytes ?? limited.truncation?.original_bytes ?? Buffer.byteLength(text, 'utf8'), + returned_bytes: Buffer.byteLength(limited.text, 'utf8'), + max_bytes: DESKTOP_BASH_MAX_BYTES, + }; + } return withDesktopOutputContinuation(limited, { - continuation_hint: fullOutputPath + continuation_hint: taskId ? { - tool: 'read', - preserve_args: ['path'], - instruction: `Read the full output at ${fullOutputPath}; continue long reads with the returned next_offset.`, + tool: 'task_output', + preserve_args: ['task_id'], + instruction: `Read task ${taskId} with task_output; continue with its next_offset (UTF-8 log file bytes). Check output persistence status before treating the log as complete.`, } - : { - tool: 'bash', - preserve_args: [], - instruction: - 'Do not blindly rerun a potentially side-effecting command; narrow it or redirect output to a file, then use read.', - }, + : fullOutputPath + ? { + tool: 'read', + preserve_args: ['path'], + instruction: `Read the full output at ${fullOutputPath}; continue long reads with the returned next_offset.`, + } + : { + tool: 'bash', + preserve_args: [], + instruction: + 'Do not blindly rerun a potentially side-effecting command; narrow it or redirect output to a file, then use read.', + }, }); } -function extractBashFullOutputPath(text: string): string | undefined { - const matches = [...text.matchAll(/Full output:\s*(.+?)\](?:\n|$)/gi)]; - const path = matches.at(-1)?.[1]?.trim(); - return path || undefined; -} - -/** - * Default foreground timeout (seconds) applied when the model omits `timeout`. - * Restores main's historical 120s bound so interactive/blocking foreground - * commands (e.g. `lark-cli auth login`) cannot hang forever. Background tasks - * (`run_in_background: true`) are intentionally NOT bounded by this. - */ -export const DEFAULT_FOREGROUND_BASH_TIMEOUT_SECONDS = 120; -export const MAX_FOREGROUND_BASH_TIMEOUT_SECONDS = 300; -export const DEFAULT_FOREGROUND_BASH_SOFT_YIELD_MS = 15_000; - -/** - * Keep interactive foreground commands bounded. Missing, non-finite and - * non-positive values use the 120s default; larger values are capped at 5m. - * Longer commands should opt into background execution explicitly. - */ export function resolveForegroundTimeout(input: { timeout?: number }): number { - const requested = input.timeout; - if (requested === undefined || !Number.isFinite(requested) || requested <= 0) { - return DEFAULT_FOREGROUND_BASH_TIMEOUT_SECONDS; - } - return Math.min(requested, MAX_FOREGROUND_BASH_TIMEOUT_SECONDS); + return resolveLocalBashTiming(input.timeout, 'direct_foreground').commandTimeoutSeconds!; } function backgroundBashToolResult( @@ -611,8 +705,15 @@ function backgroundBashToolResult( const lead = status === 'auto_promoted' ? 'The command is still running and was yielded to a managed background task without restarting it.' - : 'Started local background bash task.'; - const text = `\n${lead} The owning conversation will automatically resume when it finishes; use task_query/task_output to inspect incremental output and task_stop to cancel it.\n`; + : 'Background Bash task accepted; command startup may still be in progress.'; + const timing = details.timing as LocalBashTiming | undefined; + const timingText = + timing?.commandTimeoutSeconds !== undefined + ? ` Command limit: ${timing.commandTimeoutSeconds}s total; yielding does not reset it.` + : ''; + const purpose = + typeof details.description === 'string' ? `Purpose: ${details.description}\n` : ''; + const text = `\n${purpose}${lead}${timingText} The owning conversation will automatically resume when it finishes; use task_output to inspect incremental output, and task_stop to cancel if that tool is available.\n`; return { tool_name: LocalBashToolDef.name, text, @@ -735,3 +836,9 @@ async function pathExists(filePath: string): Promise { function isFsNotFoundError(err: unknown): boolean { return typeof err === 'object' && err !== null && (err as { code?: unknown }).code === 'ENOENT'; } + +export { + DEFAULT_FOREGROUND_BASH_TIMEOUT_SECONDS, + MAX_FOREGROUND_BASH_TIMEOUT_SECONDS, + DEFAULT_FOREGROUND_BASH_SOFT_YIELD_MS, +} from './local-bash-timing.js'; diff --git a/packages/agent-tools/src/desktop/local-task-control.ts b/packages/agent-tools/src/desktop/local-task-control.ts index 48f7f18b..cb05921f 100644 --- a/packages/agent-tools/src/desktop/local-task-control.ts +++ b/packages/agent-tools/src/desktop/local-task-control.ts @@ -14,6 +14,7 @@ import type { LocalTaskControlAdapter, LocalTaskOutputReadResult, } from './types.js'; +import { formatBashExecutionOutcome, readBashExecutionOutcome } from './local-bash-result.js'; const MAX_TASK_OUTPUT_WAIT_MS = 30_000; @@ -82,6 +83,13 @@ export class LocalTaskOutputTool implements ToolImpl< ...(signal ? { signal } : {}), }); const status = read.status ?? task.status; + const snapshot = read.task ?? task; + const execution = readBashExecutionOutcome(snapshot.metadata?.bashDetails); + const output = (snapshot.metadata?.bashDetails as Record | undefined)?.output; + const persistenceHint = + (output as { persistence?: string } | undefined)?.persistence === 'incomplete' + ? 'Output persistence is incomplete; this log may be missing command output.\n' + : ''; const body = read.content || '(no output yet)'; const cursor = [ ...(read.nextOffset === undefined ? [] : [`next_offset="${read.nextOffset}"`]), @@ -94,10 +102,18 @@ export class LocalTaskOutputTool implements ToolImpl< ? '' : 'Your requested wait_ms exceeded 30000 ms and was capped at 30000 ms (30 seconds). This output read reached its wait limit; the background task was not stopped. Use wait_ms=30000 or less for future reads.\n'; const hint = signal?.aborted ? '' : this.pollingHint(ctx, input.task_id, { ...read, status }); - const text = `\n${receipt}${waitLimitHint}${hint}${body}\n`; - return ok(LocalTaskOutputToolDef.name, text, { + const outcome = execution + ? `${formatBashExecutionOutcome(execution)}\n` + : ''; + const text = `\n${receipt}${waitLimitHint}${persistenceHint}${hint}${outcome}${body}\n`; + const result = ok(LocalTaskOutputToolDef.name, text, { task_id: input.task_id, status, + ...(execution ? { execution } : {}), + ...(output ? { output } : {}), + timing: + (snapshot.metadata?.bashDetails as Record | undefined)?.timing ?? + snapshot.metadata?.timing, effective_wait_ms: effectiveWaitMs, wait_ms_clamped: waitMsClamped, ...(read.timedOut === undefined ? {} : { timed_out: read.timedOut }), @@ -105,6 +121,7 @@ export class LocalTaskOutputTool implements ToolImpl< ...(read.truncated === undefined ? {} : { truncated: read.truncated }), ...(read.summary ? { summary: read.summary } : {}), }); + return result; } private pollingHint( @@ -152,8 +169,21 @@ export class LocalTaskStopTool implements ToolImpl< async execute(ctx: LocalRuntimeToolContext, input: LocalTaskStopToolInput): Promise { const task = await this.adapter.stop(ctx, input.task_id, input.reason); if (!task) return notFound(LocalTaskStopToolDef.name, input.task_id); - const text = `\nStop requested for ${input.task_id}.\n`; - return ok(LocalTaskStopToolDef.name, text, { task_id: input.task_id, status: task.status }); + const stopError = + task.metadata?.stopError ?? + (task.lastError?.code === 'TASK_STOP_FAILED' ? task.lastError : undefined); + const message = stopError + ? `Stop failed for ${input.task_id}: ${(stopError as { message: string }).message}. Process cleanup could not be confirmed.` + : `Task ${input.task_id} is ${task.status}.`; + const text = `\n${message}\n`; + return { + ...ok(LocalTaskStopToolDef.name, text, { + task_id: input.task_id, + status: task.status, + ...(stopError ? { stopError } : {}), + }), + ...(stopError ? { isError: true } : {}), + }; } } @@ -167,16 +197,28 @@ function renderTask(task: BackgroundTask): string { ...(mode ? [`execution_mode=${mode}`] : []), ]; const lineageText = lineage.length === 0 ? '' : ` (${lineage.join(' ')})`; - return `- ${task.taskId} [${task.kind}/${task.status}]${description}${lineageText}`; + const execution = readBashExecutionOutcome(task.metadata?.bashDetails); + const outcome = execution ? ` — ${formatBashExecutionOutcome(execution)}` : ''; + const output = (task.metadata?.bashDetails as { output?: { persistence?: string } } | undefined) + ?.output; + const persistence = output?.persistence === 'incomplete' ? ' — output log incomplete' : ''; + return `- ${task.taskId} [${task.kind}/${task.status}]${description}${lineageText}${outcome}${persistence}`; } function summarizeTask(task: BackgroundTask): Record { const sessionId = childSessionId(task); const mode = executionMode(task); + const execution = readBashExecutionOutcome(task.metadata?.bashDetails); return { task_id: task.taskId, kind: task.kind, status: task.status, + ...(execution ? { execution } : {}), + output: (task.metadata?.bashDetails as Record | undefined)?.output, + timing: + (task.metadata?.bashDetails as Record | undefined)?.timing ?? + task.metadata?.timing, + ...(task.metadata?.stopError ? { stopError: task.metadata.stopError } : {}), // Handle recovery after the first receipt scrolled out of context. Only a // subagent task that really owns a child Session gets a session handle. ...(sessionId ? { session_id: sessionId } : {}), diff --git a/packages/agent-tools/src/desktop/output-limit.ts b/packages/agent-tools/src/desktop/output-limit.ts index b7225c83..eaf2e971 100644 --- a/packages/agent-tools/src/desktop/output-limit.ts +++ b/packages/agent-tools/src/desktop/output-limit.ts @@ -3,12 +3,14 @@ import type { ToolResult, ToolResultContent } from '@mavis/agent-core/tools'; export const DESKTOP_GREP_CONTENT_MAX_BYTES = 16 * 1024; export const DESKTOP_READ_TEXT_MAX_BYTES = 24 * 1024; export const DESKTOP_BASH_MAX_BYTES = 24 * 1024; +// Leave room for exit/timing receipts and the readable output reference. +export const DESKTOP_BASH_PREVIEW_BYTES = DESKTOP_BASH_MAX_BYTES - 2048; export type DesktopOutputLimitStrategy = 'prefix_lines' | 'head_tail_lines'; export type DesktopOutputOffsetUnit = 'line' | 'match' | 'file'; export interface DesktopOutputContinuationHint { - tool: 'read' | 'grep' | 'glob' | 'mavis' | 'bash'; + tool: 'read' | 'grep' | 'glob' | 'mavis' | 'bash' | 'task_output'; preserve_args: readonly string[]; instruction: string; } diff --git a/packages/agent-tools/src/desktop/types.ts b/packages/agent-tools/src/desktop/types.ts index 20f6f0b6..69f02edb 100644 --- a/packages/agent-tools/src/desktop/types.ts +++ b/packages/agent-tools/src/desktop/types.ts @@ -322,6 +322,7 @@ export interface LocalSandboxBashOperationsFactory { export interface LocalBackgroundBashExecutorResult { text: string; details?: Record; + isError?: boolean; } export interface LocalBackgroundBashExecutor { @@ -434,6 +435,8 @@ export interface LocalTaskOutputReadOptions extends TaskOutputReadOptions { export interface LocalTaskOutputReadResult extends TaskOutputReadResult { /** Status read from the same post-wait snapshot as the returned output. */ status?: BackgroundTaskStatus; + /** Task facts from the same post-wait snapshot as status. */ + task?: BackgroundTask; timedOut?: boolean; } diff --git a/packages/agent-tools/test/desktop/local-bash-output.test.ts b/packages/agent-tools/test/desktop/local-bash-output.test.ts new file mode 100644 index 00000000..ef3228c8 --- /dev/null +++ b/packages/agent-tools/test/desktop/local-bash-output.test.ts @@ -0,0 +1,207 @@ +import { mkdtemp, readFile, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { LocalBashTool } from '../../src/desktop/local-pi-tools.js'; +import { DESKTOP_BASH_MAX_BYTES } from '../../src/desktop/output-limit.js'; +import { readPluginHookCompatibleToolResponse } from '../../src/plugin-hooks/vendor-tool-response.js'; +import type { LocalRuntimeToolContext } from '../../src/desktop/types.js'; + +const SESSION_CTX = { + sessionId: 'sess-test', + turnId: 'turn-test', +} satisfies LocalRuntimeToolContext; + +describe('LocalBashTool — foreground output truncation', () => { + let workspace: string; + + beforeEach(async () => { + workspace = await mkdtemp(join(tmpdir(), 'bash-output-')); + }); + afterEach(async () => { + await rm(workspace, { recursive: true, force: true }); + }); + + it('preserves exact stdout and stderr for Compatible PostToolUse', async () => { + const tool = new LocalBashTool(workspace, undefined, { mode: 'off' }); + const result = await tool.execute(SESSION_CTX, { + command: "printf 'from-stdout'; printf 'from-stderr' >&2", + }); + + expect(readPluginHookCompatibleToolResponse(result)).toEqual({ + stdout: 'from-stdout', + stderr: 'from-stderr', + interrupted: false, + }); + }); + + it('short output is returned verbatim, not truncated', async () => { + const tool = new LocalBashTool(workspace, undefined, { mode: 'off' }); + const result = await tool.execute(SESSION_CTX, { command: 'printf "a\\nb\\nc\\n"' }); + expect(result.tool_name).toBe('bash'); + expect(result.isError).toBeFalsy(); + expect(result.text).toContain('a'); + expect(result.text.toLowerCase()).not.toContain('full output:'); + }); + + it.skipIf(process.platform === 'win32')('reports signal termination as a failed tool result with partial output', async () => { + const tool = new LocalBashTool(workspace, undefined, { mode: 'off' }); + const result = await tool.execute(SESSION_CTX, { + command: "printf 'partial-output'; kill -TERM $$", + timeout: 3, + }); + + expect(result.isError).toBe(true); + expect(result.text).toContain('partial-output'); + expect(result.text).toContain('Command terminated by signal SIGTERM'); + expect(result.details).toMatchObject({ + execution: { status: 'failed', reason: 'signaled', exitCode: null, signal: 'SIGTERM' }, + processOutput: { stdout: 'partial-output', stderr: '' }, + }); + }); + + it('preserves original head and tail with one readable full output file', async () => { + const tool = new LocalBashTool(workspace, undefined, { mode: 'off' }); + const script = `for(let i=0;i<400;i++) console.log('row-'+i+'-'+'x'.repeat(90))`; + const command = `${JSON.stringify(process.execPath)} -e ${JSON.stringify(script)}`; + const result = await tool.execute(SESSION_CTX, { command }); + + expect(Buffer.byteLength(result.text, 'utf8')).toBeLessThanOrEqual( + DESKTOP_BASH_MAX_BYTES, + ); + expect(result.text).toContain('row-0-'); + expect(result.text).toContain('row-399-'); + expect(result.text).not.toContain('row-200-'); + expect(result.text.toLowerCase()).toContain('full output:'); + const full = await readFile(result.details?.fullOutputPath as string, 'utf8'); + expect(full).toContain('row-200-'); + expect(result.details?.output).toMatchObject({ rawBytes: Buffer.byteLength(full), persistence: 'complete' }); + expect(result.details?.desktop_output_truncation).toMatchObject({ + truncated: true, + has_more: true, + strategy: 'head_tail_lines', + max_bytes: DESKTOP_BASH_MAX_BYTES, + continuation_hint: { + tool: 'read', + preserve_args: ['path'], + }, + }); + expect(result.details?.desktop_output_truncation).not.toHaveProperty('next_offset'); + expect(result.details?.desktop_output_truncation).not.toHaveProperty('offset_unit'); + }); + + it('caps a thrown timeout result while preserving the error ToolResult status', async () => { + const tool = new LocalBashTool(workspace, undefined, { mode: 'off' }); + const innerExecute = vi + .spyOn((tool as unknown as { tool: { execute: unknown } }).tool, 'execute') + .mockRejectedValue( + new Error( + `${'timeout-output\n'.repeat(2_000)}\n\nCommand timed out after 1 seconds`, + ), + ); + + const result = await tool.execute(SESSION_CTX, { command: 'ignored', timeout: 1 }); + + expect(innerExecute).toHaveBeenCalledOnce(); + expect(result.isError).toBe(true); + expect(Buffer.byteLength(result.text, 'utf8')).toBeLessThanOrEqual( + DESKTOP_BASH_MAX_BYTES, + ); + expect(result.text).toContain('Command timed out after 1 seconds'); + expect(result.text).toContain('desktop bash output truncated'); + expect(result.details?.desktop_output_truncation).toMatchObject({ + truncated: true, + has_more: true, + strategy: 'head_tail_lines', + max_bytes: DESKTOP_BASH_MAX_BYTES, + continuation_hint: { + tool: 'bash', + preserve_args: [], + }, + }); + }); + + it('still propagates an active AbortSignal instead of converting cancellation to a tool result', async () => { + const tool = new LocalBashTool(workspace, undefined, { mode: 'off' }); + const controller = new AbortController(); + vi.spyOn((tool as unknown as { tool: { execute: unknown } }).tool, 'execute').mockImplementation( + async () => { + controller.abort(); + throw new Error('Command aborted'); + }, + ); + + await expect( + tool.execute(SESSION_CTX, { command: 'ignored' }, controller.signal), + ).rejects.toThrow('Command aborted'); + }); + + it('caps a large nonzero-exit result while preserving the error ToolResult status', async () => { + const tool = new LocalBashTool(workspace, undefined, { mode: 'off' }); + const script = `for(let i=0;i<400;i++) console.error('error-row-'+i+'-'+'e'.repeat(90)); process.exit(7)`; + const command = `${JSON.stringify(process.execPath)} -e ${JSON.stringify(script)}`; + const result = await tool.execute(SESSION_CTX, { command }); + + expect(result.isError).toBe(true); + expect(Buffer.byteLength(result.text, 'utf8')).toBeLessThanOrEqual( + DESKTOP_BASH_MAX_BYTES, + ); + expect(result.text).toContain('error-row-0-'); + expect(result.text).toContain('error-row-399-'); + expect(result.text).not.toContain('error-row-200-'); + expect(result.text).toContain('Command exited with code 7'); + expect(result.details).toMatchObject({ + execution: { status: 'failed', reason: 'exited', exitCode: 7 }, + processOutput: { exitCode: 7, interrupted: false }, + }); + expect(result.text).toContain('desktop bash output truncated'); + expect(result.details?.desktop_output_truncation).toMatchObject({ + truncated: true, + has_more: true, + strategy: 'head_tail_lines', + max_bytes: DESKTOP_BASH_MAX_BYTES, + continuation_hint: { + tool: 'read', + preserve_args: ['path'], + }, + }); + }); + + it('output beyond 2000 lines retains the original first and last lines', async () => { + const tool = new LocalBashTool(workspace, undefined, { mode: 'off' }); + const script = `for(let i=0;i<3000;i++) console.log('spill-row-'+i+'-'+'z'.repeat(50))`; + const command = `${JSON.stringify(process.execPath)} -e ${JSON.stringify(script)}`; + const result = await tool.execute(SESSION_CTX, { command }); + + const lower = result.text.toLowerCase(); + expect(lower).toContain('full output:'); + expect(lower).toContain('original head+tail'); + expect(Buffer.byteLength(result.text, 'utf8')).toBeLessThanOrEqual( + DESKTOP_BASH_MAX_BYTES, + ); + + expect(result.text).toContain('spill-row-2999-'); + expect(result.text).toContain('spill-row-0-'); + expect(result.details?.desktop_output_truncation).toMatchObject({ + truncated: true, + has_more: true, + strategy: 'head_tail_lines', + max_bytes: DESKTOP_BASH_MAX_BYTES, + continuation_hint: { + tool: 'read', + preserve_args: ['path'], + }, + }); + + // Recover the spilled path from the footer and confirm it holds the FULL output. + const m = result.text.match(/full output:\s*(.+?)\]/i); + expect(m).not.toBeNull(); + const full = await readFile((m as RegExpMatchArray)[1].trim(), 'utf-8'); + const fullLines = full.split('\n').filter((l) => l.length > 0); + expect(fullLines).toHaveLength(3000); // complete output, not the 50 KiB tail + expect(fullLines[0]).toContain('spill-row-0-'); + expect(fullLines[fullLines.length - 1]).toContain('spill-row-2999-'); + }); +}); diff --git a/packages/agent-tools/test/desktop/local-bash-timeout.test.ts b/packages/agent-tools/test/desktop/local-bash-timeout.test.ts new file mode 100644 index 00000000..e409ad9b --- /dev/null +++ b/packages/agent-tools/test/desktop/local-bash-timeout.test.ts @@ -0,0 +1,506 @@ +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { Value } from '@sinclair/typebox/value'; + +import { LocalBashToolDef } from '../../src/desktop/builtin-defs.js'; +import { createLocalBashToolDefinition } from '../../src/desktop/local-bash-contract.js'; +import { + DEFAULT_FOREGROUND_BASH_TIMEOUT_SECONDS, + DEFAULT_FOREGROUND_BASH_SOFT_YIELD_MS, + LocalBashTool, + MAX_FOREGROUND_BASH_TIMEOUT_SECONDS, + resolveForegroundTimeout, +} from '../../src/desktop/local-pi-tools.js'; +import type { + LocalBashAdapter, + LocalBashBackgroundStartResult, + LocalRuntimeToolContext, + LocalSandboxBashOperationsFactory, +} from '../../src/desktop/types.js'; + +const SESSION_CTX = { + sessionId: 'sess-test', + turnId: 'turn-test', +} satisfies LocalRuntimeToolContext; + +function timeoutDescription(): string { + const props = (LocalBashToolDef.schema as { properties: Record }) + .properties; + return props.timeout?.description ?? ''; +} + +describe('resolveForegroundTimeout (bounded foreground helper)', () => { + // TS-1 (P0): omitted timeout → 120s default. + it('defaults to 120 seconds when timeout is omitted', () => { + expect(resolveForegroundTimeout({})).toBe(120); + expect(resolveForegroundTimeout({})).toBe(DEFAULT_FOREGROUND_BASH_TIMEOUT_SECONDS); + expect(DEFAULT_FOREGROUND_BASH_TIMEOUT_SECONDS).toBe(120); + }); + + it('defaults to 120 seconds when timeout is explicitly undefined', () => { + expect(resolveForegroundTimeout({ timeout: undefined })).toBe(120); + }); + + it('honors a positive timeout up to the 300-second foreground cap', () => { + expect(resolveForegroundTimeout({ timeout: 5 })).toBe(5); + expect(resolveForegroundTimeout({ timeout: 100000 })).toBe(300); + expect(MAX_FOREGROUND_BASH_TIMEOUT_SECONDS).toBe(300); + }); + + it.each([0, -5, NaN, Infinity, 2_147_484])('rejects an invalid timeout %s before execution', (timeout) => { + expect(() => resolveForegroundTimeout({ timeout })).toThrow('finite positive'); + }); + + it('preserves fractional seconds', () => { + expect(resolveForegroundTimeout({ timeout: 0.125 })).toBe(0.125); + }); +}); + +it('requires a positive timeout in the shared schema', () => { + expect(Value.Check(LocalBashToolDef.schema, { description: '运行测试命令', command: 'echo ok', timeout: 0.5 })).toBe(true); + expect(Value.Check(LocalBashToolDef.schema, { description: '运行测试命令', command: 'echo ok', timeout: 0 })).toBe(false); +}); + +describe('LocalBashTool input timing contract', () => { + it.each([null, 42, false])('rejects non-string description %j before dispatch', async (description) => { + const startBackground = vi.fn(); + const runManagedForeground = vi.fn(); + const tool = new LocalBashTool('/tmp/workspace', { startBackground, runManagedForeground }, { mode: 'off' }); + const input = { command: 'echo ok', description }; + expect(Value.Check(LocalBashToolDef.schema, input)).toBe(false); + const result = await tool.execute({ ...SESSION_CTX, canConsumeBackgroundBashOutput: true }, input as never); + expect(result).toMatchObject({ isError: true, details: { error_code: 'BASH_INVALID_INPUT' } }); + expect(startBackground).not.toHaveBeenCalled(); + expect(runManagedForeground).not.toHaveBeenCalled(); + }); + + it.each([undefined, ''])('uses the command when description is %j', async (description) => { + const startBackground = vi.fn(async () => ({ status: 'started' as const, taskId: 'task-description' })); + const tool = new LocalBashTool('/tmp/workspace', { startBackground }, { mode: 'off' }); + const result = await tool.execute({ ...SESSION_CTX, canConsumeBackgroundBashOutput: true }, { + command: 'echo exact', description, run_in_background: true, + }); + expect(startBackground.mock.calls[0]?.[1]).toMatchObject({ command: 'echo exact', description: 'echo exact' }); + expect(result.details?.description).toBe('echo exact'); + }); + + it('keeps the purpose separate from the executable command', async () => { + const startBackground = vi.fn(async () => ({ status: 'started' as const, taskId: 'task-description' })); + const tool = new LocalBashTool('/tmp/workspace', { startBackground }, { mode: 'off' }); + const result = await tool.execute({ ...SESSION_CTX, canConsumeBackgroundBashOutput: true }, { + command: ' echo exact ', description: '检查工作区状态', run_in_background: true, + }); + expect(startBackground.mock.calls[0]?.[1]).toMatchObject({ command: ' echo exact ', description: '检查工作区状态' }); + expect(result.text).toContain('Purpose: 检查工作区状态'); + expect(result.details?.description).toBe('检查工作区状态'); + }); + + it.each([false, true])('rejects invalid parameters without dispatching (background=%s)', async (background) => { + const startBackground = vi.fn(); + const runManagedForeground = vi.fn(); + const tool = new LocalBashTool('/tmp/workspace', { startBackground, runManagedForeground }, { mode: 'off' }); + for (const input of [{ description: '运行测试命令', command: 'echo ok', timeout: 0 }, { description: '运行测试命令', command: 'echo ok', timeout: Infinity }, { description: '运行测试命令', command: ' ' }]) { + const result = await tool.execute({ ...SESSION_CTX, canConsumeBackgroundBashOutput: true }, { ...input, run_in_background: background }); + expect(result).toMatchObject({ isError: true, details: { error_code: 'BASH_INVALID_INPUT' } }); + } + expect(startBackground).not.toHaveBeenCalled(); + expect(runManagedForeground).not.toHaveBeenCalled(); + }); + + it('reports a capped foreground timeout while preserving the command text', async () => { + const tool = new LocalBashTool('/tmp/workspace', undefined, { mode: 'off' }); + const execute = vi.spyOn((tool as unknown as { tool: { execute: unknown } }).tool, 'execute') + .mockResolvedValue({ content: [{ type: 'text', text: 'ok' }], details: {} } as never); + const result = await tool.execute(SESSION_CTX, { description: '运行测试命令', command: ' echo ok ', timeout: 600 }); + expect(execute.mock.calls[0]?.[1]).toEqual({ description: '运行测试命令', command: ' echo ok ', timeout: 300 }); + expect(result.details).toMatchObject({ timing: { requestedTimeoutSeconds: 600, commandTimeoutSeconds: 300 } }); + expect(result.text).toContain('requested 600s; effective 300s'); + }); +}); + +describe('LocalBashTool.execute — background path', () => { + // TS-3 (P0): run_in_background:true (no timeout) → 120 default NOT applied; + // dispatches to the background adapter with the input untouched. + it('dispatches to the background adapter without injecting the 120s default', async () => { + const started: LocalBashBackgroundStartResult = { + status: 'started', + taskId: 'task-1', + details: { envSanitized: ['MAVIS_ACCESS_TOKEN'] }, + }; + const startBackground = vi.fn( + async (): Promise => started, + ); + const adapter: LocalBashAdapter = { startBackground }; + const tool = new LocalBashTool('/tmp/does-not-matter', adapter, { mode: 'off' }); + + const result = await tool.execute({ ...SESSION_CTX, canConsumeBackgroundBashOutput: true }, { + description: '运行测试命令', + command: 'echo hi', + run_in_background: true, + }); + + expect(startBackground).toHaveBeenCalledTimes(1); + const forwarded = startBackground.mock.calls[0]?.[1]; + // Background path must receive the input verbatim — no 120 injected. + expect(forwarded).toEqual({ description: '运行测试命令', command: 'echo hi', run_in_background: true }); + expect((forwarded as { timeout?: number }).timeout).toBeUndefined(); + expect(result.details).toMatchObject({ + status: 'started', + task_id: 'task-1', + envSanitized: ['MAVIS_ACCESS_TOKEN'], + }); + }); +}); + +describe('LocalBashTool.execute — foreground path forwarding (TS-4)', () => { + // TS-4 (P1): forwarded foreground input carries resolved timeout and NO + // run_in_background key. We spy on the inner pi tool to capture the forward. + it('forwards resolved timeout and strips run_in_background', async () => { + const tool = new LocalBashTool('/tmp/does-not-matter', undefined, { mode: 'off' }); + const innerExecute = vi + .spyOn((tool as unknown as { tool: { execute: unknown } }).tool, 'execute') + .mockResolvedValue({ content: [{ type: 'text', text: 'ok' }], details: {} } as never); + + await tool.execute(SESSION_CTX, { description: '运行测试命令', command: 'echo hi', run_in_background: false }); + + expect(innerExecute).toHaveBeenCalledTimes(1); + const forwarded = (innerExecute.mock.calls[0] as unknown[])[1] as Record; + expect(forwarded).toEqual({ description: '运行测试命令', command: 'echo hi', timeout: 120 }); + expect(forwarded).not.toHaveProperty('run_in_background'); + }); + + it('forwards an explicit timeout unchanged and strips run_in_background', async () => { + const tool = new LocalBashTool('/tmp/does-not-matter', undefined, { mode: 'off' }); + const innerExecute = vi + .spyOn((tool as unknown as { tool: { execute: unknown } }).tool, 'execute') + .mockResolvedValue({ content: [{ type: 'text', text: 'ok' }], details: {} } as never); + + await tool.execute(SESSION_CTX, { description: '运行测试命令', command: 'echo hi', timeout: 7 }); + + const forwarded = (innerExecute.mock.calls[0] as unknown[])[1] as Record; + expect(forwarded).toEqual({ description: '运行测试命令', command: 'echo hi', timeout: 7 }); + expect(forwarded).not.toHaveProperty('run_in_background'); + }); + + it('carries the trusted Explore read-only restriction through direct foreground identity', async () => { + const create = vi.fn(() => ({ + exec: async () => ({ exitCode: 0 }), + })); + const tool = new LocalBashTool( + '/tmp/does-not-matter', + undefined, + { mode: 'off' }, + undefined, + { create }, + ); + const ctx = { + ...SESSION_CTX, + toolCallId: 'call-direct', + forceReadOnlyFilesystem: true, + }; + + await tool.execute(ctx, { description: '运行测试命令', command: 'true' }); + + expect(create).toHaveBeenCalledWith({ + identity: { + operationClass: 'direct_foreground', + invocationId: 'call-direct', + sessionId: 'sess-test', + turnId: 'turn-test', + toolCallId: 'call-direct', + forceReadOnlyFilesystem: true, + }, + workspaceRoot: '/tmp/does-not-matter', + }); + }); + + it('uses a unique runtime id without leaking command text when old callers omit toolCallId', async () => { + const create = vi.fn(() => ({ + exec: async () => ({ exitCode: 0 }), + })); + const tool = new LocalBashTool( + '/tmp/does-not-matter', + undefined, + { mode: 'off' }, + undefined, + { create }, + ); + + await tool.execute(SESSION_CTX, { description: '运行测试命令', command: 'secret-command-one' }); + await tool.execute(SESSION_CTX, { description: '运行测试命令', command: 'secret-command-two' }); + + const identities = create.mock.calls.map(([input]) => input.identity); + expect(identities).toHaveLength(2); + expect(identities[0]).toMatchObject({ + operationClass: 'direct_foreground', + sessionId: 'sess-test', + turnId: 'turn-test', + }); + expect(identities[0]?.invocationId).toMatch(/^runtime_[0-9a-f-]{36}$/); + expect(identities[1]?.invocationId).not.toBe(identities[0]?.invocationId); + expect(JSON.stringify(identities)).not.toContain('secret-command'); + }); +}); + +describe('LocalBashTool.execute — managed foreground soft yield', () => { + it.each([0, 7])('returns direct output and exit code %i when native output is absent', async (exitCode) => { + const runManagedForeground = vi.fn(); + const tool = new LocalBashTool('/tmp/workspace', { startBackground: vi.fn(), runManagedForeground }, { mode: 'off' }, undefined, { + create: () => ({ exec: async (_command, _cwd, opts) => { + opts.onData(Buffer.from('direct stdout and stderr')); + return { exitCode }; + } }), + }); + const result = await tool.execute({ ...SESSION_CTX, allowBashAutoPromotion: true }, { description: '运行测试命令', command: 'controlled command' }); + expect(runManagedForeground).not.toHaveBeenCalled(); + expect(result.text).toContain('direct stdout and stderr'); + expect(Boolean(result.isError)).toBe(exitCode !== 0); + expect(result.details?.task_id).toBeUndefined(); + }); + + it('forwards cancellation to foreground operations when promotion is disabled', async () => { + const controller = new AbortController(); + let started!: () => void; + const ready = new Promise((resolve) => { started = resolve; }); + const runManagedForeground = vi.fn(); + const tool = new LocalBashTool('/tmp/workspace', { startBackground: vi.fn(), runManagedForeground }, { mode: 'off' }, undefined, { + create: () => ({ exec: async (_command, _cwd, opts) => { + expect(opts.signal).toBe(controller.signal); + return new Promise((_, reject) => { + opts.signal?.addEventListener('abort', () => reject(new Error('aborted')), { once: true }); + started(); + }); + } }), + }); + const pending = tool.execute({ ...SESSION_CTX, allowBashAutoPromotion: false }, { description: '运行测试命令', command: 'controlled command' }, controller.signal); + const rejection = expect(pending).rejects.toThrow(/abort/i); + await ready; + controller.abort(); + await rejection; + expect(runManagedForeground).not.toHaveBeenCalled(); + }); + + it('refuses explicit background Bash before starting a task when native output is unavailable', async () => { + const startBackground = vi.fn(async () => ({ status: 'started' as const, taskId: 'unexpected' })); + const tool = new LocalBashTool( + '/tmp/workspace', + { startBackground, runManagedForeground: vi.fn() }, + { mode: 'off' }, + ); + + const result = await tool.execute( + { ...SESSION_CTX, canConsumeBackgroundBashOutput: false }, + { description: '运行测试命令', command: 'controlled command', run_in_background: true }, + ); + + expect(result).toMatchObject({ + isError: true, + details: { + status: 'background_output_unavailable', + error_code: 'BASH_BACKGROUND_OUTPUT_UNAVAILABLE', + }, + }); + expect(result.text).toContain('missing task_output'); + expect(result.text).toContain('foreground instead'); + expect(startBackground).not.toHaveBeenCalled(); + }); + + it('allows explicit background execution when native output is admitted', async () => { + const startBackground = vi.fn(async () => ({ status: 'started' as const, taskId: 'explicit-task' })); + const tool = new LocalBashTool('/tmp/workspace', { startBackground, runManagedForeground: vi.fn() }, { mode: 'off' }); + const result = await tool.execute( + { + ...SESSION_CTX, + allowBashAutoPromotion: false, + canConsumeBackgroundBashOutput: true, + }, + { description: '运行测试命令', command: 'controlled command', run_in_background: true }, + ); + expect(startBackground).toHaveBeenCalledOnce(); + expect(result.details).toMatchObject({ status: 'started', task_id: 'explicit-task' }); + }); + + it('fails closed for explicit background execution when the catalog fact is absent', async () => { + const startBackground = vi.fn(async () => ({ status: 'started' as const, taskId: 'explicit-task' })); + const tool = new LocalBashTool('/tmp/workspace', { startBackground, runManagedForeground: vi.fn() }, { mode: 'off' }); + const result = await tool.execute( + { ...SESSION_CTX, allowBashAutoPromotion: false }, + { description: '运行测试命令', command: 'controlled command', run_in_background: true }, + ); + + expect(result).toMatchObject({ + isError: true, + details: { + status: 'background_output_unavailable', + error_code: 'BASH_BACKGROUND_OUTPUT_UNAVAILABLE', + }, + }); + expect(startBackground).not.toHaveBeenCalled(); + }); + + it('returns a completed managed command inline', async () => { + const runManagedForeground = vi.fn(async () => ({ + status: 'completed' as const, + taskId: 'task-fast', + text: 'ok', + })); + const tool = new LocalBashTool('/tmp/does-not-matter', { + startBackground: vi.fn(), + runManagedForeground, + }, { mode: 'off' }); + + const result = await tool.execute({ ...SESSION_CTX, canConsumeBackgroundBashOutput: true }, { description: '运行测试命令', command: 'echo ok' }); + + expect(runManagedForeground).toHaveBeenCalledWith( + { ...SESSION_CTX, canConsumeBackgroundBashOutput: true }, + { description: '运行测试命令', command: 'echo ok' }, + DEFAULT_FOREGROUND_BASH_SOFT_YIELD_MS, + undefined, + ); + expect(result).toMatchObject({ text: 'ok', details: { status: 'completed', task_id: 'task-fast' } }); + }); + + it('reports managed env sanitization once per tool instance', async () => { + const runManagedForeground = vi.fn(async () => ({ + status: 'completed' as const, + taskId: 'task-fast', + text: 'ok', + details: { envSanitized: ['MAVIS_ACCESS_TOKEN'] }, + })); + const tool = new LocalBashTool('/tmp/does-not-matter', { + startBackground: vi.fn(), + runManagedForeground, + }, { mode: 'off' }); + + const first = await tool.execute({ ...SESSION_CTX, canConsumeBackgroundBashOutput: true }, { description: '运行测试命令', command: 'echo first' }); + const second = await tool.execute({ ...SESSION_CTX, canConsumeBackgroundBashOutput: true }, { description: '运行测试命令', command: 'echo second' }); + + expect(first.details?.envSanitized).toEqual(['MAVIS_ACCESS_TOKEN']); + expect(second.details?.envSanitized).toBeUndefined(); + }); + + it('returns the same task handle when a slow command is auto-promoted', async () => { + const tool = new LocalBashTool('/tmp/does-not-matter', { + startBackground: vi.fn(), + runManagedForeground: vi.fn(async () => ({ + status: 'auto_promoted' as const, + taskId: 'task-slow', + details: { envSanitized: ['MAVIS_ACCESS_TOKEN'] }, + })), + }, { mode: 'off' }); + + const result = await tool.execute({ ...SESSION_CTX, canConsumeBackgroundBashOutput: true }, { description: '运行测试命令', command: 'sleep 30' }); + + expect(result.details).toEqual({ + description: '运行测试命令', + status: 'auto_promoted', + task_id: 'task-slow', + timing: { + commandTimeoutSeconds: 600, + }, + envSanitized: ['MAVIS_ACCESS_TOKEN'], + }); + expect(result.text).toContain('without restarting it'); + expect(result.text).toContain('Command limit: 600s total; yielding does not reset it.'); + }); +}); + +describe('LocalBashTool.execute — real foreground ops', () => { + let workspace: string; + + beforeEach(async () => { + workspace = await mkdtemp(join(tmpdir(), 'local-bash-')); + }); + afterEach(async () => { + await rm(workspace, { recursive: true, force: true }); + }); + + it('returns real output after the 60 second yield window when the Turn has no native task_output', async () => { + const runManagedForeground = vi.fn(async () => ({ status: 'auto_promoted' as const, taskId: 'unexpected-task' })); + const tool = new LocalBashTool(workspace, { startBackground: vi.fn(), runManagedForeground }, { mode: 'off' }); + const startedAt = Date.now(); + const result = await tool.execute({ ...SESSION_CTX, allowBashAutoPromotion: false }, { + description: '运行测试命令', + command: `node -e "setTimeout(() => { console.log('foreground-after-threshold'); console.error('stderr-after-threshold'); }, 61000)"`, + timeout: 90, + }); + expect(Date.now() - startedAt).toBeGreaterThanOrEqual(60_000); + expect(runManagedForeground).not.toHaveBeenCalled(); + expect(result.text).toContain('foreground-after-threshold'); + expect(result.text).toContain('stderr-after-threshold'); + expect(result.isError).toBeFalsy(); + expect(result.details?.task_id).toBeUndefined(); + }, 90_000); + + // TS-5 (P1): fast foreground command with no timeout completes normally. + it('runs a fast foreground command with no timeout and returns real output', async () => { + const tool = new LocalBashTool(workspace, undefined, { mode: 'off' }); + const result = await tool.execute(SESSION_CTX, { description: '运行测试命令', command: 'echo hi' }); + expect(result.tool_name).toBe('bash'); + expect(result.text).toContain('hi'); + expect(result.isError).toBeFalsy(); + }); + + // TS-6 (P1): timeout-hit surfaces the vendored error text. Driven with a very + // short explicit timeout against a sleep to stay fast and non-flaky. + it('surfaces the vendored "Command timed out after N seconds" text', async () => { + const tool = new LocalBashTool(workspace, undefined, { mode: 'off' }); + const result = await tool.execute(SESSION_CTX, { description: '运行测试命令', command: 'sleep 5', timeout: 1 }); + expect(result.isError).toBe(true); + expect(result.text).toMatch(/Command timed out after 1 seconds/); + }); +}); + +describe('LocalBashToolDef timeout description (TS-7)', () => { + it('documents seconds, foreground limits, and background yielding', () => { + const desc = timeoutDescription(); + expect(desc).not.toContain('host-defined'); + expect(desc).toContain('seconds'); + expect(desc).toContain('120'); + expect(desc).toContain('300'); + expect(desc).toContain('600'); + expect(desc).toContain('including foreground time'); + expect(desc.toLowerCase()).toContain('foreground'); + const backgroundDesc = createLocalBashToolDefinition({ background: true, shell: 'bash' }).description; + expect(backgroundDesc).toContain('return a task_id if unfinished'); + expect(backgroundDesc).toContain('60s'); + }); + +}); + +describe('per-Turn Bash contract', () => { + it('keeps definitions isolated while hiding background parameters without native output', () => { + const original = JSON.stringify(LocalBashToolDef); + const enabled = createLocalBashToolDefinition({ background: true, shell: 'bash' }); + const disabled = createLocalBashToolDefinition({ background: false, shell: 'bash' }); + expect(enabled.schema.properties).toHaveProperty('run_in_background'); + expect(disabled.schema.properties).not.toHaveProperty('run_in_background'); + expect(JSON.stringify(disabled.schema)).not.toContain('run_in_background'); + expect(enabled.description).toContain('Backgrounding and reading output do not reset the command timeout.'); + expect(disabled.description).toContain('foreground execution only'); + expect(disabled.schema.required).toEqual(['command']); + expect(enabled.schema.properties.timeout.description).toContain('default/max 600s'); + expect(enabled.schema.properties.timeout.description).toContain('including foreground time'); + expect(disabled.schema.properties.timeout.description).toContain('default 120s; values above 300s are capped'); + expect(JSON.stringify(LocalBashToolDef)).toBe(original); + expect(enabled.schema.properties.timeout).not.toBe(LocalBashToolDef.schema.properties.timeout); + expect(disabled.description).toContain('report the failure instead of falling back to permanent deletion'); + expect(disabled.description).toContain('dedicated `read`, `write`, `edit`, `grep`, and `glob`'); + }); + + it.each([ + ['bash', 'Selected shell: Bash', 'PowerShell 5.1'], + ['sh', 'Selected shell: POSIX sh', 'Selected shell: Bash'], + ['powershell', 'The && and || operators are unsupported', 'operators && and || are supported'], + ['pwsh', 'operators && and || are supported', 'operators are unsupported'], + ['unavailable', 'No local shell was resolved', 'Selected shell: Bash'], + ] as const)('describes the selected %s shell capabilities', (shell, expected, excluded) => { + const tool = createLocalBashToolDefinition({ background: true, shell }); + expect(tool.description).toContain(expected); + expect(tool.description).not.toContain(excluded); + expect(tool.description).toContain('Backgrounding and reading output do not reset the command timeout.'); + }); +}); diff --git a/packages/agent-tools/test/desktop/local-task-output-polling.test.ts b/packages/agent-tools/test/desktop/local-task-output-polling.test.ts new file mode 100644 index 00000000..83a404e8 --- /dev/null +++ b/packages/agent-tools/test/desktop/local-task-output-polling.test.ts @@ -0,0 +1,445 @@ +import { describe, expect, it, vi } from 'vitest'; + +import type { BackgroundTask } from '@mavis/background-task'; +import { PiTurnRunner } from '@mavis/agent-core/pi-turn-runner'; +import type { RuntimeTool } from '@mavis/agent-core/tools'; +import type { StreamFn } from '@earendil-works/pi-agent-core'; +import type { Api, AssistantMessage, Model } from '@earendil-works/pi-ai'; + +import { LocalTaskOutputToolDef } from '../../src/desktop/builtin-defs.js'; +import { LocalTaskOutputTool } from '../../src/desktop/local-task-control.js'; +import type { + LocalRuntimeToolContext, + LocalTaskControlAdapter, + LocalTaskOutputReadResult, +} from '../../src/desktop/types.js'; + +const context: LocalRuntimeToolContext = { sessionId: 'owner', turnId: 'turn-1' }; +const replay = { task_id: 'bg_1', offset: 0, wait_ms: 30_000 }; +const running: LocalTaskOutputReadResult = { + content: 'Compiling\n', + status: 'running', + nextOffset: 312, + truncated: false, + timedOut: false, +}; + +function fixture() { + const get = vi.fn(async (ctx, taskId) => ({ + taskId, + ownerSessionId: ctx.sessionId, + kind: 'bash', + status: 'running', + createdAt: 1, + updatedAt: 1, + })); + const readOutput = vi.fn(async () => ({ ...running })); + const adapter: LocalTaskControlAdapter = { + get, + readOutput, + list: async () => ({ items: [] }), + stop: async () => undefined, + }; + return { tool: new LocalTaskOutputTool(adapter), get, readOutput }; +} + +it('shows incomplete persistence even when the command itself succeeded', async () => { + const { tool, get, readOutput } = fixture(); + get.mockResolvedValue({ + taskId: 'bg_1', ownerSessionId: 'owner', kind: 'bash', status: 'succeeded', createdAt: 1, updatedAt: 2, + metadata: { bashDetails: { output: { persistence: 'incomplete', rawBytes: 1234 } } }, + }); + readOutput.mockResolvedValue({ content: 'saved portion', status: 'succeeded', nextOffset: 100 }); + const result = await tool.execute(context, { task_id: 'bg_1' }); + expect(result.text).toContain('Output persistence is incomplete'); + expect(result.text).toContain('saved portion'); + expect(result.details?.output).toMatchObject({ rawBytes: 1234 }); +}); + +const piTestModel: Model = { + id: 'test-model', + name: 'test-model', + api: 'anthropic-messages', + provider: 'test-provider', + baseUrl: 'https://example.invalid', + reasoning: false, + input: ['text'], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 200_000, + maxTokens: 8_192, +}; + +function assistantMessage( + content: AssistantMessage['content'], + stopReason: AssistantMessage['stopReason'], +): AssistantMessage { + return { + role: 'assistant', + content, + api: piTestModel.api, + provider: piTestModel.provider, + model: piTestModel.id, + usage: { + input: 1, + output: 1, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 2, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + stopReason, + timestamp: 0, + }; +} + +function resultOnlyStream(message: AssistantMessage) { + return { + async *[Symbol.asyncIterator]() { + yield { type: 'done', reason: message.stopReason, message }; + }, + async result() { + return message; + }, + } as never; +} + +function piToolCallStream( + calls: readonly { readonly id: string; readonly arguments: Record }[], +): StreamFn { + let invocation = 0; + return (async () => { + const message = + invocation++ === 0 + ? assistantMessage( + calls.map((call) => ({ + type: 'toolCall', + id: call.id, + name: 'task_output', + arguments: call.arguments, + })), + 'toolUse', + ) + : assistantMessage([{ type: 'text', text: 'done' }], 'stop'); + return resultOnlyStream(message); + }) as StreamFn; +} + +it.each(['succeeded', 'failed', 'canceled', 'lost'] as const)( + 'successfully reads output from a %s task', + async (status) => { + const { tool, get, readOutput } = fixture(); + get.mockResolvedValue({ + taskId: 'bg_1', + ownerSessionId: 'owner', + kind: 'bash', + status, + createdAt: 1, + updatedAt: 2, + }); + readOutput.mockResolvedValue({ content: 'saved command output', status, nextOffset: 20 }); + const result = await tool.execute(context, { task_id: 'bg_1' }); + expect(result.isError).not.toBe(true); + expect(result.details).toMatchObject({ task_id: 'bg_1', status, next_offset: 20 }); + expect(result.text).toContain('saved command output'); + }, +); + +describe('task_output polling guidance', () => { + it('lets Pi validate raw task_output arguments before executing capped waits', async () => { + const readOutput = vi.fn(async () => ({ + content: '', + status: 'running', + timedOut: true, + })); + const adapter: LocalTaskControlAdapter = { + get: async () => ({ + taskId: 'bg_1', + ownerSessionId: context.sessionId, + kind: 'bash', + status: 'running', + createdAt: 1, + updatedAt: 1, + }), + readOutput, + list: async () => ({ items: [] }), + stop: async () => undefined, + }; + const rawCalls = [ + { id: 'oversized-60', arguments: { task_id: 'bg_1', wait_ms: 60_000 } }, + { id: 'oversized-120', arguments: { task_id: 'bg_1', wait_ms: 120_000 } }, + { id: 'oversized-300', arguments: { task_id: 'bg_1', wait_ms: 300_000 } }, + { id: 'negative', arguments: { task_id: 'bg_1', wait_ms: -1 } }, + { id: 'fraction', arguments: { task_id: 'bg_1', wait_ms: 0.5 } }, + { id: 'string', arguments: { task_id: 'bg_1', wait_ms: '60000' } }, + { id: 'null', arguments: { task_id: 'bg_1', wait_ms: null } }, + { id: 'infinity', arguments: { task_id: 'bg_1', wait_ms: Number.POSITIVE_INFINITY } }, + { id: 'nan', arguments: { task_id: 'bg_1', wait_ms: Number.NaN } }, + ]; + const history: unknown[] = []; + const stepResults: unknown[] = []; + const tool: RuntimeTool = { + def: LocalTaskOutputToolDef, + impl: new LocalTaskOutputTool(adapter), + source: 'builtin', + }; + + await new PiTurnRunner().runTurn({ + sessionId: context.sessionId, + turnId: context.turnId, + workspaceDir: '/workspace', + systemPrompt: '', + userMessage: { text: 'read task output' }, + llm: { model: piTestModel, streamFn: piToolCallStream(rawCalls) }, + eventWriter: { pushRuntime: () => undefined, appendEvents: () => undefined }, + toolConfig: { tools: [tool], disableBuiltinToolFallback: true, context }, + hooks: { + onStepEndHook: [ + ({ toolResults }) => { + stepResults.push(...structuredClone(toolResults)); + }, + ], + onHistoryChangedHook: [ + ({ reason, messages }) => { + if (reason === 'messageDelta') history.push(...structuredClone(messages)); + }, + ], + }, + }); + + expect(readOutput).toHaveBeenCalledTimes(3); + for (const [, , options] of readOutput.mock.calls) { + expect(options).toMatchObject({ waitMs: 30_000 }); + } + const invalidCallIds = new Set(rawCalls.slice(3).map((call) => call.id)); + const invalidResults = ( + stepResults as Array<{ + readonly toolCallId: string; + readonly isError: boolean; + }> + ).filter((result) => invalidCallIds.has(result.toolCallId)); + expect(invalidResults).toHaveLength(invalidCallIds.size); + expect(invalidResults.every((result) => result.isError)).toBe(true); + const assistant = history.find( + (message): message is { readonly role: 'assistant'; readonly content: readonly unknown[] } => + typeof message === 'object' && + message !== null && + (message as { role?: unknown }).role === 'assistant', + ); + expect(assistant?.content).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: 'oversized-60', + arguments: { task_id: 'bg_1', wait_ms: 60_000 }, + }), + expect.objectContaining({ + id: 'oversized-120', + arguments: { task_id: 'bg_1', wait_ms: 120_000 }, + }), + expect.objectContaining({ + id: 'oversized-300', + arguments: { task_id: 'bg_1', wait_ms: 300_000 }, + }), + ]), + ); + }); + + it('corrects repeated offset=0 reads with the returned cursor without changing replay semantics', async () => { + const { tool, readOutput } = fixture(); + const first = await tool.execute(context, replay); + const repeated = await tool.execute(context, replay); + + expect(first.text).not.toContain(''); + expect(repeated.text).toContain('Task status and next_offset are unchanged'); + expect(repeated.text).toContain('task_output({"task_id":"bg_1","offset":312,"wait_ms":30000})'); + expect(repeated.text).toContain('offset=0 replays existing output'); + expect(repeated.text).toContain(running.content); + expect(repeated.details).toEqual(first.details); + expect(repeated.content).toEqual([{ type: 'text', text: repeated.text }]); + expect(readOutput.mock.calls.map(([, , options]) => options)).toEqual([ + { offset: 0, waitMs: 30_000 }, + { offset: 0, waitMs: 30_000 }, + ]); + expect(replay.offset).toBe(0); + }); + + it.each([undefined, 0])('corrects unchanged empty reads with wait_ms=%s', async (waitMs) => { + const { tool, readOutput } = fixture(); + readOutput.mockResolvedValue({ content: '', nextOffset: 312, status: 'running' }); + const input = { task_id: 'bg_1', offset: 312, ...(waitMs === undefined ? {} : { wait_ms: waitMs }) }; + await tool.execute(context, input); + const repeated = await tool.execute(context, input); + + expect(repeated.text).toContain('"offset":312,"wait_ms":30000'); + expect(repeated.text).toContain('(no output yet)'); + expect(readOutput).toHaveBeenLastCalledWith(context, 'bg_1', { + offset: 312, + waitMs: waitMs ?? 0, + }); + }); + + it('preserves automatic cursor reads while providing an explicit recovery call', async () => { + const { tool, readOutput } = fixture(); + readOutput.mockResolvedValue({ content: '', nextOffset: 312, status: 'running' }); + await tool.execute(context, { task_id: 'bg_1' }); + const repeated = await tool.execute(context, { task_id: 'bg_1' }); + + expect(repeated.text).toContain('"offset":312,"wait_ms":30000'); + expect(readOutput).toHaveBeenLastCalledWith(context, 'bg_1', { waitMs: 0 }); + expect(repeated.details).toMatchObject({ effective_wait_ms: 0, wait_ms_clamped: false }); + }); + + it.each([30_001, 60_000, 300_000])( + 'caps oversized wait_ms=%s while preserving the requested value for diagnostics', + async (waitMs) => { + const { tool, readOutput } = fixture(); + readOutput.mockResolvedValue({ ...running, content: '', timedOut: true }); + const input = { task_id: 'bg_1', offset: 312, wait_ms: waitMs }; + + const result = await tool.execute(context, input); + + expect(input.wait_ms).toBe(waitMs); + expect(readOutput).toHaveBeenCalledWith(context, 'bg_1', { offset: 312, waitMs: 30_000 }); + expect(result.text).toContain( + 'Your requested wait_ms exceeded 30000 ms and was capped at 30000 ms (30 seconds). This output read reached its wait limit; the background task was not stopped. Use wait_ms=30000 or less for future reads.', + ); + expect(result.details).toMatchObject({ + effective_wait_ms: 30_000, + wait_ms_clamped: true, + timed_out: true, + }); + }, + ); + + it.each([ + { read: { ...running, timedOut: true }, waitMs: 30_000 }, + { read: { ...running, timedOut: false }, waitMs: 60_000 }, + { read: { ...running, status: 'succeeded' as const, timedOut: false }, waitMs: 60_000 }, + ])( + 'does not describe a cap timeout when the read returns early: %j', + async ({ read, waitMs }) => { + const { tool, readOutput } = fixture(); + readOutput.mockResolvedValue(read); + + const result = await tool.execute(context, { task_id: 'bg_1', wait_ms: waitMs }); + + expect(result.text).not.toContain('Your requested wait_ms exceeded 30000 ms'); + expect(result.details).toMatchObject({ + effective_wait_ms: waitMs > 30_000 ? 30_000 : waitMs, + wait_ms_clamped: waitMs > 30_000, + }); + }, + ); + + it('does not describe a capped wait as timed out after cancellation', async () => { + const { tool, readOutput } = fixture(); + const controller = new AbortController(); + controller.abort(); + readOutput.mockResolvedValue({ ...running, content: '', timedOut: true }); + + const result = await tool.execute( + context, + { task_id: 'bg_1', wait_ms: 60_000 }, + controller.signal, + ); + + expect(result.text).not.toContain('Your requested wait_ms exceeded 30000 ms'); + expect(result.details).toMatchObject({ effective_wait_ms: 30_000, wait_ms_clamped: true }); + }); + + it('does not warn on a normal long-poll timeout but still detects the following immediate check', async () => { + const { tool, readOutput } = fixture(); + readOutput.mockResolvedValue({ ...running, content: '', timedOut: true }); + await tool.execute(context, { ...replay, offset: 312 }); + const timeout = await tool.execute(context, { ...replay, offset: 312 }); + expect(timeout.text).not.toContain(''); + expect(timeout.details).toMatchObject({ timed_out: true, next_offset: 312 }); + + readOutput.mockResolvedValue({ ...running, content: '', timedOut: undefined }); + const immediate = await tool.execute(context, { task_id: 'bg_1', offset: 312 }); + expect(immediate.text).toContain(''); + }); + + it('resets the comparison when output advances or task status changes', async () => { + const { tool, readOutput } = fixture(); + await tool.execute(context, replay); + readOutput.mockResolvedValue({ ...running, nextOffset: 344 }); + expect((await tool.execute(context, replay)).text).not.toContain(''); + readOutput.mockResolvedValue({ ...running, nextOffset: 344, status: 'stopping' }); + expect((await tool.execute(context, replay)).text).not.toContain(''); + expect((await tool.execute(context, replay)).text).toContain('"offset":344'); + }); + + it.each(['succeeded', 'failed', 'canceled', 'lost'])( + 'does not recommend waiting on a %s task and forgets the previous active read', + async (status) => { + const { tool, readOutput } = fixture(); + await tool.execute(context, replay); + readOutput.mockResolvedValue({ ...running, status }); + expect((await tool.execute(context, replay)).text).not.toContain(''); + expect((await tool.execute(context, replay)).text).not.toContain(''); + readOutput.mockResolvedValue(running); + expect((await tool.execute(context, replay)).text).not.toContain(''); + }, + ); + + it.each([ + { ctx: { ...context, sessionId: 'other-owner' }, input: replay }, + { ctx: { ...context, turnId: 'turn-2' }, input: replay }, + { ctx: context, input: { ...replay, task_id: 'bg_2' } }, + ])('only compares consecutive reads of the same session, Turn and task: %j', async ({ ctx, input }) => { + const { tool } = fixture(); + await tool.execute(context, replay); + expect((await tool.execute(ctx, input)).text).not.toContain(''); + expect((await tool.execute(context, replay)).text).not.toContain(''); + expect((await tool.execute(context, replay)).text).toContain(''); + }); + + it.each([undefined, -1, 0.5, Number.NaN, Number.POSITIVE_INFINITY])( + 'never invents continuation arguments for an invalid cursor %s', + async (nextOffset) => { + const { tool, readOutput } = fixture(); + await tool.execute(context, replay); + readOutput.mockResolvedValue({ ...running, nextOffset }); + await tool.execute(context, replay); + expect((await tool.execute(context, replay)).text).not.toContain(''); + readOutput.mockResolvedValue(running); + expect((await tool.execute(context, replay)).text).not.toContain(''); + }, + ); + + it('clears the comparison on task-not-found without turning it into polling advice', async () => { + const { tool, get } = fixture(); + await tool.execute(context, replay); + get.mockResolvedValueOnce(undefined); + const missing = await tool.execute(context, replay); + expect(missing.isError).toBe(true); + expect(missing.details).toMatchObject({ effective_wait_ms: 30_000, wait_ms_clamped: false }); + expect(missing.text).not.toContain(''); + expect((await tool.execute(context, replay)).text).not.toContain(''); + }); + + it('does not learn a failed or canceled read and forwards cancellation unchanged', async () => { + const { tool, readOutput } = fixture(); + readOutput.mockRejectedValueOnce(new Error('read failed')); + await expect(tool.execute(context, replay)).rejects.toThrow('read failed'); + const controller = new AbortController(); + controller.abort(); + const canceled = await tool.execute(context, replay, controller.signal); + expect(canceled.text).not.toContain(''); + expect(readOutput).toHaveBeenLastCalledWith(context, 'bg_1', { + offset: 0, waitMs: 30_000, signal: controller.signal, + }); + expect((await tool.execute(context, replay)).text).not.toContain(''); + expect((await tool.execute(context, replay)).text).toContain(''); + }); + + it('preserves truncated replay output and points to the next page without claiming the task is finished', async () => { + const { tool, readOutput } = fixture(); + readOutput.mockResolvedValue({ ...running, truncated: true }); + await tool.execute(context, replay); + const repeated = await tool.execute(context, replay); + expect(repeated.text).toContain(running.content); + expect(repeated.text).toContain('"offset":312'); + expect(repeated.details).toMatchObject({ truncated: true, status: 'running', next_offset: 312 }); + }); +}); diff --git a/packages/local-runtime-v2/assets/agents/_default/prompt-base-all.md.hbs b/packages/local-runtime-v2/assets/agents/_default/prompt-base-all.md.hbs index 37ed2a9e..b3adedcd 100644 --- a/packages/local-runtime-v2/assets/agents/_default/prompt-base-all.md.hbs +++ b/packages/local-runtime-v2/assets/agents/_default/prompt-base-all.md.hbs @@ -192,12 +192,7 @@ When the user's latest message contains the exact phrase `Prompt 灰度验证`, ### Non-interactive shell - Your shell is **non-interactive** — no TTY, no stdin, no prompt. Commands that wait for stdin - or require a terminal UI will hang forever. - -### Bash timeout - -- Bash `timeout` is measured in seconds. Foreground commands default to 120s, are capped at 300s, and yield to the same managed background process after 15s so the turn is not blocked. Start builds, tests, installs, downloads, servers, or diagnostics expected to take longer as background tasks up front; inspect their incremental output with the task tools. - Do not use excessively large timeouts to mask hung commands, and do not rerun a command after it yields—the returned task is the original process. + or require a terminal UI may fail or wait until the command timeout. Use non-interactive flags. ```bash # BAD — interactive commands hang diff --git a/packages/local-runtime-v2/assets/agents/_default/prompt-base-windows.md b/packages/local-runtime-v2/assets/agents/_default/prompt-base-windows.md index 79c46841..6e4bb0d8 100644 --- a/packages/local-runtime-v2/assets/agents/_default/prompt-base-windows.md +++ b/packages/local-runtime-v2/assets/agents/_default/prompt-base-windows.md @@ -1,45 +1,6 @@ ## Windows Shell Constraints -### PowerShell only - -- Use **PowerShell syntax only**. Do NOT use legacy DOS / `cmd.exe` commands (`cmd`, `cmd /c`, - `dir`, `type`, `copy`, `move`, `del`, `erase`, `rd`, `rmdir`, etc.). Use full PowerShell - cmdlets instead (`Get-ChildItem`, `Copy-Item`, `Move-Item`, `New-Item`). -- The bash tool already runs the command through the selected Windows shell. Do not wrap ordinary - bash-tool commands in `powershell -Command`. If you truly must launch a nested `pwsh`/PowerShell - process, prefer `pwsh -NoProfile -NonInteractive -Command '