@deepseek-harness-tui/dsh-tui 0.10.2 → 0.11.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +175 -123
- package/bin/dsh-tui.js +748 -22
- package/cordis.patch.yml +42 -13
- package/cordis.yml +5 -2
- package/lib/types/activityPrefs.d.ts +24 -0
- package/lib/types/activityPrefs.d.ts.map +1 -1
- package/lib/types/activityPrefs.js +50 -1
- package/lib/types/adapter/channel/session-projection.d.ts.map +1 -1
- package/lib/types/adapter/channel/session-projection.js +2 -0
- package/lib/types/adapter/channel/state.d.ts.map +1 -1
- package/lib/types/adapter/channel/state.js +3 -1
- package/lib/types/adapter/channel/ui-policy.d.ts +4 -1
- package/lib/types/adapter/channel/ui-policy.d.ts.map +1 -1
- package/lib/types/adapter/channel/ui-policy.js +6 -2
- package/lib/types/adapter/kernel/host-facade.d.ts.map +1 -1
- package/lib/types/adapter/kernel/host-facade.js +1 -0
- package/lib/types/adapter/kernel/replay.d.ts.map +1 -1
- package/lib/types/adapter/kernel/replay.js +3 -0
- package/lib/types/adapter/kernel/runtime.d.ts.map +1 -1
- package/lib/types/adapter/kernel/runtime.js +2 -0
- package/lib/types/adapter/kernel/slices/workspace.d.ts.map +1 -1
- package/lib/types/adapter/kernel/slices/workspace.js +1 -0
- package/lib/types/adapter/ports/channel-ui.d.ts +29 -5
- package/lib/types/adapter/ports/channel-ui.d.ts.map +1 -1
- package/lib/types/adapter/ports/channel-view.d.ts +66 -12
- package/lib/types/adapter/ports/channel-view.d.ts.map +1 -1
- package/lib/types/adapter/ports/channel-workspace.d.ts +23 -0
- package/lib/types/adapter/ports/channel-workspace.d.ts.map +1 -1
- package/lib/types/adapter/ports/channel.d.ts +4 -1
- package/lib/types/adapter/ports/channel.d.ts.map +1 -1
- package/lib/types/adapter/ports/workspace.d.ts +2 -0
- package/lib/types/adapter/ports/workspace.d.ts.map +1 -1
- package/lib/types/adapter/upstream/workspace-driver.d.ts.map +1 -1
- package/lib/types/adapter/upstream/workspace-driver.js +10 -2
- package/lib/types/commands.d.ts.map +1 -1
- package/lib/types/commands.js +1 -0
- package/lib/types/components/ActivityLine.d.ts +7 -2
- package/lib/types/components/ActivityLine.d.ts.map +1 -1
- package/lib/types/components/ActivityLine.js +0 -8
- package/lib/types/components/ContextBarView.d.ts +15 -7
- package/lib/types/components/ContextBarView.d.ts.map +1 -1
- package/lib/types/components/ContextBarView.js +26 -22
- package/lib/types/components/ImagePreviewOverlay.d.ts.map +1 -1
- package/lib/types/components/ImagePreviewOverlay.js +11 -5
- package/lib/types/components/Markdown.d.ts +11 -1
- package/lib/types/components/Markdown.d.ts.map +1 -1
- package/lib/types/components/Markdown.js +20 -2
- package/lib/types/components/MermaidDiagram.d.ts +13 -0
- package/lib/types/components/MermaidDiagram.d.ts.map +1 -0
- package/lib/types/components/MermaidDiagram.js +61 -0
- package/lib/types/components/MessageList.d.ts +26 -1
- package/lib/types/components/MessageList.d.ts.map +1 -1
- package/lib/types/components/MessageList.js +74 -8
- package/lib/types/components/PageMargin.d.ts +1 -1
- package/lib/types/components/PromptInput.d.ts +37 -1
- package/lib/types/components/PromptInput.d.ts.map +1 -1
- package/lib/types/components/PromptInput.js +291 -30
- package/lib/types/components/ScrollbarGutter.d.ts +20 -5
- package/lib/types/components/ScrollbarGutter.d.ts.map +1 -1
- package/lib/types/components/ScrollbarGutter.js +88 -10
- package/lib/types/components/SplitDiffView.d.ts.map +1 -1
- package/lib/types/components/SplitDiffView.js +2 -1
- package/lib/types/components/StreamingMarkdown.d.ts.map +1 -1
- package/lib/types/components/StreamingMarkdown.js +30 -28
- package/lib/types/components/SubagentCard.d.ts.map +1 -1
- package/lib/types/components/SubagentCard.js +12 -6
- package/lib/types/components/SubagentDetailScene.d.ts.map +1 -1
- package/lib/types/components/SubagentDetailScene.js +8 -3
- package/lib/types/components/Tooltip.d.ts.map +1 -1
- package/lib/types/components/Tooltip.js +45 -7
- package/lib/types/components/design-system/HintLine.d.ts +1 -2
- package/lib/types/components/design-system/HintLine.d.ts.map +1 -1
- package/lib/types/components/design-system/HintLine.js +1 -2
- package/lib/types/components/messages/AssistantThinkingMessage.d.ts +2 -1
- package/lib/types/components/messages/AssistantThinkingMessage.d.ts.map +1 -1
- package/lib/types/components/messages/AssistantThinkingMessage.js +2 -1
- package/lib/types/components/messages/AssistantToolUseMessage.d.ts +2 -2
- package/lib/types/components/messages/AssistantToolUseMessage.d.ts.map +1 -1
- package/lib/types/components/messages/AssistantToolUseMessage.js +30 -26
- package/lib/types/components/messages/UserPromptMessage.d.ts.map +1 -1
- package/lib/types/components/messages/UserPromptMessage.js +5 -7
- package/lib/types/components/messages/useImageInspection.d.ts.map +1 -1
- package/lib/types/components/messages/useImageInspection.js +19 -9
- package/lib/types/components/promptDraftCache.d.ts +102 -0
- package/lib/types/components/promptDraftCache.d.ts.map +1 -0
- package/lib/types/components/promptDraftCache.js +76 -0
- package/lib/types/components/questions/AskUserQuestionPanel.d.ts +17 -1
- package/lib/types/components/questions/AskUserQuestionPanel.d.ts.map +1 -1
- package/lib/types/components/questions/AskUserQuestionPanel.js +40 -7
- package/lib/types/components/questions/QuestionMinimizedBar.d.ts +12 -0
- package/lib/types/components/questions/QuestionMinimizedBar.d.ts.map +1 -0
- package/lib/types/components/questions/QuestionMinimizedBar.js +43 -0
- package/lib/types/components/sessions/SessionListRow.d.ts +23 -1
- package/lib/types/components/sessions/SessionListRow.d.ts.map +1 -1
- package/lib/types/components/sessions/SessionListRow.js +52 -5
- package/lib/types/components/workspaces/HomeWorkspaceRow.d.ts +49 -0
- package/lib/types/components/workspaces/HomeWorkspaceRow.d.ts.map +1 -0
- package/lib/types/components/workspaces/HomeWorkspaceRow.js +50 -0
- package/lib/types/dsh-adapter/activity-store.d.ts +221 -0
- package/lib/types/dsh-adapter/activity-store.d.ts.map +1 -0
- package/lib/types/dsh-adapter/activity-store.js +359 -0
- package/lib/types/dsh-adapter/bundled-presets.d.ts +10 -0
- package/lib/types/dsh-adapter/bundled-presets.d.ts.map +1 -0
- package/lib/types/dsh-adapter/bundled-presets.js +80 -0
- package/lib/types/dsh-adapter/channel/action-readiness.d.ts +1 -1
- package/lib/types/dsh-adapter/channel/action-readiness.d.ts.map +1 -1
- package/lib/types/dsh-adapter/channel/action-readiness.js +3 -0
- package/lib/types/dsh-adapter/channel/agent-view-projection.d.ts +1 -1
- package/lib/types/dsh-adapter/channel/agent-view-projection.d.ts.map +1 -1
- package/lib/types/dsh-adapter/channel/agent-view-projection.js +19 -2
- package/lib/types/dsh-adapter/channel/background-action.d.ts +1 -1
- package/lib/types/dsh-adapter/channel/background-action.d.ts.map +1 -1
- package/lib/types/dsh-adapter/channel/background-action.js +53 -29
- package/lib/types/dsh-adapter/channel/binding-events.d.ts +8 -6
- package/lib/types/dsh-adapter/channel/binding-events.d.ts.map +1 -1
- package/lib/types/dsh-adapter/channel/binding-events.js +5 -4
- package/lib/types/dsh-adapter/channel/binding.d.ts +19 -2
- package/lib/types/dsh-adapter/channel/binding.d.ts.map +1 -1
- package/lib/types/dsh-adapter/channel/binding.js +78 -18
- package/lib/types/dsh-adapter/channel/compaction.d.ts +0 -1
- package/lib/types/dsh-adapter/channel/compaction.d.ts.map +1 -1
- package/lib/types/dsh-adapter/channel/compaction.js +0 -2
- package/lib/types/dsh-adapter/channel/composer-images.d.ts +5 -0
- package/lib/types/dsh-adapter/channel/composer-images.d.ts.map +1 -1
- package/lib/types/dsh-adapter/channel/composer-images.js +105 -6
- package/lib/types/dsh-adapter/channel/context-bookkeeping.d.ts +8 -0
- package/lib/types/dsh-adapter/channel/context-bookkeeping.d.ts.map +1 -1
- package/lib/types/dsh-adapter/channel/context-bookkeeping.js +3 -2
- package/lib/types/dsh-adapter/channel/ide-selection.d.ts +58 -0
- package/lib/types/dsh-adapter/channel/ide-selection.d.ts.map +1 -0
- package/lib/types/dsh-adapter/channel/ide-selection.js +226 -0
- package/lib/types/dsh-adapter/channel/input-delivery.d.ts +6 -1
- package/lib/types/dsh-adapter/channel/input-delivery.d.ts.map +1 -1
- package/lib/types/dsh-adapter/channel/input-delivery.js +9 -1
- package/lib/types/dsh-adapter/channel/local-actions.d.ts +3 -19
- package/lib/types/dsh-adapter/channel/local-actions.d.ts.map +1 -1
- package/lib/types/dsh-adapter/channel/local-actions.js +2 -1
- package/lib/types/dsh-adapter/channel/model-actions.d.ts +4 -1
- package/lib/types/dsh-adapter/channel/model-actions.d.ts.map +1 -1
- package/lib/types/dsh-adapter/channel/model-actions.js +82 -6
- package/lib/types/dsh-adapter/channel/model-switch.d.ts +1 -2
- package/lib/types/dsh-adapter/channel/model-switch.d.ts.map +1 -1
- package/lib/types/dsh-adapter/channel/model-switch.js +49 -31
- package/lib/types/dsh-adapter/channel/projection.d.ts +4 -0
- package/lib/types/dsh-adapter/channel/projection.d.ts.map +1 -1
- package/lib/types/dsh-adapter/channel/projection.js +29 -19
- package/lib/types/dsh-adapter/channel/reports.d.ts.map +1 -1
- package/lib/types/dsh-adapter/channel/reports.js +4 -12
- package/lib/types/dsh-adapter/channel/session-adoption.d.ts +1 -1
- package/lib/types/dsh-adapter/channel/session-adoption.d.ts.map +1 -1
- package/lib/types/dsh-adapter/channel/session-adoption.js +1 -0
- package/lib/types/dsh-adapter/channel/session-fork.d.ts.map +1 -1
- package/lib/types/dsh-adapter/channel/session-fork.js +26 -2
- package/lib/types/dsh-adapter/channel/session-live-adoption.d.ts +4 -1
- package/lib/types/dsh-adapter/channel/session-live-adoption.d.ts.map +1 -1
- package/lib/types/dsh-adapter/channel/session-live-adoption.js +2 -0
- package/lib/types/dsh-adapter/channel/session-metadata.d.ts +10 -0
- package/lib/types/dsh-adapter/channel/session-metadata.d.ts.map +1 -1
- package/lib/types/dsh-adapter/channel/session-metadata.js +6 -4
- package/lib/types/dsh-adapter/channel/session-reset.d.ts +1 -1
- package/lib/types/dsh-adapter/channel/session-reset.d.ts.map +1 -1
- package/lib/types/dsh-adapter/channel/session-reset.js +0 -1
- package/lib/types/dsh-adapter/channel/session-resume.d.ts +12 -3
- package/lib/types/dsh-adapter/channel/session-resume.d.ts.map +1 -1
- package/lib/types/dsh-adapter/channel/session-resume.js +292 -94
- package/lib/types/dsh-adapter/channel/session-rewind.d.ts.map +1 -1
- package/lib/types/dsh-adapter/channel/session-rewind.js +34 -16
- package/lib/types/dsh-adapter/channel/session-tree-actions.d.ts.map +1 -1
- package/lib/types/dsh-adapter/channel/session-tree-actions.js +19 -3
- package/lib/types/dsh-adapter/channel/settings-host.d.ts.map +1 -1
- package/lib/types/dsh-adapter/channel/settings-host.js +4 -3
- package/lib/types/dsh-adapter/channel/state.d.ts +6 -1
- package/lib/types/dsh-adapter/channel/state.d.ts.map +1 -1
- package/lib/types/dsh-adapter/channel/state.js +3 -3
- package/lib/types/dsh-adapter/channel/subagent-projection.d.ts +21 -1
- package/lib/types/dsh-adapter/channel/subagent-projection.d.ts.map +1 -1
- package/lib/types/dsh-adapter/channel/subagent-projection.js +173 -6
- package/lib/types/dsh-adapter/channel/transcript.d.ts.map +1 -1
- package/lib/types/dsh-adapter/channel/transcript.js +6 -12
- package/lib/types/dsh-adapter/channel/types.d.ts +6 -1
- package/lib/types/dsh-adapter/channel/types.d.ts.map +1 -1
- package/lib/types/dsh-adapter/channel/workspace-actions.d.ts +4 -1
- package/lib/types/dsh-adapter/channel/workspace-actions.d.ts.map +1 -1
- package/lib/types/dsh-adapter/channel/workspace-actions.js +41 -1
- package/lib/types/dsh-adapter/channel.d.ts +1 -1
- package/lib/types/dsh-adapter/channel.d.ts.map +1 -1
- package/lib/types/dsh-adapter/channel.js +82 -20
- package/lib/types/dsh-adapter/compat/index.d.ts +1 -1
- package/lib/types/dsh-adapter/compat/index.d.ts.map +1 -1
- package/lib/types/dsh-adapter/compat/index.js +1 -1
- package/lib/types/dsh-adapter/compat/messages.d.ts +15 -0
- package/lib/types/dsh-adapter/compat/messages.d.ts.map +1 -0
- package/lib/types/dsh-adapter/compat/messages.js +15 -0
- package/lib/types/dsh-adapter/compat/sessionLog.d.ts +18 -0
- package/lib/types/dsh-adapter/compat/sessionLog.d.ts.map +1 -1
- package/lib/types/dsh-adapter/compat/sessionLog.js +18 -3
- package/lib/types/dsh-adapter/compat/settings.d.ts +35 -0
- package/lib/types/dsh-adapter/compat/settings.d.ts.map +1 -0
- package/lib/types/dsh-adapter/compat/settings.js +57 -0
- package/lib/types/dsh-adapter/compat/shell.d.ts +25 -0
- package/lib/types/dsh-adapter/compat/shell.d.ts.map +1 -0
- package/lib/types/dsh-adapter/compat/shell.js +11 -0
- package/lib/types/dsh-adapter/contract.d.ts +5 -4
- package/lib/types/dsh-adapter/contract.d.ts.map +1 -1
- package/lib/types/dsh-adapter/contract.js +17 -6
- package/lib/types/dsh-adapter/ide-channel.d.ts +224 -0
- package/lib/types/dsh-adapter/ide-channel.d.ts.map +1 -0
- package/lib/types/dsh-adapter/ide-channel.js +556 -0
- package/lib/types/dsh-adapter/index.d.ts +18 -3
- package/lib/types/dsh-adapter/index.d.ts.map +1 -1
- package/lib/types/dsh-adapter/index.js +32 -5
- package/lib/types/dsh-adapter/plugin.d.ts +4 -1
- package/lib/types/dsh-adapter/plugin.d.ts.map +1 -1
- package/lib/types/dsh-adapter/plugin.js +197 -49
- package/lib/types/dsh-adapter/preset-resolution.d.ts +8 -0
- package/lib/types/dsh-adapter/preset-resolution.d.ts.map +1 -1
- package/lib/types/dsh-adapter/preset-resolution.js +10 -0
- package/lib/types/dsh-adapter/session-mount-heartbeat.d.ts +53 -0
- package/lib/types/dsh-adapter/session-mount-heartbeat.d.ts.map +1 -0
- package/lib/types/dsh-adapter/session-mount-heartbeat.js +100 -0
- package/lib/types/dsh-adapter/sessionTree.d.ts.map +1 -1
- package/lib/types/dsh-adapter/sessionTree.js +3 -2
- package/lib/types/dsh-adapter/settings-sections.d.ts.map +1 -1
- package/lib/types/dsh-adapter/settings-sections.js +7 -4
- package/lib/types/dsh-adapter/subagents.d.ts +16 -3
- package/lib/types/dsh-adapter/subagents.d.ts.map +1 -1
- package/lib/types/dsh-adapter/subagents.js +92 -29
- package/lib/types/dsh-adapter/workspaces.d.ts +46 -3
- package/lib/types/dsh-adapter/workspaces.d.ts.map +1 -1
- package/lib/types/dsh-adapter/workspaces.js +116 -0
- package/lib/types/effortPrefs.d.ts +15 -3
- package/lib/types/effortPrefs.d.ts.map +1 -1
- package/lib/types/effortPrefs.js +37 -3
- package/lib/types/history.d.ts +13 -1
- package/lib/types/history.d.ts.map +1 -1
- package/lib/types/history.js +15 -2
- package/lib/types/homePrefs.d.ts +15 -0
- package/lib/types/homePrefs.d.ts.map +1 -0
- package/lib/types/homePrefs.js +53 -0
- package/lib/types/i18n.d.ts +347 -297
- package/lib/types/i18n.d.ts.map +1 -1
- package/lib/types/i18n.js +133 -85
- package/lib/types/ink/colorize.d.ts +10 -0
- package/lib/types/ink/colorize.d.ts.map +1 -1
- package/lib/types/ink/colorize.js +21 -0
- package/lib/types/ink/components/ScrollBox.d.ts.map +1 -1
- package/lib/types/ink/components/ScrollBox.js +12 -1
- package/lib/types/ink/dom.d.ts +1 -0
- package/lib/types/ink/dom.d.ts.map +1 -1
- package/lib/types/ink/hooks/use-copy-on-select.d.ts +5 -1
- package/lib/types/ink/hooks/use-copy-on-select.d.ts.map +1 -1
- package/lib/types/ink/hooks/use-copy-on-select.js +15 -1
- package/lib/types/ink/ink.d.ts.map +1 -1
- package/lib/types/ink/ink.js +36 -1
- package/lib/types/ink/log-update.d.ts.map +1 -1
- package/lib/types/ink/log-update.js +19 -1
- package/lib/types/ink/output.d.ts +10 -1
- package/lib/types/ink/output.d.ts.map +1 -1
- package/lib/types/ink/output.js +109 -17
- package/lib/types/ink/render-border.d.ts +6 -1
- package/lib/types/ink/render-border.d.ts.map +1 -1
- package/lib/types/ink/render-border.js +25 -9
- package/lib/types/ink/render-node-to-output.d.ts.map +1 -1
- package/lib/types/ink/render-node-to-output.js +48 -6
- package/lib/types/ink/renderer.d.ts +6 -0
- package/lib/types/ink/renderer.d.ts.map +1 -1
- package/lib/types/ink/renderer.js +2 -1
- package/lib/types/ink/selection.d.ts +88 -0
- package/lib/types/ink/selection.d.ts.map +1 -1
- package/lib/types/ink/selection.js +164 -0
- package/lib/types/ink/sixel-graphics.d.ts +19 -1
- package/lib/types/ink/sixel-graphics.d.ts.map +1 -1
- package/lib/types/ink/sixel-graphics.js +124 -12
- package/lib/types/ink/terminal-image.d.ts +18 -1
- package/lib/types/ink/terminal-image.d.ts.map +1 -1
- package/lib/types/ink/terminal.d.ts +17 -0
- package/lib/types/ink/terminal.d.ts.map +1 -1
- package/lib/types/ink/terminal.js +20 -1
- package/lib/types/screens/Chat.d.ts +20 -1
- package/lib/types/screens/Chat.d.ts.map +1 -1
- package/lib/types/screens/Chat.js +296 -76
- package/lib/types/screens/SessionSupervisor.d.ts +84 -0
- package/lib/types/screens/SessionSupervisor.d.ts.map +1 -0
- package/lib/types/screens/SessionSupervisor.js +350 -0
- package/lib/types/screens/SessionTree.js +2 -2
- package/lib/types/screens/Settings.d.ts.map +1 -1
- package/lib/types/screens/Settings.js +32 -9
- package/lib/types/screens/StatusLine.d.ts +18 -1
- package/lib/types/screens/StatusLine.d.ts.map +1 -1
- package/lib/types/screens/StatusLine.js +38 -19
- package/lib/types/screens/StatusMetrics.d.ts +79 -22
- package/lib/types/screens/StatusMetrics.d.ts.map +1 -1
- package/lib/types/screens/StatusMetrics.js +153 -88
- package/lib/types/screens/sessionSupervisor/model.d.ts +83 -0
- package/lib/types/screens/sessionSupervisor/model.d.ts.map +1 -0
- package/lib/types/screens/sessionSupervisor/model.js +69 -0
- package/lib/types/screens/sessionSupervisor/useSessionSupervisor.d.ts +120 -0
- package/lib/types/screens/sessionSupervisor/useSessionSupervisor.d.ts.map +1 -0
- package/lib/types/screens/sessionSupervisor/useSessionSupervisor.js +593 -0
- package/lib/types/sessionMounts.d.ts +272 -0
- package/lib/types/sessionMounts.d.ts.map +1 -0
- package/lib/types/sessionMounts.js +706 -0
- package/lib/types/sessions/format.d.ts.map +1 -1
- package/lib/types/sessions/format.js +4 -1
- package/lib/types/sessions/resumeFailure.d.ts +38 -0
- package/lib/types/sessions/resumeFailure.d.ts.map +1 -0
- package/lib/types/sessions/resumeFailure.js +58 -0
- package/lib/types/terminal-utils/mermaid.d.ts +44 -0
- package/lib/types/terminal-utils/mermaid.d.ts.map +1 -0
- package/lib/types/terminal-utils/mermaid.js +83 -0
- package/lib/types/tips.d.ts.map +1 -1
- package/lib/types/tips.js +175 -19
- package/lib/types/tuiDisplayPrefs.d.ts +7 -9
- package/lib/types/tuiDisplayPrefs.d.ts.map +1 -1
- package/lib/types/tuiDisplayPrefs.js +31 -28
- package/lib/types/update.d.ts +61 -0
- package/lib/types/update.d.ts.map +1 -1
- package/lib/types/update.js +194 -0
- package/lib/types/utils/fold-long-lines.d.ts +1 -1
- package/lib/types/utils/fold-long-lines.js +1 -1
- package/lib/types/utils/imageResize.d.ts +90 -0
- package/lib/types/utils/imageResize.d.ts.map +1 -0
- package/lib/types/utils/imageResize.js +272 -0
- package/lib/types/utils/keymap.d.ts +5 -1
- package/lib/types/utils/keymap.d.ts.map +1 -1
- package/lib/types/utils/keymap.js +23 -0
- package/lib/types/working-activity.d.ts +10 -9
- package/lib/types/working-activity.d.ts.map +1 -1
- package/lib/types/working-activity.js +17 -10
- package/lib/types/workspaces.d.ts +2 -1
- package/lib/types/workspaces.d.ts.map +1 -1
- package/lib/types/workspaces.js +1 -1
- package/node_modules/@deepseek-harness-tui/dsh-auth/package.json +10 -10
- package/package.json +126 -105
- package/presets/liangshen/.dsh-tui-managed.json +1 -1
- package/presets/liangshen/agent.cordis.yml +15 -0
- package/lib/types/components/design-system/KeyboardShortcutHint.d.ts +0 -19
- package/lib/types/components/design-system/KeyboardShortcutHint.d.ts.map +0 -1
- package/lib/types/components/design-system/KeyboardShortcutHint.js +0 -14
- package/lib/types/dsh-adapter/channel/activity.d.ts +0 -20
- package/lib/types/dsh-adapter/channel/activity.d.ts.map +0 -1
- package/lib/types/dsh-adapter/channel/activity.js +0 -113
- package/lib/types/screens/AgentView.d.ts +0 -23
- package/lib/types/screens/AgentView.d.ts.map +0 -1
- package/lib/types/screens/AgentView.js +0 -546
- package/lib/types/screens/SessionBrowser.d.ts +0 -42
- package/lib/types/screens/SessionBrowser.d.ts.map +0 -1
- package/lib/types/screens/SessionBrowser.js +0 -911
- package/lib/types/terminal-utils/terminal.d.ts +0 -18
- package/lib/types/terminal-utils/terminal.d.ts.map +0 -1
- package/lib/types/terminal-utils/terminal.js +0 -96
|
@@ -0,0 +1,706 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cross-process session mounting ledger, kept at
|
|
3
|
+
* `~/.dsh-tui/session-mounts.json`.
|
|
4
|
+
*
|
|
5
|
+
* A TUI terminal hosts SEVERAL agent sessions at once (the attached one plus
|
|
6
|
+
* every parked background session), and sessions are durable: another TUI, or
|
|
7
|
+
* `dsh web`, can list the same logs. Nothing in the DSH session store says
|
|
8
|
+
* *who is currently driving a log*, so two TUI processes resuming the same
|
|
9
|
+
* session would interleave writes into one append-only event log and corrupt
|
|
10
|
+
* the transcript. This file is that missing fact: each TUI process publishes
|
|
11
|
+
* the set of sessions it has mounted, and a reader that sees a foreign entry
|
|
12
|
+
* treats the session as OCCUPIED and refuses to mount it.
|
|
13
|
+
*
|
|
14
|
+
* Liveness is one witness, `process.kill(pid, 0)`: it catches a clean exit and
|
|
15
|
+
* a `kill -9`. There is no heartbeat timestamp, because a timestamp only stays
|
|
16
|
+
* truthful while a timer keeps refreshing it, and the process that cannot
|
|
17
|
+
* refresh it is exactly the one whose record should expire. The cost is pid
|
|
18
|
+
* REUSE — a recycled pid keeps a dead owner's record alive — which errs toward
|
|
19
|
+
* refusing a session that is in fact free and costs one restart; the opposite
|
|
20
|
+
* error interleaves two writers into one transcript. This is a same-machine
|
|
21
|
+
* guard only: the host's own session write lock stays the authority that
|
|
22
|
+
* actually separates writers.
|
|
23
|
+
*
|
|
24
|
+
* Reads never write. Pruning a dead owner happens on the write path, because a
|
|
25
|
+
* reader that wrote its snapshot back could erase a peer's record published
|
|
26
|
+
* between its read and its replace — silently dropping a live session from the
|
|
27
|
+
* occupancy table while its process kept writing.
|
|
28
|
+
*
|
|
29
|
+
* Writes take a short cross-process lock, re-read the file under it, and replace
|
|
30
|
+
* it atomically. The lock is token-checked, so a holder whose lock was reclaimed
|
|
31
|
+
* as stale cannot write over — or delete — the new holder's lock. A CLAIM
|
|
32
|
+
* re-derives its conflict under that lock ({@link claimMount}), because
|
|
33
|
+
* "check, then publish" is not atomic across processes.
|
|
34
|
+
*
|
|
35
|
+
* "I could not read the ledger" is NOT "nobody holds the session". Granting a
|
|
36
|
+
* write handle on a damaged or unreadable ledger is exactly the failure this
|
|
37
|
+
* module exists to prevent, and it is unrecoverable, so the authoritative
|
|
38
|
+
* paths refuse instead of degrading ({@link readMountLedgerStrict},
|
|
39
|
+
* {@link MountFailure}). Only the display surface reads best-effort.
|
|
40
|
+
*
|
|
41
|
+
* The lock is reclaimable only from a holder that is provably gone: its token
|
|
42
|
+
* carries the holder pid, so a lock whose holder is still ALIVE is never stolen
|
|
43
|
+
* from — not even by a peer that finds it old. That closes the window where a
|
|
44
|
+
* paused holder resumed after its lock had been reclaimed and committed a
|
|
45
|
+
* snapshot derived before the steal. The price is pid REUSE on a lock: a
|
|
46
|
+
* recycled pid keeps a dead holder's lock alive, and the remedy is to delete
|
|
47
|
+
* `session-mounts.lock` while every process sharing this data directory is
|
|
48
|
+
* stopped.
|
|
49
|
+
*/
|
|
50
|
+
import { closeSync, mkdirSync, openSync, readFileSync, readSync, renameSync, rmSync, statSync, writeFileSync, writeSync, } from 'node:fs';
|
|
51
|
+
import { randomBytes } from 'node:crypto';
|
|
52
|
+
import { join } from 'node:path';
|
|
53
|
+
import { DATA_DIR } from './utils/paths.js';
|
|
54
|
+
const MOUNTS_FILE = 'session-mounts.json';
|
|
55
|
+
const LOCK_FILE = 'session-mounts.lock';
|
|
56
|
+
/** Schema version of the persisted document. */
|
|
57
|
+
const MOUNTS_VERSION = 1;
|
|
58
|
+
/**
|
|
59
|
+
* How old a lock with no readable holder pid must be before it counts as a
|
|
60
|
+
* crash leftover. A lock whose token names a pid is judged by THAT witness
|
|
61
|
+
* instead (see {@link acquireLock}); this grace window only covers the sliver
|
|
62
|
+
* between creating the file and writing the token into it.
|
|
63
|
+
*/
|
|
64
|
+
const STALE_LOCK_MS = 30_000;
|
|
65
|
+
let temporarySequence = 0;
|
|
66
|
+
function hasCode(error, code) {
|
|
67
|
+
return typeof error === 'object' && error !== null && error.code === code;
|
|
68
|
+
}
|
|
69
|
+
/** Whether a published record was written by THIS process. */
|
|
70
|
+
export function ownerIsSelf(owner) {
|
|
71
|
+
return owner.pid === process.pid;
|
|
72
|
+
}
|
|
73
|
+
const EMPTY = { kind: 'free' };
|
|
74
|
+
/**
|
|
75
|
+
* Whether a process id is still alive. `process.kill(pid, 0)` sends no signal
|
|
76
|
+
* and throws `ESRCH` when the process is gone; `EPERM` means it exists but is
|
|
77
|
+
* owned by another user, which still counts as alive here.
|
|
78
|
+
*/
|
|
79
|
+
export function pidAlive(pid) {
|
|
80
|
+
if (!Number.isInteger(pid) || pid <= 0)
|
|
81
|
+
return false;
|
|
82
|
+
if (pid === process.pid)
|
|
83
|
+
return true;
|
|
84
|
+
try {
|
|
85
|
+
process.kill(pid, 0);
|
|
86
|
+
return true;
|
|
87
|
+
}
|
|
88
|
+
catch (error) {
|
|
89
|
+
return hasCode(error, 'EPERM');
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
/** Parse one record, or undefined when the shape is wrong. */
|
|
93
|
+
function parseOwner(value) {
|
|
94
|
+
if (typeof value !== 'object' || value === null)
|
|
95
|
+
return undefined;
|
|
96
|
+
const { pid, startedAt, sessionIds } = value;
|
|
97
|
+
if (typeof pid !== 'number' || !Number.isInteger(pid) || pid <= 0)
|
|
98
|
+
return undefined;
|
|
99
|
+
if (typeof startedAt !== 'number' || !Number.isFinite(startedAt))
|
|
100
|
+
return undefined;
|
|
101
|
+
if (!Array.isArray(sessionIds))
|
|
102
|
+
return undefined;
|
|
103
|
+
return {
|
|
104
|
+
pid,
|
|
105
|
+
startedAt,
|
|
106
|
+
sessionIds: sessionIds.filter((id) => typeof id === 'string' && id.length > 0),
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Read the ledger for DISPLAY, keeping every record the shape check accepts.
|
|
111
|
+
* A missing, empty, truncated or foreign-shaped document reads as an empty
|
|
112
|
+
* ledger, because a screen that cannot read the occupancy table should still
|
|
113
|
+
* paint the sessions it does know about.
|
|
114
|
+
*
|
|
115
|
+
* Never use this to decide whether a session may be mounted: "the read failed"
|
|
116
|
+
* and "nobody holds it" are the same answer here. Authoritative callers use
|
|
117
|
+
* {@link readMountLedgerStrict}.
|
|
118
|
+
*/
|
|
119
|
+
export function readMountLedger() {
|
|
120
|
+
try {
|
|
121
|
+
const parsed = JSON.parse(readFileSync(join(DATA_DIR, MOUNTS_FILE), 'utf8'));
|
|
122
|
+
if (typeof parsed !== 'object' || parsed === null)
|
|
123
|
+
return [];
|
|
124
|
+
const document = parsed;
|
|
125
|
+
if (document.version !== MOUNTS_VERSION || !Array.isArray(document.owners))
|
|
126
|
+
return [];
|
|
127
|
+
return document.owners
|
|
128
|
+
.map(parseOwner)
|
|
129
|
+
.filter((owner) => owner !== undefined);
|
|
130
|
+
}
|
|
131
|
+
catch {
|
|
132
|
+
return [];
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* Read the ledger for DECIDING, where "I could not read it" must not pass for
|
|
137
|
+
* "it is empty".
|
|
138
|
+
*
|
|
139
|
+
* Only a missing file is an empty ledger — that is the genuine first run.
|
|
140
|
+
* Everything else (unreadable, malformed JSON, unknown version, a record whose
|
|
141
|
+
* shape does not parse) is `unavailable`, and the caller must refuse. Skipping
|
|
142
|
+
* a bad record and writing the ledger anyway is not an option either: the
|
|
143
|
+
* record that failed to parse may be another writer's only claim, so the safe
|
|
144
|
+
* move is to leave the file exactly as it is and ask the user to repair it.
|
|
145
|
+
*/
|
|
146
|
+
export function readMountLedgerStrict() {
|
|
147
|
+
const path = join(DATA_DIR, MOUNTS_FILE);
|
|
148
|
+
let raw;
|
|
149
|
+
try {
|
|
150
|
+
raw = readFileSync(path, 'utf8');
|
|
151
|
+
}
|
|
152
|
+
catch (error) {
|
|
153
|
+
if (hasCode(error, 'ENOENT'))
|
|
154
|
+
return { ok: true, owners: [] };
|
|
155
|
+
return { ok: false, detail: `cannot read ${path}: ${error instanceof Error ? error.message : String(error)}` };
|
|
156
|
+
}
|
|
157
|
+
let parsed;
|
|
158
|
+
try {
|
|
159
|
+
parsed = JSON.parse(raw);
|
|
160
|
+
}
|
|
161
|
+
catch {
|
|
162
|
+
return { ok: false, detail: `${path} is not valid JSON` };
|
|
163
|
+
}
|
|
164
|
+
if (typeof parsed !== 'object' || parsed === null) {
|
|
165
|
+
return { ok: false, detail: `${path} is not a ledger document` };
|
|
166
|
+
}
|
|
167
|
+
const document = parsed;
|
|
168
|
+
if (document.version !== MOUNTS_VERSION) {
|
|
169
|
+
return { ok: false, detail: `${path} has unknown version ${JSON.stringify(document.version)}` };
|
|
170
|
+
}
|
|
171
|
+
if (!Array.isArray(document.owners)) {
|
|
172
|
+
return { ok: false, detail: `${path} has no owners array` };
|
|
173
|
+
}
|
|
174
|
+
const owners = [];
|
|
175
|
+
for (const record of document.owners) {
|
|
176
|
+
const owner = parseOwner(record);
|
|
177
|
+
if (owner === undefined)
|
|
178
|
+
return { ok: false, detail: `${path} has a record whose shape is wrong` };
|
|
179
|
+
owners.push(owner);
|
|
180
|
+
}
|
|
181
|
+
return { ok: true, owners };
|
|
182
|
+
}
|
|
183
|
+
/**
|
|
184
|
+
* Which process has each session mounted, considering only owners whose pid is
|
|
185
|
+
* still alive. A session claimed by several live owners keeps the record that
|
|
186
|
+
* started last: the protocol forbids that state, and picking one
|
|
187
|
+
* deterministically beats reporting whichever the file happened to list first.
|
|
188
|
+
*/
|
|
189
|
+
export function readSessionOwners() {
|
|
190
|
+
const owners = new Map();
|
|
191
|
+
for (const owner of readMountLedger()) {
|
|
192
|
+
if (!pidAlive(owner.pid))
|
|
193
|
+
continue;
|
|
194
|
+
for (const sessionId of owner.sessionIds) {
|
|
195
|
+
const existing = owners.get(sessionId);
|
|
196
|
+
if (existing === undefined || existing.startedAt < owner.startedAt)
|
|
197
|
+
owners.set(sessionId, owner);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
return owners;
|
|
201
|
+
}
|
|
202
|
+
/**
|
|
203
|
+
* The occupancy of one session, from THIS process's point of view.
|
|
204
|
+
* @param sessionId - Session id to look up.
|
|
205
|
+
* @param owners - A snapshot from {@link readSessionOwners}, so a screen can
|
|
206
|
+
* resolve a whole listing from one read instead of one per row.
|
|
207
|
+
* @returns `mine` for a self-held session, `occupied` for a foreign one.
|
|
208
|
+
*/
|
|
209
|
+
export function occupancyOf(sessionId, owners) {
|
|
210
|
+
const owner = owners.get(sessionId);
|
|
211
|
+
if (owner === undefined)
|
|
212
|
+
return EMPTY;
|
|
213
|
+
if (ownerIsSelf(owner))
|
|
214
|
+
return { kind: 'mine' };
|
|
215
|
+
return { kind: 'occupied', pid: owner.pid };
|
|
216
|
+
}
|
|
217
|
+
/** The pid recorded at the head of a lock token, or undefined when unreadable. */
|
|
218
|
+
function readLockHolderPid(lockPath) {
|
|
219
|
+
let fd;
|
|
220
|
+
try {
|
|
221
|
+
fd = openSync(lockPath, 'r');
|
|
222
|
+
}
|
|
223
|
+
catch {
|
|
224
|
+
return undefined;
|
|
225
|
+
}
|
|
226
|
+
try {
|
|
227
|
+
const buffer = Buffer.alloc(64);
|
|
228
|
+
const read = readSync(fd, buffer, 0, buffer.length, 0);
|
|
229
|
+
const token = buffer.subarray(0, read).toString('utf8').trim();
|
|
230
|
+
const separator = token.indexOf('-');
|
|
231
|
+
const pid = Number(separator < 0 ? token : token.slice(0, separator));
|
|
232
|
+
return Number.isInteger(pid) && pid > 0 ? pid : undefined;
|
|
233
|
+
}
|
|
234
|
+
catch {
|
|
235
|
+
return undefined;
|
|
236
|
+
}
|
|
237
|
+
finally {
|
|
238
|
+
try {
|
|
239
|
+
closeSync(fd);
|
|
240
|
+
}
|
|
241
|
+
catch {
|
|
242
|
+
// Losing the descriptor is not a reason to report a different verdict.
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
/**
|
|
247
|
+
* Whether an existing lock may be removed, and the peer that left it is not
|
|
248
|
+
* coming back.
|
|
249
|
+
*
|
|
250
|
+
* A readable holder pid is the whole verdict: dead holder, reclaim; live
|
|
251
|
+
* holder, leave it alone however old the file is. Only a lock whose token
|
|
252
|
+
* could not be read (the window between `open` and `write`, or a crash inside
|
|
253
|
+
* it) falls back to age.
|
|
254
|
+
*/
|
|
255
|
+
function lockIsReclaimable(lockPath) {
|
|
256
|
+
const holderPid = readLockHolderPid(lockPath);
|
|
257
|
+
if (holderPid !== undefined)
|
|
258
|
+
return !pidAlive(holderPid);
|
|
259
|
+
try {
|
|
260
|
+
return Date.now() - statSync(lockPath).mtimeMs > STALE_LOCK_MS;
|
|
261
|
+
}
|
|
262
|
+
catch {
|
|
263
|
+
return false;
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
/** Take the short cross-process lock, or report why another writer kept it. */
|
|
267
|
+
function acquireLock() {
|
|
268
|
+
try {
|
|
269
|
+
mkdirSync(DATA_DIR, { recursive: true, mode: 0o700 });
|
|
270
|
+
}
|
|
271
|
+
catch (error) {
|
|
272
|
+
return {
|
|
273
|
+
ok: false,
|
|
274
|
+
reason: 'unavailable',
|
|
275
|
+
detail: `cannot create ${DATA_DIR}: ${error instanceof Error ? error.message : String(error)}`,
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
const lockPath = join(DATA_DIR, LOCK_FILE);
|
|
279
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
280
|
+
let fd;
|
|
281
|
+
try {
|
|
282
|
+
fd = openSync(lockPath, 'wx', 0o600);
|
|
283
|
+
}
|
|
284
|
+
catch (error) {
|
|
285
|
+
if (!hasCode(error, 'EEXIST')) {
|
|
286
|
+
return {
|
|
287
|
+
ok: false,
|
|
288
|
+
reason: 'unavailable',
|
|
289
|
+
detail: `cannot create ${lockPath}: ${error instanceof Error ? error.message : String(error)}`,
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
if (attempt > 0)
|
|
293
|
+
return { ok: false, reason: 'busy' };
|
|
294
|
+
try {
|
|
295
|
+
if (!lockIsReclaimable(lockPath))
|
|
296
|
+
return { ok: false, reason: 'busy' };
|
|
297
|
+
rmSync(lockPath, { force: true });
|
|
298
|
+
}
|
|
299
|
+
catch {
|
|
300
|
+
// Racing a peer that just released (or re-took) the lock is ordinary.
|
|
301
|
+
return { ok: false, reason: 'busy' };
|
|
302
|
+
}
|
|
303
|
+
continue;
|
|
304
|
+
}
|
|
305
|
+
const token = `${process.pid}-${randomBytes(6).toString('hex')}`;
|
|
306
|
+
try {
|
|
307
|
+
// `writeSync` on the descriptor (not `writeFileSync`) so the token lands
|
|
308
|
+
// in the file this fd owns, and flush before anyone can read it.
|
|
309
|
+
writeSync(fd, `${token}\n`);
|
|
310
|
+
return { ok: true, lock: { fd, path: lockPath, token } };
|
|
311
|
+
}
|
|
312
|
+
catch (error) {
|
|
313
|
+
try {
|
|
314
|
+
closeSync(fd);
|
|
315
|
+
}
|
|
316
|
+
catch {
|
|
317
|
+
// The lock name is removed below regardless.
|
|
318
|
+
}
|
|
319
|
+
try {
|
|
320
|
+
rmSync(lockPath, { force: true });
|
|
321
|
+
}
|
|
322
|
+
catch {
|
|
323
|
+
// A stale lock is recoverable on the next mutation.
|
|
324
|
+
}
|
|
325
|
+
return {
|
|
326
|
+
ok: false,
|
|
327
|
+
reason: 'unavailable',
|
|
328
|
+
detail: `cannot write ${lockPath}: ${error instanceof Error ? error.message : String(error)}`,
|
|
329
|
+
};
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
return { ok: false, reason: 'busy' };
|
|
333
|
+
}
|
|
334
|
+
/**
|
|
335
|
+
* Whether this holder's lock is still the one on disk. False means the lock was
|
|
336
|
+
* reclaimed (or replaced), so the holder must NOT write and must NOT delete the
|
|
337
|
+
* file — it belongs to somebody else now.
|
|
338
|
+
*/
|
|
339
|
+
function lockIsHeld(lock) {
|
|
340
|
+
try {
|
|
341
|
+
const fd = openSync(lock.path, 'r');
|
|
342
|
+
try {
|
|
343
|
+
const buffer = Buffer.alloc(256);
|
|
344
|
+
const read = readSync(fd, buffer, 0, buffer.length, 0);
|
|
345
|
+
return buffer.subarray(0, read).toString('utf8').trim() === lock.token;
|
|
346
|
+
}
|
|
347
|
+
finally {
|
|
348
|
+
closeSync(fd);
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
catch {
|
|
352
|
+
return false;
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
/** Release the lock taken by {@link acquireLock}, if it is still ours. */
|
|
356
|
+
function releaseLock(lock) {
|
|
357
|
+
try {
|
|
358
|
+
closeSync(lock.fd);
|
|
359
|
+
}
|
|
360
|
+
catch {
|
|
361
|
+
// Removing the name below is what actually frees the lock.
|
|
362
|
+
}
|
|
363
|
+
if (!lockIsHeld(lock))
|
|
364
|
+
return;
|
|
365
|
+
try {
|
|
366
|
+
rmSync(lock.path, { force: true });
|
|
367
|
+
}
|
|
368
|
+
catch {
|
|
369
|
+
// A stale lock is reclaimable on the next mutation.
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
/**
|
|
373
|
+
* Atomically replace the ledger with the live foreign records plus `mine`.
|
|
374
|
+
*
|
|
375
|
+
* A random-suffixed sibling plus a rename means a reader never observes a
|
|
376
|
+
* half-written document, and a crash mid-write leaves the previous ledger
|
|
377
|
+
* intact. CALLERS MUST HOLD THE LOCK: the replacement is rebuilt from a read
|
|
378
|
+
* taken while holding it, so a peer that published since the caller's last read
|
|
379
|
+
* survives, and dead owners are dropped here — the write path is the only place
|
|
380
|
+
* that prunes.
|
|
381
|
+
*
|
|
382
|
+
* The read is the STRICT one. Rebuilding from a best-effort read would let a
|
|
383
|
+
* damaged ledger be "repaired" into whatever this process happened to parse,
|
|
384
|
+
* silently dropping the claims that failed to parse — including a peer's only
|
|
385
|
+
* declaration. A ledger that cannot be read is left exactly as it is.
|
|
386
|
+
* @param mine - This process's record; undefined clears our own record.
|
|
387
|
+
* @returns Whether the ledger was replaced, and why not when it was not.
|
|
388
|
+
*/
|
|
389
|
+
function writeLedger(mine) {
|
|
390
|
+
const target = join(DATA_DIR, MOUNTS_FILE);
|
|
391
|
+
// The timestamp is part of the name, not decoration: a crash can leave a
|
|
392
|
+
// temp file behind, and a reused pid would then collide with `<pid>.<seq>`
|
|
393
|
+
// and fail the `wx` create.
|
|
394
|
+
const temporary = `${target}.${process.pid}.${Date.now()}.${temporarySequence++}.tmp`;
|
|
395
|
+
const existing = readMountLedgerStrict();
|
|
396
|
+
if (!existing.ok)
|
|
397
|
+
return { ok: false, reason: 'unavailable', detail: existing.detail };
|
|
398
|
+
try {
|
|
399
|
+
mkdirSync(DATA_DIR, { recursive: true, mode: 0o700 });
|
|
400
|
+
const records = existing.owners.filter(owner => !ownerIsSelf(owner) && pidAlive(owner.pid));
|
|
401
|
+
if (mine !== undefined)
|
|
402
|
+
records.push(mine);
|
|
403
|
+
writeFileSync(temporary, JSON.stringify({ version: MOUNTS_VERSION, owners: records }, null, 2), { encoding: 'utf8', flag: 'wx', mode: 0o600 });
|
|
404
|
+
renameSync(temporary, target);
|
|
405
|
+
return { ok: true };
|
|
406
|
+
}
|
|
407
|
+
catch (error) {
|
|
408
|
+
try {
|
|
409
|
+
rmSync(temporary, { force: true });
|
|
410
|
+
}
|
|
411
|
+
catch {
|
|
412
|
+
// The previous ledger is still intact; nothing else is safe to do.
|
|
413
|
+
}
|
|
414
|
+
return {
|
|
415
|
+
ok: false,
|
|
416
|
+
reason: 'unavailable',
|
|
417
|
+
detail: `cannot replace ${target}: ${error instanceof Error ? error.message : String(error)}`,
|
|
418
|
+
};
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
/** This process's own record, as last published. */
|
|
422
|
+
let ownStartedAt;
|
|
423
|
+
/** Session ids this process currently has mounted. */
|
|
424
|
+
const ownSessionIds = new Set();
|
|
425
|
+
/** This process's record, or undefined before the first publish. */
|
|
426
|
+
function ownRecord() {
|
|
427
|
+
return { pid: process.pid, startedAt: ownStartedAt ?? Date.now(), sessionIds: [...ownSessionIds] };
|
|
428
|
+
}
|
|
429
|
+
/**
|
|
430
|
+
* Sessions with an in-flight mount operation, keyed to the operations holding
|
|
431
|
+
* them. A claim is committed to the ledger BEFORE the agent it stands for
|
|
432
|
+
* exists in the registry (a resume reads preset, route and workspace first),
|
|
433
|
+
* so for that window the claim has no roster entry backing it — and a publisher
|
|
434
|
+
* beat that rebuilt the published set from the roster alone would erase it,
|
|
435
|
+
* handing the session to whatever peer asks next.
|
|
436
|
+
*/
|
|
437
|
+
const mountOperations = new Map();
|
|
438
|
+
/**
|
|
439
|
+
* Pin `sessionId` for the duration of one mount operation, so a concurrent
|
|
440
|
+
* {@link publishMounts} beat cannot drop its claim, and so the session counts
|
|
441
|
+
* as held until the caller has finished closing whatever it opened.
|
|
442
|
+
*
|
|
443
|
+
* The token identifies THIS operation only: it keeps a stale operation's
|
|
444
|
+
* cleanup from deleting a newer operation's pin. It is not a cross-process
|
|
445
|
+
* fencing token and does not replace the ledger. Every caller gets one through
|
|
446
|
+
* {@link reserveMount} or {@link reserveNewSession}.
|
|
447
|
+
* @param sessionId - Session the operation is about to mount.
|
|
448
|
+
* @returns An idempotent release for the pin.
|
|
449
|
+
*/
|
|
450
|
+
function holdMountOperation(sessionId) {
|
|
451
|
+
const token = Symbol('session-mount-operation');
|
|
452
|
+
let tokens = mountOperations.get(sessionId);
|
|
453
|
+
if (tokens === undefined) {
|
|
454
|
+
tokens = new Set();
|
|
455
|
+
mountOperations.set(sessionId, tokens);
|
|
456
|
+
}
|
|
457
|
+
tokens.add(token);
|
|
458
|
+
let released = false;
|
|
459
|
+
return () => {
|
|
460
|
+
if (released)
|
|
461
|
+
return;
|
|
462
|
+
released = true;
|
|
463
|
+
const current = mountOperations.get(sessionId);
|
|
464
|
+
if (current === undefined)
|
|
465
|
+
return;
|
|
466
|
+
current.delete(token);
|
|
467
|
+
if (current.size === 0)
|
|
468
|
+
mountOperations.delete(sessionId);
|
|
469
|
+
};
|
|
470
|
+
}
|
|
471
|
+
/**
|
|
472
|
+
* Publish this process's mounted set. Call at boot, whenever the set changes,
|
|
473
|
+
* and just before teardown, so a peer always reads a current answer without a
|
|
474
|
+
* timer keeping it fresh.
|
|
475
|
+
*
|
|
476
|
+
* The argument is the set the caller can OBSERVE (the live agent roster). Any
|
|
477
|
+
* session with an in-flight operation is added on top, because a claim is
|
|
478
|
+
* published before its agent exists: without that union a beat landing inside
|
|
479
|
+
* a resume would delete the reservation this process just committed.
|
|
480
|
+
* @param sessionIds - The sessions currently mounted (replaces the prior set).
|
|
481
|
+
* @returns True when the ledger was updated.
|
|
482
|
+
*/
|
|
483
|
+
export function publishMounts(sessionIds) {
|
|
484
|
+
const now = Date.now();
|
|
485
|
+
// Materialize the argument BEFORE clearing the live set. A caller may pass
|
|
486
|
+
// `ownSessionIds` (or a view of it) to re-publish the current claim with one
|
|
487
|
+
// change, and clearing first would then erase the input as it is read.
|
|
488
|
+
const requested = [...sessionIds];
|
|
489
|
+
ownSessionIds.clear();
|
|
490
|
+
for (const sessionId of requested) {
|
|
491
|
+
if (typeof sessionId === 'string' && sessionId.length > 0)
|
|
492
|
+
ownSessionIds.add(sessionId);
|
|
493
|
+
}
|
|
494
|
+
for (const sessionId of mountOperations.keys())
|
|
495
|
+
ownSessionIds.add(sessionId);
|
|
496
|
+
if (ownStartedAt === undefined)
|
|
497
|
+
ownStartedAt = now;
|
|
498
|
+
const attempt = acquireLock();
|
|
499
|
+
if (!attempt.ok)
|
|
500
|
+
return false;
|
|
501
|
+
const lock = attempt.lock;
|
|
502
|
+
try {
|
|
503
|
+
// A lock reclaimed by a peer belongs to somebody else now: this process's
|
|
504
|
+
// snapshot is older than theirs, so it must not be committed over it.
|
|
505
|
+
if (!lockIsHeld(lock))
|
|
506
|
+
return false;
|
|
507
|
+
return writeLedger(ownRecord()).ok;
|
|
508
|
+
}
|
|
509
|
+
finally {
|
|
510
|
+
releaseLock(lock);
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
/**
|
|
514
|
+
* Claim one session for THIS process, atomically.
|
|
515
|
+
*
|
|
516
|
+
* The occupancy check and the claim have to be one lock-protected step: two
|
|
517
|
+
* processes that each check first and publish second can both observe `free`
|
|
518
|
+
* and both publish, which is exactly the state this ledger exists to prevent.
|
|
519
|
+
* Inside the lock the conflict is re-derived from the file, and a session that
|
|
520
|
+
* is genuinely ours is accepted — a second mount of our own parked handle is
|
|
521
|
+
* not a conflict. When a live PEER holds it, nothing is written.
|
|
522
|
+
*
|
|
523
|
+
* A refusal that cannot name a peer is reported as `busy` (the lock was taken)
|
|
524
|
+
* or `unavailable` (the ledger could not be read or replaced) — never as an
|
|
525
|
+
* empty holder list, because callers have to be able to tell "somebody else
|
|
526
|
+
* has it" from "we could not find out".
|
|
527
|
+
* @param sessionId - Session id to claim.
|
|
528
|
+
* @returns `ok`, or why the session is not ours.
|
|
529
|
+
*/
|
|
530
|
+
export function claimMount(sessionId) {
|
|
531
|
+
const attempt = acquireLock();
|
|
532
|
+
if (!attempt.ok) {
|
|
533
|
+
return attempt.reason === 'busy'
|
|
534
|
+
? { ok: false, reason: 'busy' }
|
|
535
|
+
: { ok: false, reason: 'unavailable', detail: attempt.detail };
|
|
536
|
+
}
|
|
537
|
+
const lock = attempt.lock;
|
|
538
|
+
try {
|
|
539
|
+
// Lost the lock (reclaimed by a peer): the file on disk is no longer the one
|
|
540
|
+
// this claim was derived from, so refuse rather than write a stale merge.
|
|
541
|
+
if (!lockIsHeld(lock))
|
|
542
|
+
return { ok: false, reason: 'busy' };
|
|
543
|
+
const read = readMountLedgerStrict();
|
|
544
|
+
if (!read.ok)
|
|
545
|
+
return { ok: false, reason: 'unavailable', detail: read.detail };
|
|
546
|
+
const holders = [];
|
|
547
|
+
for (const owner of read.owners) {
|
|
548
|
+
if (ownerIsSelf(owner) || !pidAlive(owner.pid))
|
|
549
|
+
continue;
|
|
550
|
+
if (owner.sessionIds.includes(sessionId))
|
|
551
|
+
holders.push(owner.pid);
|
|
552
|
+
}
|
|
553
|
+
if (holders.length > 0)
|
|
554
|
+
return { ok: false, reason: 'occupied', holders };
|
|
555
|
+
// Whether THIS claim is what publishes the session: a session already in
|
|
556
|
+
// our set may have been put there by a parked handle or a publisher beat,
|
|
557
|
+
// and a caller that later gives its reservation back must not take it away.
|
|
558
|
+
const fresh = !ownSessionIds.has(sessionId);
|
|
559
|
+
ownSessionIds.add(sessionId);
|
|
560
|
+
if (ownStartedAt === undefined)
|
|
561
|
+
ownStartedAt = Date.now();
|
|
562
|
+
const written = writeLedger(ownRecord());
|
|
563
|
+
if (!written.ok) {
|
|
564
|
+
// The claim was not persisted, so it must not be held in memory either:
|
|
565
|
+
// the process must not believe it owns what the ledger does not record.
|
|
566
|
+
ownSessionIds.delete(sessionId);
|
|
567
|
+
return written;
|
|
568
|
+
}
|
|
569
|
+
return { ok: true, fresh };
|
|
570
|
+
}
|
|
571
|
+
finally {
|
|
572
|
+
releaseLock(lock);
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
/**
|
|
576
|
+
* Drop one session from this process's published set. Used when a session is
|
|
577
|
+
* explicitly closed; an ordinary session switch KEEPS its session mounted
|
|
578
|
+
* (that is the point of parking), so most switches do not call this.
|
|
579
|
+
* @param sessionId - Session id to release.
|
|
580
|
+
* @returns True when the ledger was updated.
|
|
581
|
+
*/
|
|
582
|
+
export function releaseMount(sessionId) {
|
|
583
|
+
if (!ownSessionIds.delete(sessionId))
|
|
584
|
+
return true;
|
|
585
|
+
// Snapshot before republishing: `publishMounts` clears `ownSessionIds` before
|
|
586
|
+
// it reads its argument, so handing it the live Set would empty the very
|
|
587
|
+
// claim being rebuilt and release every session this process holds.
|
|
588
|
+
return publishMounts([...ownSessionIds]);
|
|
589
|
+
}
|
|
590
|
+
/**
|
|
591
|
+
* Remove this process's record entirely, so every session it held is
|
|
592
|
+
* immediately mountable by another TUI. Called from the teardown funnel; a
|
|
593
|
+
* crashed process skips this and its record is pruned by the next write
|
|
594
|
+
* instead, which is why both paths must work.
|
|
595
|
+
* @returns True when the ledger was updated.
|
|
596
|
+
*/
|
|
597
|
+
export function clearOwnMounts() {
|
|
598
|
+
const attempt = acquireLock();
|
|
599
|
+
if (!attempt.ok)
|
|
600
|
+
return false;
|
|
601
|
+
const lock = attempt.lock;
|
|
602
|
+
try {
|
|
603
|
+
if (!lockIsHeld(lock))
|
|
604
|
+
return false;
|
|
605
|
+
ownSessionIds.clear();
|
|
606
|
+
mountOperations.clear();
|
|
607
|
+
ownStartedAt = undefined;
|
|
608
|
+
return writeLedger(undefined).ok;
|
|
609
|
+
}
|
|
610
|
+
finally {
|
|
611
|
+
releaseLock(lock);
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
/**
|
|
615
|
+
* {@link reserveMount} for a session id this process just minted, where a
|
|
616
|
+
* refusal is a loss of ANNOUNCEMENT rather than a conflict.
|
|
617
|
+
*
|
|
618
|
+
* A brand-new id cannot be held by anybody, so a create must not be blocked by
|
|
619
|
+
* a ledger it cannot write — but it still has to be announced before the
|
|
620
|
+
* factory runs, because from the moment the factory returns this process owns
|
|
621
|
+
* the only write handle and the publisher will not name the session until its
|
|
622
|
+
* next beat. A refusal therefore yields a no-op reservation and the caller
|
|
623
|
+
* carries on; `failure` reports what happened so the caller can say so.
|
|
624
|
+
* @param sessionId - Freshly minted session id.
|
|
625
|
+
* @returns A reservation (never a refusal) plus the failure, if any.
|
|
626
|
+
*/
|
|
627
|
+
export async function reserveNewSession(sessionId) {
|
|
628
|
+
const reserved = await reserveMount(sessionId);
|
|
629
|
+
if (reserved.ok)
|
|
630
|
+
return { reservation: reserved.reservation };
|
|
631
|
+
return { reservation: { settle: () => { }, abandon: () => { } }, failure: reserved };
|
|
632
|
+
}
|
|
633
|
+
/**
|
|
634
|
+
* The sessions this process has published, for the diagnostics surface.
|
|
635
|
+
* @returns The ids last handed to {@link publishMounts}.
|
|
636
|
+
*/
|
|
637
|
+
export function ownMounts() {
|
|
638
|
+
return [...ownSessionIds];
|
|
639
|
+
}
|
|
640
|
+
/**
|
|
641
|
+
* Backoff schedule for a lock a peer is holding right now. The lock only spans
|
|
642
|
+
* one read-and-replace of a small file, so a few hundred milliseconds covers
|
|
643
|
+
* every ordinary collision; this is an interaction budget, not a correctness
|
|
644
|
+
* condition — the claim itself is what decides, every time it is retried.
|
|
645
|
+
*/
|
|
646
|
+
const CLAIM_RETRY_DELAYS_MS = [25, 50, 100, 200, 400];
|
|
647
|
+
/**
|
|
648
|
+
* {@link claimMount} with a bounded retry for `busy`.
|
|
649
|
+
*
|
|
650
|
+
* `busy` means a peer held the ledger lock for the instant we wanted it, which
|
|
651
|
+
* says nothing about occupancy and is worth waiting out. `occupied` and
|
|
652
|
+
* `unavailable` are answers, not collisions, and are returned immediately.
|
|
653
|
+
* @param sessionId - Session id to claim.
|
|
654
|
+
* @returns The final claim, `busy` only when the whole budget was spent.
|
|
655
|
+
*/
|
|
656
|
+
export async function claimMountWithRetry(sessionId) {
|
|
657
|
+
let claim = claimMount(sessionId);
|
|
658
|
+
for (const delay of CLAIM_RETRY_DELAYS_MS) {
|
|
659
|
+
if (claim.ok || claim.reason !== 'busy')
|
|
660
|
+
return claim;
|
|
661
|
+
await new Promise(resolve => setTimeout(resolve, delay));
|
|
662
|
+
claim = claimMount(sessionId);
|
|
663
|
+
}
|
|
664
|
+
return claim;
|
|
665
|
+
}
|
|
666
|
+
/**
|
|
667
|
+
* Claim `sessionId` and pin it for the WHOLE mount attempt, not just the claim
|
|
668
|
+
* call.
|
|
669
|
+
*
|
|
670
|
+
* Everything that opens a writer goes through here, so that the rules live in
|
|
671
|
+
* one place: the claim is atomic (nothing is opened on a refusal), the pin
|
|
672
|
+
* survives a publisher beat while the agent does not exist yet, and the
|
|
673
|
+
* reservation ends explicitly — `settle` on a commit, `abandon` on every path
|
|
674
|
+
* that does not commit.
|
|
675
|
+
* @param sessionId - Session about to be mounted.
|
|
676
|
+
* @returns The reservation, or the {@link MountFailure} to report back.
|
|
677
|
+
*/
|
|
678
|
+
export async function reserveMount(sessionId) {
|
|
679
|
+
const claim = await claimMountWithRetry(sessionId);
|
|
680
|
+
if (!claim.ok)
|
|
681
|
+
return claim;
|
|
682
|
+
const releaseOperation = holdMountOperation(sessionId);
|
|
683
|
+
let settled = false;
|
|
684
|
+
return {
|
|
685
|
+
ok: true,
|
|
686
|
+
reservation: {
|
|
687
|
+
settle: () => {
|
|
688
|
+
if (settled)
|
|
689
|
+
return;
|
|
690
|
+
settled = true;
|
|
691
|
+
releaseOperation();
|
|
692
|
+
},
|
|
693
|
+
abandon: () => {
|
|
694
|
+
if (settled)
|
|
695
|
+
return;
|
|
696
|
+
settled = true;
|
|
697
|
+
releaseOperation();
|
|
698
|
+
// Only give back what this reservation took. A claim that inherited the
|
|
699
|
+
// session (already ours) must not release it: this process was driving
|
|
700
|
+
// that log before the attempt and still is.
|
|
701
|
+
if (claim.fresh)
|
|
702
|
+
releaseMount(sessionId);
|
|
703
|
+
},
|
|
704
|
+
},
|
|
705
|
+
};
|
|
706
|
+
}
|