@oh-my-pi/pi-coding-agent 17.0.8 → 17.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +87 -1
- package/README.md +1 -1
- package/dist/cli.js +5495 -5021
- package/dist/types/async/job-manager.d.ts +37 -1
- package/dist/types/cli/args.d.ts +2 -0
- package/dist/types/cli/bench-cli.d.ts +41 -0
- package/dist/types/commands/bench.d.ts +15 -3
- package/dist/types/commands/launch.d.ts +4 -0
- package/dist/types/config/__tests__/model-registry.test.d.ts +1 -0
- package/dist/types/config/model-registry.d.ts +30 -1
- package/dist/types/config/models-config-schema.d.ts +18 -6
- package/dist/types/config/models-config.d.ts +14 -5
- package/dist/types/config/provider-globals.d.ts +2 -2
- package/dist/types/config/service-tier.d.ts +5 -1
- package/dist/types/config/settings-schema.d.ts +350 -50
- package/dist/types/config/settings.d.ts +7 -0
- package/dist/types/dap/client.d.ts +20 -0
- package/dist/types/exec/bash-executor.d.ts +38 -0
- package/dist/types/exec/direnv.d.ts +33 -0
- package/dist/types/export/html/args.d.ts +15 -0
- package/dist/types/export/html/index.d.ts +11 -26
- package/dist/types/export/html/web-palette.d.ts +55 -116
- package/dist/types/extensibility/extensions/loader.d.ts +3 -0
- package/dist/types/extensibility/extensions/types.d.ts +17 -2
- package/dist/types/extensibility/legacy-pi-coding-agent-shim.d.ts +1 -0
- package/dist/types/extensibility/legacy-pi-tui-shim.d.ts +10 -0
- package/dist/types/extensibility/plugins/legacy-pi-compat.d.ts +14 -1
- package/dist/types/internal-urls/registry-helpers.d.ts +11 -0
- package/dist/types/live/controller.d.ts +40 -0
- package/dist/types/live/protocol.d.ts +85 -0
- package/dist/types/live/protocol.test.d.ts +1 -0
- package/dist/types/live/transport.d.ts +48 -0
- package/dist/types/live/visualizer.d.ts +33 -0
- package/dist/types/lsp/client.d.ts +18 -1
- package/dist/types/mcp/config-writer.test.d.ts +1 -0
- package/dist/types/mcp/manager.d.ts +6 -2
- package/dist/types/mcp/oauth-discovery.d.ts +2 -0
- package/dist/types/mcp/render.d.ts +2 -2
- package/dist/types/mcp/smithery-auth.d.ts +1 -1
- package/dist/types/mcp/smithery-auth.test.d.ts +1 -0
- package/dist/types/mcp/tool-bridge.d.ts +7 -3
- package/dist/types/mcp/types.d.ts +6 -0
- package/dist/types/memory-backend/tool-names.d.ts +2 -0
- package/dist/types/modes/components/login-dialog.d.ts +2 -2
- package/dist/types/modes/components/oauth-selector.d.ts +8 -0
- package/dist/types/modes/components/plan-review-overlay.d.ts +26 -0
- package/dist/types/modes/components/settings-defs.d.ts +8 -1
- package/dist/types/modes/components/status-line/component.jj-cache.test.d.ts +1 -0
- package/dist/types/modes/components/tool-execution.d.ts +9 -1
- package/dist/types/modes/controllers/command-controller.d.ts +1 -1
- package/dist/types/modes/controllers/event-controller.d.ts +6 -1
- package/dist/types/modes/controllers/live-command-controller.d.ts +14 -0
- package/dist/types/modes/controllers/mcp-command-controller.d.ts +3 -0
- package/dist/types/modes/interactive-mode.d.ts +5 -0
- package/dist/types/modes/rpc/rpc-client.d.ts +10 -1
- package/dist/types/modes/rpc/rpc-frame.d.ts +16 -0
- package/dist/types/modes/rpc/rpc-messages.d.ts +26 -0
- package/dist/types/modes/rpc/rpc-types.d.ts +40 -0
- package/dist/types/modes/setup-wizard/scenes/sign-in.d.ts +1 -1
- package/dist/types/modes/setup-wizard/scenes/types.d.ts +9 -1
- package/dist/types/modes/setup-wizard/scenes/web-search.d.ts +1 -1
- package/dist/types/modes/theme/theme.d.ts +1 -1
- package/dist/types/modes/types.d.ts +3 -1
- package/dist/types/modes/utils/context-usage.d.ts +12 -2
- package/dist/types/plan-mode/plan-files.d.ts +10 -0
- package/dist/types/registry/agent-lifecycle.d.ts +21 -9
- package/dist/types/registry/agent-registry.d.ts +11 -4
- package/dist/types/sdk.d.ts +15 -1
- package/dist/types/secrets/index.d.ts +15 -2
- package/dist/types/secrets/obfuscator.d.ts +113 -15
- package/dist/types/session/acp-permission-gate.d.ts +19 -0
- package/dist/types/session/agent-session-events.d.ts +84 -0
- package/dist/types/session/agent-session-types.d.ts +314 -0
- package/dist/types/session/agent-session.d.ts +120 -750
- package/dist/types/session/async-job-delivery.d.ts +28 -0
- package/dist/types/session/bash-runner.d.ts +75 -0
- package/dist/types/session/blob-store.d.ts +11 -1
- package/dist/types/session/blob-store.test.d.ts +1 -0
- package/dist/types/session/checkpoint-entries.d.ts +28 -0
- package/dist/types/session/eval-runner.d.ts +49 -0
- package/dist/types/session/irc-bridge.d.ts +46 -0
- package/dist/types/session/messages.d.ts +22 -0
- package/dist/types/session/model-controls.d.ts +180 -0
- package/dist/types/session/prewalk.d.ts +46 -0
- package/dist/types/session/queued-messages.d.ts +22 -0
- package/dist/types/session/redis-session-storage.d.ts +1 -0
- package/dist/types/session/retry-fallback-chains.d.ts +74 -0
- package/dist/types/session/role-models.d.ts +15 -0
- package/dist/types/session/session-advisors.d.ts +228 -0
- package/dist/types/session/session-entries.d.ts +8 -0
- package/dist/types/session/session-handoff.d.ts +70 -0
- package/dist/types/session/session-history-format.d.ts +6 -1
- package/dist/types/session/session-loader.d.ts +6 -4
- package/dist/types/session/session-maintenance.d.ts +246 -0
- package/dist/types/session/session-manager.d.ts +41 -0
- package/dist/types/session/session-memory.d.ts +56 -0
- package/dist/types/session/session-provider-boundary.d.ts +62 -0
- package/dist/types/session/session-stats.d.ts +48 -0
- package/dist/types/session/session-tools.d.ts +143 -0
- package/dist/types/session/session-workspace.d.ts +32 -0
- package/dist/types/session/stream-guards.d.ts +44 -0
- package/dist/types/session/todo-tracker.d.ts +56 -0
- package/dist/types/session/ttsr-coordinator.d.ts +51 -0
- package/dist/types/session/turn-recovery.d.ts +225 -0
- package/dist/types/system-prompt.d.ts +2 -0
- package/dist/types/task/executor.d.ts +4 -1
- package/dist/types/task/output-manager.d.ts +3 -1
- package/dist/types/task/types.d.ts +14 -0
- package/dist/types/tools/approval.d.ts +1 -0
- package/dist/types/tools/browser/launch.d.ts +5 -0
- package/dist/types/tools/hub/jobs.d.ts +1 -1
- package/dist/types/tools/image-gen.d.ts +4 -4
- package/dist/types/tools/image-providers.d.ts +38 -0
- package/dist/types/tools/index.d.ts +8 -0
- package/dist/types/tools/path-utils.d.ts +8 -0
- package/dist/types/tools/report-tool-issue.d.ts +24 -9
- package/dist/types/tools/todo.d.ts +10 -4
- package/dist/types/tools/xdev.d.ts +5 -1
- package/dist/types/tts/streaming-player.d.ts +14 -0
- package/dist/types/tts/vocalizer.d.ts +5 -0
- package/dist/types/utils/jj.d.ts +29 -0
- package/dist/types/utils/lang-from-path.d.ts +1 -0
- package/dist/types/utils/title-generator.d.ts +30 -0
- package/dist/types/vibe/runtime.d.ts +36 -8
- package/dist/types/web/search/index.d.ts +1 -1
- package/dist/types/web/search/provider.d.ts +15 -5
- package/dist/types/web/search/providers/firecrawl.d.ts +9 -0
- package/dist/types/web/search/types.d.ts +95 -1
- package/package.json +13 -12
- package/scripts/generate-share-viewer.ts +4 -6
- package/scripts/legacy-pi-virtual-module.ts +1 -1
- package/src/advisor/__tests__/advisor.test.ts +643 -0
- package/src/advisor/runtime.ts +144 -43
- package/src/async/job-manager.ts +90 -2
- package/src/cli/args.ts +2 -0
- package/src/cli/auth-gateway-cli.ts +18 -3
- package/src/cli/bench-cli.ts +395 -29
- package/src/cli/flag-tables.ts +3 -0
- package/src/cli/gc-cli.ts +2 -2
- package/src/cli/grievances-cli.ts +2 -2
- package/src/cli/usage-cli.ts +11 -2
- package/src/collab/host.ts +3 -2
- package/src/commands/bench.ts +18 -4
- package/src/commands/launch.ts +4 -0
- package/src/config/__tests__/model-registry.test.ts +147 -0
- package/src/config/model-registry.ts +50 -2
- package/src/config/model-resolver.ts +0 -1
- package/src/config/models-config-schema.ts +15 -4
- package/src/config/provider-globals.ts +9 -9
- package/src/config/service-tier.ts +26 -1
- package/src/config/settings-schema.ts +250 -54
- package/src/config/settings.ts +81 -2
- package/src/dap/client.ts +37 -4
- package/src/edit/index.ts +49 -12
- package/src/eval/py/executor.ts +103 -33
- package/src/exec/bash-executor.ts +89 -6
- package/src/exec/direnv.ts +145 -0
- package/src/export/html/args.ts +20 -0
- package/src/export/html/index.ts +45 -47
- package/src/export/html/template.css +19 -1
- package/src/export/html/template.html +6 -0
- package/src/export/html/template.js +21 -0
- package/src/export/html/tool-views.generated.js +31 -31
- package/src/export/html/web-palette.ts +116 -132
- package/src/export/share.ts +249 -49
- package/src/extensibility/extensions/loader.ts +29 -1
- package/src/extensibility/extensions/runner.ts +6 -0
- package/src/extensibility/extensions/types.ts +33 -2
- package/src/extensibility/legacy-pi-coding-agent-shim.ts +1 -0
- package/src/extensibility/legacy-pi-tui-shim.ts +10 -0
- package/src/extensibility/plugins/legacy-pi-compat.ts +870 -311
- package/src/hindsight/state.ts +64 -6
- package/src/internal-urls/local-protocol.ts +2 -1
- package/src/internal-urls/memory-protocol.ts +2 -2
- package/src/internal-urls/registry-helpers.ts +40 -0
- package/src/internal-urls/skill-protocol.ts +2 -2
- package/src/internal-urls/ssh-protocol.ts +2 -1
- package/src/internal-urls/vault-protocol.ts +2 -1
- package/src/live/audio-worklet.txt +59 -0
- package/src/live/browser-runtime.txt +221 -0
- package/src/live/controller.ts +464 -0
- package/src/live/prompts/agent-final-message.md +3 -0
- package/src/live/prompts/live-instructions.md +23 -0
- package/src/live/protocol.test.ts +140 -0
- package/src/live/protocol.ts +233 -0
- package/src/live/transport.ts +490 -0
- package/src/live/visualizer.ts +234 -0
- package/src/lsp/client.ts +52 -9
- package/src/lsp/clients/biome-client.ts +3 -4
- package/src/lsp/index.ts +44 -11
- package/src/main.ts +9 -1
- package/src/mcp/config-writer.test.ts +43 -0
- package/src/mcp/config-writer.ts +109 -82
- package/src/mcp/manager.ts +39 -8
- package/src/mcp/oauth-discovery.ts +10 -2
- package/src/mcp/render.ts +94 -35
- package/src/mcp/smithery-auth.test.ts +29 -0
- package/src/mcp/smithery-auth.ts +3 -2
- package/src/mcp/tool-bridge.ts +107 -11
- package/src/mcp/types.ts +7 -0
- package/src/memories/index.ts +40 -20
- package/src/memory-backend/tool-names.ts +2 -0
- package/src/mnemopi/backend.ts +24 -7
- package/src/modes/acp/acp-agent.ts +56 -10
- package/src/modes/acp/acp-event-mapper.ts +8 -1
- package/src/modes/components/agent-hub.ts +27 -10
- package/src/modes/components/login-dialog.ts +14 -4
- package/src/modes/components/oauth-selector.ts +24 -7
- package/src/modes/components/plan-review-overlay.ts +350 -35
- package/src/modes/components/settings-defs.ts +28 -3
- package/src/modes/components/settings-selector.ts +178 -6
- package/src/modes/components/status-line/component.jj-cache.test.ts +229 -0
- package/src/modes/components/status-line/component.ts +123 -2
- package/src/modes/components/tool-execution.test.ts +63 -2
- package/src/modes/components/tool-execution.ts +11 -2
- package/src/modes/controllers/command-controller.ts +70 -22
- package/src/modes/controllers/event-controller.ts +111 -16
- package/src/modes/controllers/extension-ui-controller.test.ts +1 -0
- package/src/modes/controllers/extension-ui-controller.ts +20 -4
- package/src/modes/controllers/live-command-controller.ts +178 -0
- package/src/modes/controllers/mcp-command-controller.ts +80 -41
- package/src/modes/controllers/omfg-controller.ts +44 -40
- package/src/modes/controllers/selector-controller.ts +18 -10
- package/src/modes/controllers/session-focus-controller.ts +6 -1
- package/src/modes/interactive-mode.ts +160 -38
- package/src/modes/rpc/rpc-client.ts +94 -3
- package/src/modes/rpc/rpc-frame.ts +164 -4
- package/src/modes/rpc/rpc-messages.ts +127 -0
- package/src/modes/rpc/rpc-mode.ts +79 -7
- package/src/modes/rpc/rpc-types.ts +34 -2
- package/src/modes/runtime-init.ts +2 -0
- package/src/modes/setup-wizard/scenes/model.ts +5 -7
- package/src/modes/setup-wizard/scenes/providers.ts +3 -2
- package/src/modes/setup-wizard/scenes/sign-in.ts +8 -2
- package/src/modes/setup-wizard/scenes/theme.ts +13 -3
- package/src/modes/setup-wizard/scenes/types.ts +9 -1
- package/src/modes/setup-wizard/scenes/web-search.ts +23 -10
- package/src/modes/setup-wizard/wizard-overlay.ts +1 -1
- package/src/modes/theme/theme.ts +1 -1
- package/src/modes/types.ts +3 -1
- package/src/modes/utils/context-usage.ts +23 -7
- package/src/modes/utils/ui-helpers.ts +10 -5
- package/src/plan-mode/plan-files.ts +40 -0
- package/src/prompts/bench/cache-prefix-chunk.md +1 -0
- package/src/prompts/bench/cache-prefix.md +3 -0
- package/src/prompts/bench/cache-suffix.md +1 -0
- package/src/prompts/goals/guided-goal-system.md +24 -3
- package/src/prompts/system/custom-system-prompt.md +1 -1
- package/src/prompts/system/project-prompt.md +8 -1
- package/src/prompts/system/subagent-async-pending.md +6 -0
- package/src/prompts/system/system-prompt.md +1 -1
- package/src/prompts/system/workflow-notice.md +48 -40
- package/src/prompts/system/xdev-mount-notice.md +4 -0
- package/src/prompts/tools/task.md +12 -2
- package/src/prompts/tools/todo.md +3 -1
- package/src/registry/agent-lifecycle.ts +116 -46
- package/src/registry/agent-registry.ts +35 -8
- package/src/registry/persisted-agents.ts +26 -2
- package/src/sdk.ts +255 -139
- package/src/secrets/index.ts +127 -4
- package/src/secrets/obfuscator.ts +2328 -105
- package/src/session/acp-permission-gate.ts +165 -0
- package/src/session/agent-session-error-log.test.ts +1 -1
- package/src/session/agent-session-events.ts +66 -0
- package/src/session/agent-session-types.ts +334 -0
- package/src/session/agent-session.ts +1910 -12213
- package/src/session/artifacts.ts +6 -5
- package/src/session/async-job-delivery.ts +74 -0
- package/src/session/bash-runner.ts +326 -0
- package/src/session/blob-store.test.ts +56 -0
- package/src/session/blob-store.ts +18 -2
- package/src/session/checkpoint-entries.ts +81 -0
- package/src/session/eval-runner.ts +212 -0
- package/src/session/indexed-session-storage.ts +9 -2
- package/src/session/irc-bridge.ts +203 -0
- package/src/session/messages.ts +263 -1
- package/src/session/model-controls.ts +714 -0
- package/src/session/prewalk.ts +252 -0
- package/src/session/queued-messages.ts +93 -0
- package/src/session/redis-session-storage.ts +51 -11
- package/src/session/retry-fallback-chains.ts +455 -0
- package/src/session/role-models.ts +85 -0
- package/src/session/session-advisors.ts +1679 -0
- package/src/session/session-context.test.ts +224 -1
- package/src/session/session-context.ts +47 -5
- package/src/session/session-entries.ts +8 -0
- package/src/session/session-handoff.ts +308 -0
- package/src/session/session-history-format.ts +20 -9
- package/src/session/session-loader.ts +10 -51
- package/src/session/session-maintenance.ts +2983 -0
- package/src/session/session-manager.ts +390 -30
- package/src/session/session-memory.ts +222 -0
- package/src/session/session-provider-boundary.ts +307 -0
- package/src/session/session-stats.ts +293 -0
- package/src/session/session-tools.ts +943 -0
- package/src/session/session-workspace.ts +53 -0
- package/src/session/stream-guards.ts +417 -0
- package/src/session/todo-tracker.ts +380 -0
- package/src/session/ttsr-coordinator.ts +496 -0
- package/src/session/turn-recovery.ts +1629 -0
- package/src/slash-commands/builtin-registry.ts +92 -11
- package/src/slash-commands/helpers/usage-report.ts +25 -4
- package/src/system-prompt.ts +41 -17
- package/src/task/executor.ts +288 -70
- package/src/task/index.ts +148 -111
- package/src/task/output-manager.ts +25 -3
- package/src/task/persisted-revive.ts +4 -3
- package/src/task/structured-subagent.ts +4 -1
- package/src/task/types.ts +16 -0
- package/src/tools/approval.ts +55 -10
- package/src/tools/bash-interactive.ts +26 -0
- package/src/tools/bash-skill-urls.ts +28 -2
- package/src/tools/bash.ts +339 -123
- package/src/tools/browser/launch.ts +9 -1
- package/src/tools/browser/tab-supervisor.ts +8 -5
- package/src/tools/browser.ts +94 -47
- package/src/tools/hub/index.ts +1 -1
- package/src/tools/hub/jobs.ts +67 -8
- package/src/tools/image-gen.ts +20 -28
- package/src/tools/image-providers.ts +50 -0
- package/src/tools/index.ts +12 -0
- package/src/tools/path-utils.ts +49 -2
- package/src/tools/read.ts +156 -88
- package/src/tools/renderers.ts +7 -1
- package/src/tools/report-tool-issue.ts +79 -28
- package/src/tools/todo.ts +101 -11
- package/src/tools/vibe.ts +1 -2
- package/src/tools/write.ts +51 -1
- package/src/tools/xdev.ts +67 -3
- package/src/tts/streaming-player.ts +62 -4
- package/src/tts/vocalizer.ts +18 -1
- package/src/utils/jj.ts +125 -4
- package/src/utils/lang-from-path.ts +7 -0
- package/src/utils/title-generator.ts +137 -1
- package/src/vibe/runtime.ts +852 -72
- package/src/web/search/index.ts +3 -6
- package/src/web/search/provider.ts +31 -16
- package/src/web/search/providers/firecrawl.ts +46 -13
- package/src/web/search/providers/xai.ts +7 -1
- package/src/web/search/types.ts +8 -1
package/src/tools/todo.ts
CHANGED
|
@@ -2,7 +2,7 @@ import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallb
|
|
|
2
2
|
import type { ToolExample } from "@oh-my-pi/pi-ai";
|
|
3
3
|
import type { Component } from "@oh-my-pi/pi-tui";
|
|
4
4
|
import { Text } from "@oh-my-pi/pi-tui";
|
|
5
|
-
import { prompt } from "@oh-my-pi/pi-utils";
|
|
5
|
+
import { isRecord, prompt } from "@oh-my-pi/pi-utils";
|
|
6
6
|
import { type } from "arktype";
|
|
7
7
|
import chalk from "chalk";
|
|
8
8
|
import type { RenderResultOptions } from "../extensibility/custom-tools/types";
|
|
@@ -18,13 +18,15 @@ import { formatErrorDetail, formatMoreItems, PREVIEW_LIMITS, pluralize } from ".
|
|
|
18
18
|
// Types
|
|
19
19
|
// =============================================================================
|
|
20
20
|
|
|
21
|
-
export type TodoStatus = "pending" | "in_progress" | "completed" | "abandoned";
|
|
21
|
+
export type TodoStatus = "pending" | "in_progress" | "completed" | "abandoned" | "blocked";
|
|
22
22
|
/** Operation names accepted by the todo tool and echoed in successful result details. */
|
|
23
|
-
export type TodoOperation = "init" | "start" | "done" | "rm" | "drop" | "append" | "view";
|
|
23
|
+
export type TodoOperation = "init" | "start" | "done" | "rm" | "drop" | "block" | "unblock" | "append" | "view";
|
|
24
24
|
|
|
25
25
|
export interface TodoItem {
|
|
26
26
|
content: string;
|
|
27
27
|
status: TodoStatus;
|
|
28
|
+
/** When `status === "blocked"`, an optional note on what the task is waiting for. */
|
|
29
|
+
blocker?: string;
|
|
28
30
|
}
|
|
29
31
|
|
|
30
32
|
export interface TodoPhase {
|
|
@@ -32,6 +34,21 @@ export interface TodoPhase {
|
|
|
32
34
|
tasks: TodoItem[];
|
|
33
35
|
}
|
|
34
36
|
|
|
37
|
+
/** Whether an unknown value is a persisted todo phase. */
|
|
38
|
+
export function isTodoPhase(value: unknown): value is TodoPhase {
|
|
39
|
+
if (!isRecord(value) || typeof value.name !== "string" || !Array.isArray(value.tasks)) return false;
|
|
40
|
+
return value.tasks.every(
|
|
41
|
+
task =>
|
|
42
|
+
isRecord(task) &&
|
|
43
|
+
typeof task.content === "string" &&
|
|
44
|
+
(task.status === "pending" ||
|
|
45
|
+
task.status === "in_progress" ||
|
|
46
|
+
task.status === "completed" ||
|
|
47
|
+
task.status === "abandoned" ||
|
|
48
|
+
task.status === "blocked"),
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
35
52
|
export interface TodoCompletionTransition {
|
|
36
53
|
phase: string;
|
|
37
54
|
content: string;
|
|
@@ -49,7 +66,9 @@ export interface TodoToolDetails {
|
|
|
49
66
|
// Schema
|
|
50
67
|
// =============================================================================
|
|
51
68
|
|
|
52
|
-
const TodoOp = type('"init" | "start" | "done" | "rm" | "drop" | "append" | "view"').describe(
|
|
69
|
+
const TodoOp = type('"init" | "start" | "done" | "rm" | "drop" | "block" | "unblock" | "append" | "view"').describe(
|
|
70
|
+
"operation to apply",
|
|
71
|
+
);
|
|
53
72
|
|
|
54
73
|
const InitListEntry = type({
|
|
55
74
|
phase: type("string").describe("phase name"),
|
|
@@ -65,6 +84,7 @@ const todoSchema = type({
|
|
|
65
84
|
// and both enforce non-empty with op-specific errors. A stray `items: []` on
|
|
66
85
|
// an op that ignores it (e.g. `view`) must not be a hard schema rejection.
|
|
67
86
|
"items?": type("string").describe("task content").array().describe("tasks to append"),
|
|
87
|
+
"reason?": type("string").describe("blocker note (block op)"),
|
|
68
88
|
}).describe("apply a single todo operation");
|
|
69
89
|
|
|
70
90
|
type TodoParams = TodoSchema;
|
|
@@ -89,7 +109,9 @@ function findPhaseByName(phases: TodoPhase[], name: string): TodoPhase | undefin
|
|
|
89
109
|
}
|
|
90
110
|
|
|
91
111
|
function cloneTask(task: TodoItem): TodoItem {
|
|
92
|
-
return
|
|
112
|
+
return task.blocker !== undefined
|
|
113
|
+
? { content: task.content, status: task.status, blocker: task.blocker }
|
|
114
|
+
: { content: task.content, status: task.status };
|
|
93
115
|
}
|
|
94
116
|
|
|
95
117
|
function clonePhases(phases: TodoPhase[]): TodoPhase[] {
|
|
@@ -256,7 +278,9 @@ export function selectCollapsedTodos<T extends { status: TodoStatus }>(
|
|
|
256
278
|
isMatched: (task: T) => boolean,
|
|
257
279
|
cap: number,
|
|
258
280
|
): CollapsedTodoSelection<T> {
|
|
259
|
-
const open = tasks.filter(
|
|
281
|
+
const open = tasks.filter(
|
|
282
|
+
task => task.status === "pending" || task.status === "in_progress" || task.status === "blocked",
|
|
283
|
+
);
|
|
260
284
|
// No open work: fall back to the closed tasks so a settled phase still
|
|
261
285
|
// renders (HUD closed-todo persistence). Closed tasks are never active.
|
|
262
286
|
const base = open.length > 0 ? open : tasks;
|
|
@@ -456,6 +480,41 @@ function applyEntry(phases: TodoPhase[], entry: TodoOpEntryValue, errors: string
|
|
|
456
480
|
}
|
|
457
481
|
return phases;
|
|
458
482
|
}
|
|
483
|
+
case "block": {
|
|
484
|
+
if (!entry.task && !entry.phase) {
|
|
485
|
+
errors.push("block requires a task or phase target");
|
|
486
|
+
return phases;
|
|
487
|
+
}
|
|
488
|
+
// Collapse whitespace runs (incl. newlines) to single spaces: a blocker
|
|
489
|
+
// note rides on one Markdown checklist line (as a trailing HTML comment)
|
|
490
|
+
// and one HUD/summary line, so an embedded newline from a multi-line
|
|
491
|
+
// external error or user question would corrupt the round-trip parse and
|
|
492
|
+
// the rendered line. Normalizing here keeps every consumer one-line-safe.
|
|
493
|
+
const reason = entry.reason?.replace(/\s+/g, " ").trim() || undefined;
|
|
494
|
+
for (const task of getTaskTargets(phases, entry, errors)) {
|
|
495
|
+
// Only actionable open work can be blocked: blocking a phase must not
|
|
496
|
+
// reopen completed/abandoned tasks or erase finished progress. An
|
|
497
|
+
// already-blocked task stays eligible so a later block can refine its
|
|
498
|
+
// blocker note (e.g. first blocked without a reason, then with one).
|
|
499
|
+
if (task.status !== "pending" && task.status !== "in_progress" && task.status !== "blocked") continue;
|
|
500
|
+
task.status = "blocked";
|
|
501
|
+
task.blocker = reason;
|
|
502
|
+
}
|
|
503
|
+
return phases;
|
|
504
|
+
}
|
|
505
|
+
case "unblock": {
|
|
506
|
+
if (!entry.task && !entry.phase) {
|
|
507
|
+
errors.push("unblock requires a task or phase target");
|
|
508
|
+
return phases;
|
|
509
|
+
}
|
|
510
|
+
for (const task of getTaskTargets(phases, entry, errors)) {
|
|
511
|
+
if (task.status === "blocked") {
|
|
512
|
+
task.status = "pending";
|
|
513
|
+
task.blocker = undefined;
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
return phases;
|
|
517
|
+
}
|
|
459
518
|
case "rm":
|
|
460
519
|
return removeTasks(phases, entry, errors);
|
|
461
520
|
case "append":
|
|
@@ -495,6 +554,7 @@ const STATUS_TO_MARKER: Record<TodoStatus, string> = {
|
|
|
495
554
|
in_progress: "/",
|
|
496
555
|
completed: "x",
|
|
497
556
|
abandoned: "-",
|
|
557
|
+
blocked: "!",
|
|
498
558
|
};
|
|
499
559
|
|
|
500
560
|
export function resolveTodoMarkdownPath(input: string, cwd: string): string {
|
|
@@ -510,7 +570,12 @@ export function phasesToMarkdown(phases: TodoPhase[]): string {
|
|
|
510
570
|
if (i > 0) out.push("");
|
|
511
571
|
out.push(`# ${phases[i].name}`);
|
|
512
572
|
for (const task of phases[i].tasks) {
|
|
513
|
-
|
|
573
|
+
// A blocked task's reason rides in a trailing HTML comment: invisible in
|
|
574
|
+
// rendered markdown, unambiguous to parse back (task content can't
|
|
575
|
+
// contain the comment delimiters), so the note survives `/todo edit` and
|
|
576
|
+
// export/import round-trips.
|
|
577
|
+
const blockerNote = task.status === "blocked" && task.blocker ? ` <!-- blocker: ${task.blocker} -->` : "";
|
|
578
|
+
out.push(`- [${STATUS_TO_MARKER[task.status]}] ${task.content}${blockerNote}`);
|
|
514
579
|
}
|
|
515
580
|
}
|
|
516
581
|
return `${out.join("\n")}\n`;
|
|
@@ -525,6 +590,7 @@ const MARKER_TO_STATUS: Record<string, TodoStatus> = {
|
|
|
525
590
|
">": "in_progress",
|
|
526
591
|
"-": "abandoned",
|
|
527
592
|
"~": "abandoned",
|
|
593
|
+
"!": "blocked",
|
|
528
594
|
};
|
|
529
595
|
|
|
530
596
|
/** Parse a Markdown checklist back into todo phases. */
|
|
@@ -556,10 +622,18 @@ export function markdownToPhases(md: string): { phases: TodoPhase[]; errors: str
|
|
|
556
622
|
const marker = taskMatch[1];
|
|
557
623
|
const status = MARKER_TO_STATUS[marker];
|
|
558
624
|
if (!status) {
|
|
559
|
-
errors.push(`Line ${lineNum + 1}: unknown status marker "[${marker}]" (use [ ], [x], [/], [-])`);
|
|
625
|
+
errors.push(`Line ${lineNum + 1}: unknown status marker "[${marker}]" (use [ ], [x], [/], [-], [!])`);
|
|
560
626
|
continue;
|
|
561
627
|
}
|
|
562
|
-
|
|
628
|
+
// Recover a blocked task's reason from its trailing HTML comment (see
|
|
629
|
+
// phasesToMarkdown), then strip the comment from the visible content.
|
|
630
|
+
const rawContent = taskMatch[2].trim();
|
|
631
|
+
const blockerMatch = /^(.*?)\s*<!--\s*blocker:\s*(.*?)\s*-->$/.exec(rawContent);
|
|
632
|
+
if (status === "blocked" && blockerMatch) {
|
|
633
|
+
currentPhase.tasks.push({ content: blockerMatch[1].trim(), status, blocker: blockerMatch[2].trim() });
|
|
634
|
+
} else {
|
|
635
|
+
currentPhase.tasks.push({ content: rawContent, status });
|
|
636
|
+
}
|
|
563
637
|
continue;
|
|
564
638
|
}
|
|
565
639
|
|
|
@@ -604,6 +678,7 @@ function formatSummary(phases: TodoPhase[], errors: string[], readOnly = false):
|
|
|
604
678
|
}
|
|
605
679
|
// Closed = completed + abandoned, mirroring the per-phase `done` count.
|
|
606
680
|
const closedAll = tasks.filter(task => task.status === "completed" || task.status === "abandoned").length;
|
|
681
|
+
const blockedAll = tasks.filter(task => task.status === "blocked").length;
|
|
607
682
|
// The active phase is the EARLIEST one still holding open work, so the
|
|
608
683
|
// in-progress pointer can sit in a phase whose successors already have
|
|
609
684
|
// completed tasks. Detect that "worked ahead" case to explain the
|
|
@@ -613,7 +688,9 @@ function formatSummary(phases: TodoPhase[], errors: string[], readOnly = false):
|
|
|
613
688
|
(phase, idx) =>
|
|
614
689
|
idx > currentIdx && phase.tasks.some(task => task.status === "completed" || task.status === "abandoned"),
|
|
615
690
|
);
|
|
616
|
-
lines.push(
|
|
691
|
+
lines.push(
|
|
692
|
+
`Overall: ${closedAll}/${tasks.length} done, ${remainingTasks.length} open${blockedAll > 0 ? `, ${blockedAll} blocked` : ""}.`,
|
|
693
|
+
);
|
|
617
694
|
lines.push(
|
|
618
695
|
`Active phase ${currentIdx + 1}/${phases.length} "${current.name}" (${done}/${current.tasks.length})${
|
|
619
696
|
workedAhead
|
|
@@ -625,7 +702,16 @@ function formatSummary(phases: TodoPhase[], errors: string[], readOnly = false):
|
|
|
625
702
|
lines.push(` ${phase.name}:`);
|
|
626
703
|
for (const task of phase.tasks) {
|
|
627
704
|
const checkbox = task.status === "completed" ? "[X]" : "[ ]";
|
|
628
|
-
const tag =
|
|
705
|
+
const tag =
|
|
706
|
+
task.status === "in_progress"
|
|
707
|
+
? " (in progress)"
|
|
708
|
+
: task.status === "abandoned"
|
|
709
|
+
? " (dropped)"
|
|
710
|
+
: task.status === "blocked"
|
|
711
|
+
? task.blocker
|
|
712
|
+
? ` (blocked: ${task.blocker})`
|
|
713
|
+
: " (blocked)"
|
|
714
|
+
: "";
|
|
629
715
|
lines.push(` - ${checkbox} ${task.content}${tag}`);
|
|
630
716
|
}
|
|
631
717
|
}
|
|
@@ -845,6 +931,10 @@ function formatTodoLine(
|
|
|
845
931
|
return uiTheme.fg("accent", `${prefix}${checkbox.unchecked} ${item.content}`);
|
|
846
932
|
case "abandoned":
|
|
847
933
|
return uiTheme.fg("error", `${prefix}${checkbox.unchecked} ${strikethroughText(item.content)}`);
|
|
934
|
+
case "blocked": {
|
|
935
|
+
const note = item.blocker ? `blocked: ${item.blocker}` : "blocked";
|
|
936
|
+
return uiTheme.fg("warning", `${prefix}${checkbox.unchecked} ${item.content} (${note})`);
|
|
937
|
+
}
|
|
848
938
|
default:
|
|
849
939
|
// A pending todo lit by a live subagent match renders accent, matching
|
|
850
940
|
// the sticky HUD's convention (#5873).
|
package/src/tools/vibe.ts
CHANGED
|
@@ -23,7 +23,6 @@ import vibeListDescription from "../prompts/tools/vibe-list.md" with { type: "te
|
|
|
23
23
|
import vibeSendDescription from "../prompts/tools/vibe-send.md" with { type: "text" };
|
|
24
24
|
import vibeSpawnDescription from "../prompts/tools/vibe-spawn.md" with { type: "text" };
|
|
25
25
|
import vibeWaitDescription from "../prompts/tools/vibe-wait.md" with { type: "text" };
|
|
26
|
-
import { MAIN_AGENT_ID } from "../registry/agent-registry";
|
|
27
26
|
import { oneLineLabel } from "../task/types";
|
|
28
27
|
import { renderStatusLine } from "../tui";
|
|
29
28
|
import {
|
|
@@ -93,7 +92,7 @@ export interface VibeToolDetails {
|
|
|
93
92
|
}
|
|
94
93
|
|
|
95
94
|
function screensOf(session: ToolSession, ids?: string[]): VibeScreenSnapshot[] {
|
|
96
|
-
return VibeSessionRegistry.global().screens(session
|
|
95
|
+
return VibeSessionRegistry.global().screens(session, ids);
|
|
97
96
|
}
|
|
98
97
|
|
|
99
98
|
function textResult(text: string, details: VibeToolDetails): AgentToolResult<VibeToolDetails> {
|
package/src/tools/write.ts
CHANGED
|
@@ -49,7 +49,14 @@ import {
|
|
|
49
49
|
} from "./conflict-detect";
|
|
50
50
|
import { invalidateFsScanAfterWrite } from "./fs-cache-invalidation";
|
|
51
51
|
import { type OutputMeta, outputMeta } from "./output-meta";
|
|
52
|
-
import {
|
|
52
|
+
import {
|
|
53
|
+
formatPathRelativeToCwd,
|
|
54
|
+
isInternalUrlPath,
|
|
55
|
+
pathTargetsSsh,
|
|
56
|
+
peelWriteUrlSelector,
|
|
57
|
+
probeLiteralPathExists,
|
|
58
|
+
splitPathAndSel,
|
|
59
|
+
} from "./path-utils";
|
|
53
60
|
import { enforcePlanModeWrite, resolvePlanPath, unwrapHashlineHeaderPath } from "./plan-mode-guard";
|
|
54
61
|
import {
|
|
55
62
|
cachedRenderedString,
|
|
@@ -116,6 +123,43 @@ function assertWriteTargetAddressable(target: string, router: InternalUrlRouter)
|
|
|
116
123
|
);
|
|
117
124
|
}
|
|
118
125
|
|
|
126
|
+
/**
|
|
127
|
+
* Fail closed when a local write target looks like a mis-dispatched read.
|
|
128
|
+
*
|
|
129
|
+
* A read-only step that selects `write` instead of `read` passes the full read
|
|
130
|
+
* expression (`src/foo.tsx:1-260:raw`) as the target. Because a literal colon
|
|
131
|
+
* filename is legal on POSIX (issue #4618), that request otherwise resolves to
|
|
132
|
+
* filesystem creation and reports success, leaving a stray zero-byte file the
|
|
133
|
+
* model cannot recover from — the local analogue of the `xd://` near-miss guard
|
|
134
|
+
* ({@link assertWriteTargetAddressable}, issue #6123).
|
|
135
|
+
*
|
|
136
|
+
* Fires only on the high-confidence combination the report identifies: the tail
|
|
137
|
+
* parses as a read-tool selector, the literal target is missing, and no content
|
|
138
|
+
* was supplied. Non-empty content is the escape hatch — it is never blocked, so
|
|
139
|
+
* a deliberate write to a selector-shaped filename still succeeds. An existing
|
|
140
|
+
* literal path or an ambiguous stat (`"unknown"`: EACCES, transient I/O) also
|
|
141
|
+
* passes through so a real file is never shadowed by the guard.
|
|
142
|
+
*/
|
|
143
|
+
function readSelectorForEmptyWrite(target: string, content: string): string | undefined {
|
|
144
|
+
if (content.length > 0) return undefined;
|
|
145
|
+
return splitPathAndSel(target).sel;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function throwReadSelectorMisfire(target: string, sel: string): never {
|
|
149
|
+
throw new ToolError(
|
|
150
|
+
`write target '${target}' ends with a read-tool selector ':${sel}' and no such file exists — refusing to create a literal file by that name. ` +
|
|
151
|
+
`If you meant to read it, use read({ path: "${target}" }). ` +
|
|
152
|
+
`If you truly intend to create this file, pass its contents in \`content\` (a non-empty write is never blocked).`,
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
async function assertNotReadSelectorMisfire(target: string, content: string, cwd: string): Promise<void> {
|
|
157
|
+
const sel = readSelectorForEmptyWrite(target, content);
|
|
158
|
+
if (sel === undefined) return;
|
|
159
|
+
if ((await probeLiteralPathExists(target, cwd)) !== "missing") return;
|
|
160
|
+
throwReadSelectorMisfire(target, sel);
|
|
161
|
+
}
|
|
162
|
+
|
|
119
163
|
const BULK_DIRECTIVE_RE = /^#?(\d+)\s*[:=]\s*(@ours|@theirs|@base|@both)$/;
|
|
120
164
|
/**
|
|
121
165
|
* The head of a per-id directive line — `<id>:` / `<id>=` (optionally `#`-prefixed),
|
|
@@ -576,6 +620,11 @@ export class WriteTool implements AgentTool<typeof writeSchema, WriteToolDetails
|
|
|
576
620
|
throw new ToolError(error instanceof Error ? error.message : String(error));
|
|
577
621
|
}
|
|
578
622
|
}
|
|
623
|
+
const writeTarget = `${resolvedArchivePath.archivePath}:${resolvedArchivePath.archiveSubPath}`;
|
|
624
|
+
const sel = readSelectorForEmptyWrite(writeTarget, content);
|
|
625
|
+
if (sel !== undefined && !entries.has(resolvedArchivePath.archiveSubPath)) {
|
|
626
|
+
throwReadSelectorMisfire(writeTarget, sel);
|
|
627
|
+
}
|
|
579
628
|
entries.set(resolvedArchivePath.archiveSubPath, content);
|
|
580
629
|
|
|
581
630
|
try {
|
|
@@ -1159,6 +1208,7 @@ export class WriteTool implements AgentTool<typeof writeSchema, WriteToolDetails
|
|
|
1159
1208
|
return sqliteResult;
|
|
1160
1209
|
}
|
|
1161
1210
|
|
|
1211
|
+
await assertNotReadSelectorMisfire(path, cleanContent, this.session.cwd);
|
|
1162
1212
|
enforcePlanModeWrite(this.session, path, { op: "create" });
|
|
1163
1213
|
const absolutePath = resolvePlanPath(this.session, path);
|
|
1164
1214
|
const batchRequest = getLspBatchRequest(context?.toolCall);
|
package/src/tools/xdev.ts
CHANGED
|
@@ -60,6 +60,9 @@ export const XDEV_KEEP_TOP_LEVEL: Record<string, true> = {
|
|
|
60
60
|
*/
|
|
61
61
|
export const XDEV_TRANSPORT_TOOLS: Record<string, true> = { read: true, write: true };
|
|
62
62
|
|
|
63
|
+
/** Controls which mounted-device docs are inlined into the system prompt. */
|
|
64
|
+
export type XdevDocsMode = "inline" | "builtins" | "catalog";
|
|
65
|
+
|
|
63
66
|
/**
|
|
64
67
|
* Whether an enabled tool is presented under `xd://` (rather than top-level)
|
|
65
68
|
* while the `xd://` transport is active. Discoverable tools mount unless they
|
|
@@ -170,6 +173,28 @@ function toolSummary(inst: Tool): string {
|
|
|
170
173
|
return firstLine?.trim() ?? inst.label ?? inst.name;
|
|
171
174
|
}
|
|
172
175
|
|
|
176
|
+
function promptCatalogSummary(inst: Tool, maxLength?: number): string {
|
|
177
|
+
const summary =
|
|
178
|
+
toolSummary(inst)
|
|
179
|
+
.split("\n")
|
|
180
|
+
.find(line => line.trim().length > 0)
|
|
181
|
+
?.trim() ?? inst.name;
|
|
182
|
+
if (maxLength === undefined || summary.length <= maxLength) return summary;
|
|
183
|
+
return `${summary.slice(0, maxLength).trimEnd()}…`;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** Compile the `tools.xdevInlineDevices` allowlist once per render, dropping
|
|
187
|
+
* non-string entries so malformed user config cannot break prompt builds. */
|
|
188
|
+
function compileInlineGlobs(patterns: readonly string[]): Bun.Glob[] {
|
|
189
|
+
if (!Array.isArray(patterns)) return [];
|
|
190
|
+
const globs: Bun.Glob[] = [];
|
|
191
|
+
for (const pattern of patterns) {
|
|
192
|
+
if (typeof pattern !== "string" || pattern.length === 0) continue;
|
|
193
|
+
globs.push(new Bun.Glob(pattern));
|
|
194
|
+
}
|
|
195
|
+
return globs;
|
|
196
|
+
}
|
|
197
|
+
|
|
173
198
|
/** Decode the (possibly partially streamed) inner args JSON string into display args. */
|
|
174
199
|
function decodeInnerArgs(raw: unknown): Record<string, unknown> {
|
|
175
200
|
if (typeof raw !== "string" || raw.length === 0) return {};
|
|
@@ -230,7 +255,13 @@ export class XdevRegistry {
|
|
|
230
255
|
|
|
231
256
|
/** `{name, summary}` pairs for prompt templates and /tools display. */
|
|
232
257
|
entries(): Array<{ name: string; summary: string }> {
|
|
233
|
-
return this.list().map(tool => ({
|
|
258
|
+
return this.list().map(tool => ({
|
|
259
|
+
name: tool.name,
|
|
260
|
+
summary: promptCatalogSummary(
|
|
261
|
+
tool,
|
|
262
|
+
this.#dynamic.has(tool.name) ? XdevRegistry.EXTERNAL_DESCRIPTION_CAP : undefined,
|
|
263
|
+
),
|
|
264
|
+
}));
|
|
234
265
|
}
|
|
235
266
|
|
|
236
267
|
/** `read xd://` listing with one device per line. */
|
|
@@ -273,11 +304,16 @@ export class XdevRegistry {
|
|
|
273
304
|
* Dynamic mounts embed at most {@link EXTERNAL_DESCRIPTION_CAP} description
|
|
274
305
|
* chars (schema always intact); `read xd://<tool>` returns the full text.
|
|
275
306
|
*/
|
|
276
|
-
docsAll(): string {
|
|
307
|
+
docsAll(mode: XdevDocsMode = "inline", inlinePatterns: readonly string[] = []): string {
|
|
277
308
|
const sections: string[] = [];
|
|
278
309
|
const overflow: Tool[] = [];
|
|
310
|
+
const inlineGlobs = compileInlineGlobs(inlinePatterns);
|
|
279
311
|
let used = 0;
|
|
280
312
|
for (const tool of this.list()) {
|
|
313
|
+
if (!this.#shouldInline(tool, mode, inlineGlobs)) {
|
|
314
|
+
overflow.push(tool);
|
|
315
|
+
continue;
|
|
316
|
+
}
|
|
281
317
|
const descriptionCap = this.#dynamic.has(tool.name) ? XdevRegistry.EXTERNAL_DESCRIPTION_CAP : undefined;
|
|
282
318
|
const docs = renderDocs(tool, "##", descriptionCap);
|
|
283
319
|
if (docs.length > XdevRegistry.DOCS_PER_DEVICE_CAP || used + docs.length > XdevRegistry.DOCS_TOTAL_BUDGET) {
|
|
@@ -291,7 +327,10 @@ export class XdevRegistry {
|
|
|
291
327
|
sections.push(
|
|
292
328
|
[
|
|
293
329
|
"## Additional devices (docs on demand)",
|
|
294
|
-
...overflow.map(tool =>
|
|
330
|
+
...overflow.map(tool => {
|
|
331
|
+
const maxLength = this.#dynamic.has(tool.name) ? XdevRegistry.EXTERNAL_DESCRIPTION_CAP : undefined;
|
|
332
|
+
return `- ${XD_URL_PREFIX}${tool.name} — ${promptCatalogSummary(tool, maxLength)}`;
|
|
333
|
+
}),
|
|
295
334
|
"",
|
|
296
335
|
`Read ${XD_URL_PREFIX}<tool> for full docs + JSON schema before first use.`,
|
|
297
336
|
].join("\n"),
|
|
@@ -300,6 +339,31 @@ export class XdevRegistry {
|
|
|
300
339
|
return sections.join("\n\n");
|
|
301
340
|
}
|
|
302
341
|
|
|
342
|
+
/** Docs for selected mounted devices under the configured prompt-doc policy. */
|
|
343
|
+
docsFor(names: Iterable<string>, mode: XdevDocsMode, inlinePatterns: readonly string[] = []): string {
|
|
344
|
+
const sections: string[] = [];
|
|
345
|
+
const inlineGlobs = compileInlineGlobs(inlinePatterns);
|
|
346
|
+
let used = 0;
|
|
347
|
+
for (const name of names) {
|
|
348
|
+
const tool = this.get(name);
|
|
349
|
+
if (!tool || !this.#shouldInline(tool, mode, inlineGlobs)) continue;
|
|
350
|
+
const descriptionCap = this.#dynamic.has(tool.name) ? XdevRegistry.EXTERNAL_DESCRIPTION_CAP : undefined;
|
|
351
|
+
const docs = renderDocs(tool, "##", descriptionCap);
|
|
352
|
+
if (docs.length > XdevRegistry.DOCS_PER_DEVICE_CAP || used + docs.length > XdevRegistry.DOCS_TOTAL_BUDGET)
|
|
353
|
+
continue;
|
|
354
|
+
used += docs.length;
|
|
355
|
+
sections.push(docs);
|
|
356
|
+
}
|
|
357
|
+
return sections.join("\n\n");
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
#shouldInline(tool: Tool, mode: XdevDocsMode, inlineGlobs: readonly Bun.Glob[]): boolean {
|
|
361
|
+
return (
|
|
362
|
+
mode !== "catalog" &&
|
|
363
|
+
(mode === "inline" || this.#builtins.has(tool.name) || inlineGlobs.some(glob => glob.match(tool.name)))
|
|
364
|
+
);
|
|
365
|
+
}
|
|
366
|
+
|
|
303
367
|
#resolve(name: string): Tool {
|
|
304
368
|
const inst = this.get(name);
|
|
305
369
|
if (!inst) {
|
|
@@ -31,6 +31,15 @@ const DEFAULT_SAMPLE_RATE = 24_000;
|
|
|
31
31
|
const LEAD_SECONDS = 0.6;
|
|
32
32
|
/** Output gain applied while ducked (the user is speaking over the assistant). */
|
|
33
33
|
export const DUCK_GAIN = 0.25;
|
|
34
|
+
/**
|
|
35
|
+
* Cap on streamed PCM retained for the nonzero-exit replay. Past this the
|
|
36
|
+
* buffer is dropped: the failure being recovered is a short clip that fits the
|
|
37
|
+
* OS pipe buffer before a broken backend dies, while a backend that consumed
|
|
38
|
+
* minutes of realtime-paced audio was playing it — replaying a whole long
|
|
39
|
+
* utterance would duplicate audio, and unbounded retention (~5.8 MB/min at
|
|
40
|
+
* 24 kHz mono f32) would defeat streaming for long input.
|
|
41
|
+
*/
|
|
42
|
+
const REPLAY_RETENTION_SECONDS = 60;
|
|
34
43
|
|
|
35
44
|
/** Injection seam for {@link streamingPlayerCommandsFor} — defaults to real PATH/tools lookups. */
|
|
36
45
|
export interface StreamingPlayerLookup {
|
|
@@ -89,6 +98,20 @@ export function streamingPlayerCommandsFor(
|
|
|
89
98
|
return commands;
|
|
90
99
|
}
|
|
91
100
|
|
|
101
|
+
/**
|
|
102
|
+
* Test seams for {@link StreamingAudioPlayer}: override backend discovery and
|
|
103
|
+
* the per-file fallback so playback logic can be exercised without a real audio
|
|
104
|
+
* device. Both default to the platform lookup and {@link playAudioFile}.
|
|
105
|
+
*/
|
|
106
|
+
export interface StreamingPlayerOptions {
|
|
107
|
+
/** Ordered backend commands for a sample rate; defaults to {@link streamingPlayerCommandsFor}. */
|
|
108
|
+
commandsFor?: (sampleRate: number) => PlayerCommand[];
|
|
109
|
+
/** Per-file fallback playback; defaults to {@link playAudioFile}. */
|
|
110
|
+
playAudio?: (wavPath: string, signal: AbortSignal) => Promise<void>;
|
|
111
|
+
/** Max seconds of streamed PCM retained for the nonzero-exit replay; defaults to {@link REPLAY_RETENTION_SECONDS}. */
|
|
112
|
+
replayRetentionSeconds?: number;
|
|
113
|
+
}
|
|
114
|
+
|
|
92
115
|
/**
|
|
93
116
|
* Single-session gapless player. Lifecycle: {@link start} once, {@link write}
|
|
94
117
|
* chunks in order, then {@link end} to drain or {@link stop} to abort. Not
|
|
@@ -111,6 +134,18 @@ export class StreamingAudioPlayer {
|
|
|
111
134
|
#abortController = new AbortController();
|
|
112
135
|
#wake: (() => void) | null = null;
|
|
113
136
|
#drain: Promise<void> = Promise.resolve();
|
|
137
|
+
readonly #commandsFor: (sampleRate: number) => PlayerCommand[];
|
|
138
|
+
readonly #playAudio: (wavPath: string, signal: AbortSignal) => Promise<void>;
|
|
139
|
+
readonly #replayRetentionSec: number;
|
|
140
|
+
/** Streamed PCM retained for this utterance so a failed backend can be replayed via file playback. */
|
|
141
|
+
#played: Float32Array[] = [];
|
|
142
|
+
#playedSec = 0;
|
|
143
|
+
|
|
144
|
+
constructor(options: StreamingPlayerOptions = {}) {
|
|
145
|
+
this.#commandsFor = options.commandsFor ?? (rate => streamingPlayerCommandsFor(process.platform, rate));
|
|
146
|
+
this.#playAudio = options.playAudio ?? ((wavPath, signal) => playAudioFile(wavPath, { signal }));
|
|
147
|
+
this.#replayRetentionSec = options.replayRetentionSeconds ?? REPLAY_RETENTION_SECONDS;
|
|
148
|
+
}
|
|
114
149
|
|
|
115
150
|
/** Pick a backend and begin draining. Idempotent; the first call's rate wins. */
|
|
116
151
|
start(sampleRate: number): void {
|
|
@@ -146,6 +181,7 @@ export class StreamingAudioPlayer {
|
|
|
146
181
|
if (this.#stopped) return;
|
|
147
182
|
this.#stopped = true;
|
|
148
183
|
this.#queue.length = 0;
|
|
184
|
+
this.#played.length = 0;
|
|
149
185
|
this.#abortController.abort();
|
|
150
186
|
this.#signal();
|
|
151
187
|
try {
|
|
@@ -168,7 +204,7 @@ export class StreamingAudioPlayer {
|
|
|
168
204
|
* in-flight chunk.
|
|
169
205
|
*/
|
|
170
206
|
#spawnStream(): boolean {
|
|
171
|
-
this.#candidates ??=
|
|
207
|
+
this.#candidates ??= this.#commandsFor(this.#sampleRate);
|
|
172
208
|
for (let command = this.#candidates.shift(); command; command = this.#candidates.shift()) {
|
|
173
209
|
const { cmd, args } = command;
|
|
174
210
|
try {
|
|
@@ -213,6 +249,12 @@ export class StreamingAudioPlayer {
|
|
|
213
249
|
continue;
|
|
214
250
|
}
|
|
215
251
|
if (this.#mode === "stream") {
|
|
252
|
+
if (this.#playedSec <= this.#replayRetentionSec) {
|
|
253
|
+
this.#played.push(chunk);
|
|
254
|
+
this.#playedSec += chunk.length / this.#sampleRate;
|
|
255
|
+
// Over the cap: drop retention for the rest of the utterance.
|
|
256
|
+
if (this.#playedSec > this.#replayRetentionSec) this.#played.length = 0;
|
|
257
|
+
}
|
|
216
258
|
// Pace writes so the player buffers ~LEAD_SECONDS, no more, keeping
|
|
217
259
|
// ducking and stop responsive instead of locked behind buffered audio.
|
|
218
260
|
const ahead = this.#writtenSec - (performance.now() - this.#startedAt) / 1000;
|
|
@@ -242,11 +284,27 @@ export class StreamingAudioPlayer {
|
|
|
242
284
|
try {
|
|
243
285
|
await this.#sink?.end();
|
|
244
286
|
} catch {}
|
|
245
|
-
|
|
287
|
+
const proc = this.#proc;
|
|
288
|
+
let exitCode: number | null = null;
|
|
289
|
+
if (proc) {
|
|
246
290
|
try {
|
|
247
|
-
await
|
|
291
|
+
exitCode = await proc.exited;
|
|
248
292
|
} catch {}
|
|
249
293
|
}
|
|
294
|
+
// A streaming backend that exits nonzero never opened its audio
|
|
295
|
+
// device (e.g. the bundled ffmpeg built without pulse/alsa output).
|
|
296
|
+
// For a short single-segment clip the pipe write succeeds before
|
|
297
|
+
// that death and #inputClosed is already set, so neither the
|
|
298
|
+
// broken-pipe replay nor the early-exit handler advances backends.
|
|
299
|
+
// Replay the buffered utterance through per-file playback so it
|
|
300
|
+
// still reaches the speakers.
|
|
301
|
+
if (!this.#stopped && proc && exitCode !== 0) {
|
|
302
|
+
this.#mode = "file";
|
|
303
|
+
for (const chunk of this.#played) {
|
|
304
|
+
if (this.#stopped) break;
|
|
305
|
+
await this.#playFile(chunk);
|
|
306
|
+
}
|
|
307
|
+
}
|
|
250
308
|
}
|
|
251
309
|
} catch (error) {
|
|
252
310
|
logger.debug("tts: streaming player drain failed", {
|
|
@@ -291,7 +349,7 @@ export class StreamingAudioPlayer {
|
|
|
291
349
|
const wavPath = path.join(os.tmpdir(), `omp-speech-${Snowflake.next()}.wav`);
|
|
292
350
|
try {
|
|
293
351
|
await fs.writeFile(wavPath, encodeWav(this.#scaled(pcm), this.#sampleRate));
|
|
294
|
-
if (!this.#stopped) await
|
|
352
|
+
if (!this.#stopped) await this.#playAudio(wavPath, this.#abortController.signal);
|
|
295
353
|
} catch (error) {
|
|
296
354
|
logger.debug("tts: file playback failed", {
|
|
297
355
|
error: error instanceof Error ? error.message : String(error),
|
package/src/tts/vocalizer.ts
CHANGED
|
@@ -100,6 +100,8 @@ export class Vocalizer {
|
|
|
100
100
|
#chain: Promise<void> = Promise.resolve();
|
|
101
101
|
/** Whether the user is currently speaking; new sessions open ducked. */
|
|
102
102
|
#ducked = false;
|
|
103
|
+
/** Active scopes that suppress local TTS while another speech surface owns audio. */
|
|
104
|
+
#suspensions = 0;
|
|
103
105
|
/** Available rewrite slots; blocks queue when exhausted. */
|
|
104
106
|
#rewriteSlots = MAX_REWRITES_IN_FLIGHT;
|
|
105
107
|
#slotWaiters: Array<() => void> = [];
|
|
@@ -114,6 +116,21 @@ export class Vocalizer {
|
|
|
114
116
|
this.#enhancer = enhancer;
|
|
115
117
|
}
|
|
116
118
|
|
|
119
|
+
/**
|
|
120
|
+
* Suppress new vocalization until the returned idempotent release function runs.
|
|
121
|
+
* Existing synthesis and playback stop immediately; nested scopes release independently.
|
|
122
|
+
*/
|
|
123
|
+
suspend(): () => void {
|
|
124
|
+
this.#suspensions += 1;
|
|
125
|
+
this.clear();
|
|
126
|
+
let released = false;
|
|
127
|
+
return () => {
|
|
128
|
+
if (released) return;
|
|
129
|
+
released = true;
|
|
130
|
+
this.#suspensions = Math.max(0, this.#suspensions - 1);
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
117
134
|
/**
|
|
118
135
|
* Stream a delta of assistant text into the pipeline. No-op when
|
|
119
136
|
* vocalization is disabled. The synthesis session (worker, player) is only
|
|
@@ -123,7 +140,7 @@ export class Vocalizer {
|
|
|
123
140
|
* pipeline (enhanced vs mechanical) is latched per utterance.
|
|
124
141
|
*/
|
|
125
142
|
pushDelta(text: string): void {
|
|
126
|
-
if (!settings.get("speech.enabled")) return;
|
|
143
|
+
if (this.#suspensions > 0 || !settings.get("speech.enabled")) return;
|
|
127
144
|
if (!text) return;
|
|
128
145
|
if (this.#enhanced || (!this.#speakable && this.#enhancer && settings.get("speech.enhanced"))) {
|
|
129
146
|
this.#pushEnhanced(text);
|