@visual-z/xeno 0.8.4 → 0.9.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 +8 -0
- package/README.md +31 -746
- package/bin/xeno.exe +26 -0
- package/install.cjs +88 -0
- package/package.json +18 -48
- package/docs/app-server.md +0 -115
- package/docs/foundations.md +0 -88
- package/docs/install.md +0 -244
- package/docs/providers.md +0 -172
- package/docs/research/foundation-clients-2026-10-09.md +0 -27
- package/docs/research/foundation-editing-2026-10-09.md +0 -56
- package/docs/research/foundation-network-2026-10-09.md +0 -37
- package/docs/research/foundation-workflow-2026-10-09.md +0 -37
- package/src/apps/acp-server.ts +0 -41
- package/src/apps/agent-profiles.ts +0 -19
- package/src/apps/app-server.ts +0 -30
- package/src/apps/approve.ts +0 -49
- package/src/apps/cli.ts +0 -99
- package/src/apps/commands.ts +0 -319
- package/src/apps/completions.ts +0 -87
- package/src/apps/control-transport.ts +0 -90
- package/src/apps/export-cli.ts +0 -24
- package/src/apps/goal-flow.ts +0 -70
- package/src/apps/image-draft.ts +0 -46
- package/src/apps/input.ts +0 -312
- package/src/apps/model-catalog.ts +0 -69
- package/src/apps/preferences.ts +0 -20
- package/src/apps/project-trust.ts +0 -9
- package/src/apps/provider-connect.ts +0 -346
- package/src/apps/question-ui.ts +0 -27
- package/src/apps/render.ts +0 -181
- package/src/apps/resources.ts +0 -66
- package/src/apps/review.ts +0 -109
- package/src/apps/session-hub.ts +0 -96
- package/src/apps/session-lifecycle.ts +0 -64
- package/src/apps/shell.ts +0 -2071
- package/src/apps/startup-input.ts +0 -135
- package/src/apps/startup.ts +0 -1677
- package/src/apps/ui/app.ts +0 -1818
- package/src/apps/ui/control-model.ts +0 -43
- package/src/apps/ui/extension-host.ts +0 -194
- package/src/apps/ui/image-cache.ts +0 -121
- package/src/apps/ui/palette.ts +0 -88
- package/src/apps/ui/project.ts +0 -127
- package/src/apps/ui/standalone.ts +0 -148
- package/src/apps/ui/status-model.ts +0 -68
- package/src/apps/ui/surface.ts +0 -91
- package/src/apps/ui/theme.ts +0 -86
- package/src/apps/update-check.ts +0 -112
- package/src/apps/workflow.ts +0 -58
- package/src/core/acceptance.ts +0 -139
- package/src/core/action-defaults.ts +0 -58
- package/src/core/activity-status.ts +0 -52
- package/src/core/agent-profile.ts +0 -130
- package/src/core/attachment-input.ts +0 -17
- package/src/core/blind-checks.ts +0 -72
- package/src/core/boundary.ts +0 -174
- package/src/core/branch-receipt.ts +0 -26
- package/src/core/catalog-merge.ts +0 -19
- package/src/core/checkpoints.ts +0 -85
- package/src/core/churn.ts +0 -61
- package/src/core/command-artifacts.ts +0 -94
- package/src/core/command-contract.ts +0 -151
- package/src/core/completion-action.ts +0 -20
- package/src/core/completion-review.ts +0 -98
- package/src/core/context-detail.ts +0 -37
- package/src/core/context-policy.ts +0 -133
- package/src/core/context-window.ts +0 -54
- package/src/core/continuation.ts +0 -130
- package/src/core/control-protocol.ts +0 -69
- package/src/core/control-view.ts +0 -39
- package/src/core/cycle.ts +0 -17
- package/src/core/data-query.ts +0 -201
- package/src/core/deadline.ts +0 -40
- package/src/core/derive.ts +0 -516
- package/src/core/draft-images.ts +0 -56
- package/src/core/edit-plan.ts +0 -200
- package/src/core/environment.ts +0 -181
- package/src/core/event-validation.ts +0 -220
- package/src/core/events.ts +0 -670
- package/src/core/evidence-ladder.ts +0 -62
- package/src/core/evidence-links.ts +0 -68
- package/src/core/evidence.ts +0 -240
- package/src/core/execution.ts +0 -18
- package/src/core/exhaustive.ts +0 -18
- package/src/core/export.ts +0 -179
- package/src/core/extension-flags.ts +0 -14
- package/src/core/external-effects.ts +0 -488
- package/src/core/failure-recovery.ts +0 -173
- package/src/core/file-precondition.ts +0 -78
- package/src/core/file-references.ts +0 -35
- package/src/core/frontmatter.ts +0 -213
- package/src/core/git-status.ts +0 -29
- package/src/core/goal-view.ts +0 -64
- package/src/core/goal.ts +0 -200
- package/src/core/handoff.ts +0 -479
- package/src/core/history-query.ts +0 -139
- package/src/core/history.ts +0 -237
- package/src/core/image-limits.ts +0 -72
- package/src/core/images.ts +0 -46
- package/src/core/integration-health.ts +0 -34
- package/src/core/json-event.ts +0 -131
- package/src/core/json-pointer.ts +0 -46
- package/src/core/keymap.ts +0 -153
- package/src/core/lsp-framing.ts +0 -34
- package/src/core/mcp-config.ts +0 -68
- package/src/core/model-picker.ts +0 -64
- package/src/core/model-preferences.ts +0 -28
- package/src/core/model-replay.ts +0 -22
- package/src/core/model-scope.ts +0 -53
- package/src/core/note-guidance.ts +0 -34
- package/src/core/oauth-fields.ts +0 -44
- package/src/core/observation-view.ts +0 -152
- package/src/core/outcome.ts +0 -44
- package/src/core/overflow.ts +0 -59
- package/src/core/packages.ts +0 -47
- package/src/core/patch.ts +0 -130
- package/src/core/path-suggestions.ts +0 -59
- package/src/core/permissions.ts +0 -410
- package/src/core/process-receipt.ts +0 -105
- package/src/core/process-state.ts +0 -37
- package/src/core/program-status.ts +0 -40
- package/src/core/progress.ts +0 -150
- package/src/core/project.ts +0 -431
- package/src/core/prompt-policy.ts +0 -46
- package/src/core/prompts.ts +0 -111
- package/src/core/question.ts +0 -94
- package/src/core/queue.ts +0 -78
- package/src/core/read-bounds.ts +0 -24
- package/src/core/read-observation.ts +0 -106
- package/src/core/recovery-sources.ts +0 -74
- package/src/core/regex-dialect.ts +0 -25
- package/src/core/request-budget.ts +0 -23
- package/src/core/request-progress.ts +0 -87
- package/src/core/request-state.ts +0 -273
- package/src/core/resource-selection.ts +0 -36
- package/src/core/response-recovery.ts +0 -59
- package/src/core/resume.ts +0 -59
- package/src/core/review.ts +0 -68
- package/src/core/ripgrep-release.ts +0 -64
- package/src/core/runtime-target.ts +0 -94
- package/src/core/scheduling.ts +0 -8
- package/src/core/search-observation.ts +0 -97
- package/src/core/secrets.ts +0 -188
- package/src/core/session-archive.ts +0 -4
- package/src/core/session-browser.ts +0 -82
- package/src/core/session-entry.ts +0 -61
- package/src/core/session-exit.ts +0 -42
- package/src/core/session-format.ts +0 -169
- package/src/core/session-position.ts +0 -26
- package/src/core/session-tree.ts +0 -147
- package/src/core/settings.ts +0 -192
- package/src/core/skills.ts +0 -165
- package/src/core/sql-query.ts +0 -211
- package/src/core/stall.ts +0 -128
- package/src/core/stop-report.ts +0 -68
- package/src/core/text-normalize.ts +0 -81
- package/src/core/theme.ts +0 -166
- package/src/core/thinking.ts +0 -26
- package/src/core/tokens.ts +0 -151
- package/src/core/tool-contract.ts +0 -153
- package/src/core/tool-discovery.ts +0 -87
- package/src/core/tool-failure.ts +0 -81
- package/src/core/tool-input.ts +0 -44
- package/src/core/transcript.ts +0 -108
- package/src/core/tui-mode.ts +0 -29
- package/src/core/update-check.ts +0 -157
- package/src/core/version.ts +0 -55
- package/src/core/web-content.ts +0 -80
- package/src/core/work-state.ts +0 -154
- package/src/core/workflow-mode.ts +0 -34
- package/src/core/working-set.ts +0 -119
- package/src/core/yaml.ts +0 -282
- package/src/runtime/acp-agent.ts +0 -289
- package/src/runtime/agent-profiles.ts +0 -21
- package/src/runtime/auth.ts +0 -367
- package/src/runtime/blind-check.ts +0 -36
- package/src/runtime/branch-jobs.ts +0 -36
- package/src/runtime/catalog.ts +0 -285
- package/src/runtime/clipboard.ts +0 -125
- package/src/runtime/command-artifacts.ts +0 -64
- package/src/runtime/connection-reuse.ts +0 -40
- package/src/runtime/control-client.ts +0 -149
- package/src/runtime/control-connection.ts +0 -139
- package/src/runtime/counter-check.ts +0 -74
- package/src/runtime/environment-tools.ts +0 -60
- package/src/runtime/evidence-notes.ts +0 -35
- package/src/runtime/exec-sessions.ts +0 -456
- package/src/runtime/execution-control.ts +0 -40
- package/src/runtime/extension-loader.ts +0 -37
- package/src/runtime/extension-ui.ts +0 -47
- package/src/runtime/extensions.ts +0 -370
- package/src/runtime/formatter-tool.ts +0 -38
- package/src/runtime/formatter.ts +0 -50
- package/src/runtime/foundation.ts +0 -116
- package/src/runtime/history-cache.ts +0 -57
- package/src/runtime/history-tools.ts +0 -168
- package/src/runtime/images.ts +0 -80
- package/src/runtime/integrations.ts +0 -216
- package/src/runtime/loop.ts +0 -3108
- package/src/runtime/lsp-client.ts +0 -201
- package/src/runtime/mcp-client.ts +0 -264
- package/src/runtime/mcp-oauth.ts +0 -168
- package/src/runtime/mcp-schema.ts +0 -51
- package/src/runtime/model-config.ts +0 -232
- package/src/runtime/model-data.LICENSE +0 -27
- package/src/runtime/model-data.ts +0 -21
- package/src/runtime/model-discovery.ts +0 -208
- package/src/runtime/model-replay.ts +0 -10
- package/src/runtime/model-scope.ts +0 -20
- package/src/runtime/model-sources.ts +0 -151
- package/src/runtime/model.ts +0 -686
- package/src/runtime/models-fetch.ts +0 -57
- package/src/runtime/oauth-device.ts +0 -86
- package/src/runtime/oauth.ts +0 -853
- package/src/runtime/open-image.ts +0 -42
- package/src/runtime/openai-responses.ts +0 -304
- package/src/runtime/openai.ts +0 -697
- package/src/runtime/output-schema.ts +0 -26
- package/src/runtime/package-manager.ts +0 -125
- package/src/runtime/patch-tool.ts +0 -38
- package/src/runtime/permission-resources.ts +0 -18
- package/src/runtime/pi-provider.ts +0 -423
- package/src/runtime/program-status.ts +0 -38
- package/src/runtime/progress.ts +0 -26
- package/src/runtime/provider-access.ts +0 -21
- package/src/runtime/provider-connect.ts +0 -109
- package/src/runtime/provider-presets.ts +0 -98
- package/src/runtime/provider-protocols.ts +0 -29
- package/src/runtime/provider-request.ts +0 -87
- package/src/runtime/provider-tool-policy.ts +0 -33
- package/src/runtime/provider.ts +0 -580
- package/src/runtime/question-tool.ts +0 -62
- package/src/runtime/retry.ts +0 -447
- package/src/runtime/review-tool.ts +0 -48
- package/src/runtime/rich-text.ts +0 -36
- package/src/runtime/runtime-target.ts +0 -149
- package/src/runtime/secrets.ts +0 -79
- package/src/runtime/self-update.ts +0 -74
- package/src/runtime/session-control.ts +0 -265
- package/src/runtime/session-hub.ts +0 -85
- package/src/runtime/stream-idle.ts +0 -80
- package/src/runtime/tool-broker.ts +0 -137
- package/src/runtime/tool-discovery.ts +0 -58
- package/src/runtime/tool-queue.ts +0 -77
- package/src/runtime/tools.ts +0 -1820
- package/src/runtime/turn-effects.ts +0 -31
- package/src/runtime/update-check.ts +0 -48
- package/src/runtime/usage.ts +0 -253
- package/src/runtime/version.ts +0 -12
- package/src/runtime/web-http.ts +0 -56
- package/src/runtime/web-tools.ts +0 -88
- package/src/runtime/working-notes.ts +0 -60
- package/src/runtime/workspace-lifecycle.ts +0 -141
- package/src/store/acceptance.ts +0 -15
- package/src/store/agent-profile-files.ts +0 -13
- package/src/store/atomic.ts +0 -31
- package/src/store/auth-store.ts +0 -50
- package/src/store/blind-checks.ts +0 -32
- package/src/store/blobs.ts +0 -143
- package/src/store/checkpoint-history.ts +0 -32
- package/src/store/command-artifacts.ts +0 -96
- package/src/store/command.ts +0 -111
- package/src/store/config.ts +0 -69
- package/src/store/context-files.ts +0 -436
- package/src/store/file-checkpoints.ts +0 -108
- package/src/store/file-index.ts +0 -174
- package/src/store/foundation-config.ts +0 -24
- package/src/store/git-checkpoints.ts +0 -67
- package/src/store/git-status.ts +0 -15
- package/src/store/history-search.ts +0 -19
- package/src/store/log.ts +0 -725
- package/src/store/lsp-document.ts +0 -30
- package/src/store/mcp-credentials.ts +0 -85
- package/src/store/models.ts +0 -60
- package/src/store/observations.ts +0 -224
- package/src/store/packages.ts +0 -98
- package/src/store/patch.ts +0 -92
- package/src/store/permission-paths.ts +0 -34
- package/src/store/process-receipts.ts +0 -34
- package/src/store/query-source.ts +0 -49
- package/src/store/recovery.ts +0 -44
- package/src/store/replay.ts +0 -76
- package/src/store/resource-index.ts +0 -170
- package/src/store/resources.ts +0 -285
- package/src/store/restore.ts +0 -110
- package/src/store/review.ts +0 -109
- package/src/store/runtime-target.ts +0 -64
- package/src/store/search.ts +0 -728
- package/src/store/secrets.ts +0 -44
- package/src/store/serial.ts +0 -13
- package/src/store/session-archive.ts +0 -165
- package/src/store/session-browser.ts +0 -68
- package/src/store/session-position.ts +0 -27
- package/src/store/sessions.ts +0 -525
- package/src/store/source-cancellation.ts +0 -6
- package/src/store/tool-binaries.ts +0 -90
- package/src/store/trust.ts +0 -82
- package/src/store/update-cache.ts +0 -36
- package/src/store/user-settings.ts +0 -30
- package/src/store/workspace-clone.ts +0 -42
- package/src/store/workspace-observation.ts +0 -113
- package/src/store/workspace.ts +0 -115
- package/src/store/worktree.ts +0 -278
- package/src/tui/ansi-spans.ts +0 -126
- package/src/tui/diff.ts +0 -160
- package/src/tui/exhaustive.ts +0 -14
- package/src/tui/fuzzy.ts +0 -75
- package/src/tui/style.ts +0 -208
- package/src/tui/ui/atomic-draft.ts +0 -121
- package/src/tui/ui/branch-panel.ts +0 -47
- package/src/tui/ui/canvas.ts +0 -96
- package/src/tui/ui/colors.ts +0 -70
- package/src/tui/ui/composer.ts +0 -199
- package/src/tui/ui/control-panel.ts +0 -25
- package/src/tui/ui/control-sidebar.ts +0 -49
- package/src/tui/ui/dialog.ts +0 -398
- package/src/tui/ui/draft.ts +0 -23
- package/src/tui/ui/duration.ts +0 -16
- package/src/tui/ui/edge.ts +0 -186
- package/src/tui/ui/image-size.ts +0 -44
- package/src/tui/ui/ink.ts +0 -210
- package/src/tui/ui/items.ts +0 -42
- package/src/tui/ui/keys.ts +0 -40
- package/src/tui/ui/layout.ts +0 -74
- package/src/tui/ui/list-model.ts +0 -86
- package/src/tui/ui/list-view.ts +0 -172
- package/src/tui/ui/mascot.ts +0 -67
- package/src/tui/ui/messages.ts +0 -518
- package/src/tui/ui/motion.ts +0 -28
- package/src/tui/ui/paths.ts +0 -10
- package/src/tui/ui/pictures.ts +0 -108
- package/src/tui/ui/prompt-history.ts +0 -60
- package/src/tui/ui/screen.ts +0 -579
- package/src/tui/ui/status.ts +0 -403
- package/src/tui/ui/styled.ts +0 -85
- package/src/tui/ui/tool-card.ts +0 -150
- package/src/tui/width.ts +0 -567
- package/src/vendor/pi-rich/LICENSE.upstream +0 -21
- package/src/vendor/pi-rich/UPSTREAM.json +0 -17
- package/src/vendor/pi-rich/clipboard-command.ts +0 -44
- package/src/vendor/pi-rich/exif-orientation.ts +0 -182
- package/src/vendor/pi-rich/html.ts +0 -51
- package/src/vendor/pi-rich/image-convert.ts +0 -49
- package/src/vendor/pi-rich/image-resize-core.ts +0 -164
- package/src/vendor/pi-rich/image-resize-worker.ts +0 -42
- package/src/vendor/pi-rich/image-resize.ts +0 -123
- package/src/vendor/pi-rich/mime.ts +0 -116
- package/src/vendor/pi-rich/photon.ts +0 -139
- package/src/vendor/pi-rich/syntax-highlight.ts +0 -214
package/src/apps/shell.ts
DELETED
|
@@ -1,2071 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Interactive loop and one-shot runner.
|
|
3
|
-
*
|
|
4
|
-
* The live UI is OpenTUI (D20), driven through the same business hooks as
|
|
5
|
-
* line mode. A pipe or CI run never constructs it: `-p` output is parsed
|
|
6
|
-
* by scripts, while the interactive app projects the durable transcript.
|
|
7
|
-
*/
|
|
8
|
-
|
|
9
|
-
import { criterionSummary, draftGoal, pickCriteria, type PendingGoal } from "./goal-flow.ts";
|
|
10
|
-
import { openImageFile } from "../runtime/open-image.ts";
|
|
11
|
-
import { closeAppSession, switchAppSession } from "./session-lifecycle.ts";
|
|
12
|
-
import { basename, dirname, join, resolve } from "node:path";
|
|
13
|
-
import { tmpdir } from "node:os";
|
|
14
|
-
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
|
15
|
-
import { assertNever } from "../core/exhaustive.ts";
|
|
16
|
-
import type { CounterVerdict } from "../core/acceptance.ts";
|
|
17
|
-
import type { LogEvent } from "../core/events.ts";
|
|
18
|
-
import type { Skill } from "../core/skills.ts";
|
|
19
|
-
import { workReadiness } from "../core/work-state.ts";
|
|
20
|
-
import { userInputData, type ImageRef, type UserInput } from "../core/images.ts";
|
|
21
|
-
import { attachImageFile } from "../runtime/images.ts";
|
|
22
|
-
import { writeClipboard } from "../runtime/clipboard.ts";
|
|
23
|
-
import type { ExtensionContext, ExtensionRegistry } from "../runtime/extensions.ts";
|
|
24
|
-
import { headlessUI } from "../runtime/extension-ui.ts";
|
|
25
|
-
import { fileReferences } from "../core/file-references.ts";
|
|
26
|
-
import { readNodeHistory } from "../store/restore.ts";
|
|
27
|
-
import type { Handoff } from "../core/handoff.ts";
|
|
28
|
-
import { projectContext, type FrozenPrefix } from "../core/project.ts";
|
|
29
|
-
import { contextDetail } from "../core/context-detail.ts";
|
|
30
|
-
import type { BoundaryConfig } from "../core/boundary.ts";
|
|
31
|
-
import {
|
|
32
|
-
contextLimits, decideContext, DEFAULT_CONTEXT_POLICY, gaugeLevel, nextBoundary,
|
|
33
|
-
type ContextDecision, type ContextLimits, type ContextPolicy, type GaugeLevel,
|
|
34
|
-
} from "../core/context-policy.ts";
|
|
35
|
-
import { anchoredCount, estimateTokens, type Anchor, type TokenCounter } from "../core/tokens.ts";
|
|
36
|
-
import type { RestorePoint, RewindMode, RequestOptions } from "../runtime/loop.ts";
|
|
37
|
-
import { createProvider, providerSessionId, findModelByPattern, groupedModels, loadCatalog } from "../runtime/catalog.ts";
|
|
38
|
-
import { outputTokenBudget, type Effort, type ModelSpec } from "../runtime/model.ts";
|
|
39
|
-
import { scopedModels } from "../runtime/model-scope.ts";
|
|
40
|
-
import { equivalentCost, reportedCost } from "../runtime/usage.ts";
|
|
41
|
-
import type { ProviderConfig } from "../runtime/provider.ts";
|
|
42
|
-
import { describeMissingCredential, envLabel, ensureFreshCredential, storedIsOAuth } from "../runtime/auth.ts";
|
|
43
|
-
import {
|
|
44
|
-
SUBSCRIPTION_NOTICE, SUBSCRIPTION_OPTIONS, subscriptionById, loginGrok,
|
|
45
|
-
loginGitHubCopilot, generatePKCE, generateState, anthropicAuthorizeUrl, openaiCodexAuthorizeUrl,
|
|
46
|
-
startCallbackServer, exchangeAnthropicCode, exchangeOpenAICode, parseAuthorizationInput,
|
|
47
|
-
ANTHROPIC_CALLBACK_PORT, ANTHROPIC_CALLBACK_PATH, ANTHROPIC_REDIRECT_URI,
|
|
48
|
-
OPENAI_CALLBACK_PORT, OPENAI_CALLBACK_PATH, OPENAI_REDIRECT_URI,
|
|
49
|
-
type CallbackServer, type OAuthCredential, type SubscriptionId,
|
|
50
|
-
} from "../runtime/oauth.ts";
|
|
51
|
-
import { writeAuthFile } from "../store/auth-store.ts";
|
|
52
|
-
import { authPath, configDir } from "../store/config.ts";
|
|
53
|
-
import { catalogCachePath, readModelSources, writeCatalogCache } from "../store/models.ts";
|
|
54
|
-
import { buildFileIndex, indexTruncationNote, type FileIndex } from "../store/file-index.ts";
|
|
55
|
-
import { projectTrust, trustPath } from "../store/trust.ts";
|
|
56
|
-
import { discoverResources, themeResourcePath } from "../store/resource-index.ts";
|
|
57
|
-
import { applyTrustChoice, type TrustChoice } from "./project-trust.ts";
|
|
58
|
-
import { reloadAppResources } from "./resources.ts";
|
|
59
|
-
import { publishModelCatalog } from "./model-catalog.ts";
|
|
60
|
-
import { readCursor, writeCursor, listNodes, readNode, readManifest, setSessionLabel } from "../store/log.ts";
|
|
61
|
-
import { registerSessionRoot } from "../store/session-browser.ts";
|
|
62
|
-
import { createSession, findSessionByPrefix, hasConversation, listSessions, truncationNote, sessionsDir } from "../store/sessions.ts";
|
|
63
|
-
import { exportSessionArchive, importSessionArchive } from "../store/session-archive.ts";
|
|
64
|
-
import { resolveTuiMode } from "../core/tui-mode.ts";
|
|
65
|
-
import { modelPreferencePatch, thinkingPreferencePatch } from "../core/model-preferences.ts";
|
|
66
|
-
import { flattenTree, treeFromPoints, LEFT_BRANCH_MARK, EMPTY_TREE_MESSAGE, type TreeNode, type NodeParent, type TreePoint } from "../core/session-tree.ts";
|
|
67
|
-
import { cycle } from "../core/cycle.ts";
|
|
68
|
-
import { THINKING_LEVELS, asThinkingLevel as asEffort, supportedThinkingLevels, clampThinkingLevel } from "../core/thinking.ts";
|
|
69
|
-
import { modelKey } from "../core/session-position.ts";
|
|
70
|
-
import { defaultKeymap, listBindings } from "../core/keymap.ts";
|
|
71
|
-
import { settingPatch } from "../core/settings.ts";
|
|
72
|
-
import { resolveTheme } from "../store/resources.ts";
|
|
73
|
-
import { commitUserSettings, type PreferenceOrigin } from "./preferences.ts";
|
|
74
|
-
import { colorEnabled } from "./render.ts";
|
|
75
|
-
import { createStyle, IDENTITY } from "../tui/style.ts";
|
|
76
|
-
import { lastAssistantText, toHtml } from "../core/export.ts";
|
|
77
|
-
import { toJsonEvent } from "../core/json-event.ts";
|
|
78
|
-
import { sessionControl } from "../runtime/session-control.ts";
|
|
79
|
-
import { hostExternalOptions, requestExternalEffects } from "../runtime/turn-effects.ts";
|
|
80
|
-
import { formatExternalEffects } from "../core/external-effects.ts";
|
|
81
|
-
import { COMMANDS, parseCommand, type Command } from "./commands.ts";
|
|
82
|
-
import { expandInput } from "./input.ts";
|
|
83
|
-
import {
|
|
84
|
-
describeError, firstLine, formatEvent, indent, stdinIsTty, stdoutIsTty, wrap,
|
|
85
|
-
} from "./render.ts";
|
|
86
|
-
import {
|
|
87
|
-
openSessionFor, sessionDirFor, retryHandler, whyNot, splitModelPatterns, runModelsRefresh, sessionToolCatalog, type App,
|
|
88
|
-
} from "./startup.ts";
|
|
89
|
-
import type { TurnSurface, Ui } from "./ui/surface.ts";
|
|
90
|
-
import { runManualUpdate } from "./update-check.ts";
|
|
91
|
-
import { presentQuestions } from "./question-ui.ts";
|
|
92
|
-
import { bindAppWorkflow, setAppWorkflow } from "./workflow.ts";
|
|
93
|
-
import { appReviewSnapshot, startReview, reviewScopeFromText } from "./review.ts";
|
|
94
|
-
import { selectAppAgent, listAppAgents } from "./agent-profiles.ts";
|
|
95
|
-
|
|
96
|
-
/**
|
|
97
|
-
* One line. Listing all ~40 commands here wrapped into a two-line wall that
|
|
98
|
-
* nobody reads; typing `/` already opens the searchable command menu.
|
|
99
|
-
*/
|
|
100
|
-
const HINT = `type a prompt · / for ${COMMANDS.length} commands · /provider connects a provider · /model switches · /help`;
|
|
101
|
-
const OAUTH_PASTE = "\0oauth";
|
|
102
|
-
|
|
103
|
-
const initializedExtensions = new WeakSet<ExtensionRegistry>();
|
|
104
|
-
const extensionHistories = new WeakMap<App, LogEvent[]>();
|
|
105
|
-
function extensionContext(app: App, tui?: Ui): ExtensionContext {
|
|
106
|
-
const entries = () => structuredClone([...(extensionHistories.get(app) ?? []).filter(event => !app.session.state.events.some(current => current.id === event.id)), ...app.session.state.events]);
|
|
107
|
-
return {
|
|
108
|
-
ui: tui?.extensionUi ?? headlessUI((text, type) => app.io.say(type === "error" ? app.io.palette.red(text) : text)),
|
|
109
|
-
cwd: app.workspace, mode: tui ? "tui" : app.args.mode === "json" ? "json" : "print", hasUI: !!tui,
|
|
110
|
-
model: app.providerConfig.spec, thinkingLevel: app.providerConfig.effort, signal: tui?.signal,
|
|
111
|
-
isIdle: () => !app.session.running, isProjectTrusted: () => app.projectTrusted === true,
|
|
112
|
-
hasPendingMessages: () => (tui?.queueLength ?? 0) > 0,
|
|
113
|
-
abort: () => { tui?.interrupt(); app.session.cancel(); }, shutdown: () => { app.shutdownRequested = true; app.session.cancel(); tui?.requestShutdown(); },
|
|
114
|
-
getSystemPrompt: () => app.session.config.frozen.system,
|
|
115
|
-
getContextUsage: () => ({ tokens: app.session.state.anchor?.promptTokens ?? null, contextWindow: app.providerConfig.spec.contextWindow }),
|
|
116
|
-
waitForIdle: () => app.session.whenIdle(),
|
|
117
|
-
reload: async () => { if (app.session.running) throw new Error("Reload requires an idle session"); await reloadAppResources(app); await tui?.refreshResources(); },
|
|
118
|
-
newSession: async () => { await openNewSession(app, tui); await bindExtensions(app, tui); return { cancelled: false }; },
|
|
119
|
-
switchSession: async (path: string) => { const changed = await adoptLogDir(app, path); if (changed) { await bindExtensions(app, tui); await tui?.refreshResources(); } return { cancelled: !changed }; },
|
|
120
|
-
fork: async (id: string) => { const changed = await spawnForkedSession(app, id, "forked by extension", "before"); if (changed) await bindExtensions(app, tui); return { cancelled: !changed }; },
|
|
121
|
-
sessionManager: { getEntries: entries, getBranch: entries, getSessionId: () => basename(app.logDir), getSessionFile: () => app.logDir, getCwd: () => app.workspace },
|
|
122
|
-
modelRegistry: { getAll: () => [...app.catalog], getAvailable: () => app.catalog.filter(model => app.credentialOf(model).ok), find: (provider: string, id: string) => app.catalog.find(model => model.provider === provider && model.id === id) },
|
|
123
|
-
};
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
async function bindExtensions(app: App, tui?: Ui): Promise<void> {
|
|
127
|
-
bindAppWorkflow(app);
|
|
128
|
-
if (tui) {
|
|
129
|
-
const control = sessionControl(app.session);
|
|
130
|
-
control.questionSlot.current = (request, signal) => control.askQuestion(request, signal, current => presentQuestions(tui.extensionUi, current, signal));
|
|
131
|
-
}
|
|
132
|
-
tui?.bindExtensionRegistry();
|
|
133
|
-
const registry = app.extensions; if (!registry) return;
|
|
134
|
-
const history = await readNodeHistory(app.logDir, app.session.state.node);
|
|
135
|
-
if (history.ok) extensionHistories.set(app, history.nodes.flatMap(node => node.events));
|
|
136
|
-
registry.bind({
|
|
137
|
-
context: () => extensionContext(app, tui),
|
|
138
|
-
append: async (entry, options) => {
|
|
139
|
-
if (entry.inContext && app.session.running) await app.session.whenIdle();
|
|
140
|
-
await app.session.appendExtension(entry); await tui?.syncTranscript();
|
|
141
|
-
if (options?.triggerTurn) { if (tui) tui.deliverExtension("Continue.", "followup"); else await run(app, "Continue."); }
|
|
142
|
-
},
|
|
143
|
-
user: async (text, options) => { if (tui) tui.deliverExtension(text, options?.deliverAs === "steer" ? "steering" : "followup"); else await run(app, text); },
|
|
144
|
-
setName: name => nameSession(app, name, tui),
|
|
145
|
-
setLabel: async (id, label) => { await setSessionLabel(app.logDir, id, label ?? "", app.session.clock.now()); },
|
|
146
|
-
getTools: () => app.session.config.frozen.tools.flatMap(tool => tool !== null && typeof tool === "object" &&
|
|
147
|
-
"name" in tool && typeof tool.name === "string" && "description" in tool && typeof tool.description === "string" && "input_schema" in tool
|
|
148
|
-
? [{ name: tool.name, description: tool.description, parameters: JSON.parse(JSON.stringify(tool.input_schema)) as unknown }] : []),
|
|
149
|
-
getAllTools: () => {
|
|
150
|
-
const { schemas, deferred } = sessionToolCatalog(app.args, registry, app.session.config.frozen.tools);
|
|
151
|
-
return [...new Map([...schemas, ...deferred].map(tool => [tool.name, tool])).values()]
|
|
152
|
-
.map(tool => ({ name: tool.name, description: tool.description, parameters: tool.input_schema }));
|
|
153
|
-
},
|
|
154
|
-
setTools: async names => {
|
|
155
|
-
if (app.session.running) throw new Error("Active tools can only change at an idle checkpoint");
|
|
156
|
-
const args = { ...app.args, tools: names, noTools: false };
|
|
157
|
-
const { schemas: tools, deferred } = sessionToolCatalog(args, registry);
|
|
158
|
-
if (names.some(name => !tools.some(tool => tool.name === name))) throw new Error("Unknown active tool");
|
|
159
|
-
await app.session.reloadResources({ ...app.session.config.frozen, tools });
|
|
160
|
-
app.session.config.tools.deferredTools = deferred; app.args = args;
|
|
161
|
-
},
|
|
162
|
-
setModel: async model => {
|
|
163
|
-
if (typeof model !== "object" || !model || !("provider" in model) || !("id" in model)) return false;
|
|
164
|
-
await switchByPattern(app, String(model.provider) + "/" + String(model.id), "session"); return app.providerConfig.spec.id === model.id && app.providerConfig.spec.provider === model.provider;
|
|
165
|
-
},
|
|
166
|
-
setThinking: async level => { await applyThinkingCommand(app, level, tui ?? null, "session"); },
|
|
167
|
-
exec: (command, args, options) => app.session.runExtensionCommand(command, args, options),
|
|
168
|
-
});
|
|
169
|
-
if (!initializedExtensions.has(registry)) {
|
|
170
|
-
initializedExtensions.add(registry);
|
|
171
|
-
const start = async () => { await registry.emit("session_start"); await registry.settle(); };
|
|
172
|
-
if (tui) tui.runExtensionAction(start); else await start();
|
|
173
|
-
}
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
function parseLine(app: App, text: string): Command {
|
|
177
|
-
const match = /^\/([^\s]+)(?:\s+([\s\S]*))?$/.exec(text.trim());
|
|
178
|
-
if (match && app.extensions?.commands.has(match[1]!)) return { kind: "extension", name: match[1]!, args: match[2] ?? "" };
|
|
179
|
-
return parseCommand(text, app.slash);
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
/**
|
|
183
|
-
* Inputs maybeCut reads when it decides whether to cut. The gauge must use
|
|
184
|
-
* this same set -- a second estimate would disagree with the actual cut. Policy,
|
|
185
|
-
* window and reserve have no defaults: a default is how the footer came to
|
|
186
|
-
* measure against a boundary the active policy never consults.
|
|
187
|
-
*/
|
|
188
|
-
export interface BudgetInput {
|
|
189
|
-
readonly logHint?: { manifestPath: string; segmentPath: string };
|
|
190
|
-
readonly frozen: FrozenPrefix;
|
|
191
|
-
readonly handoff: Handoff | null;
|
|
192
|
-
readonly events: readonly LogEvent[];
|
|
193
|
-
readonly anchor: Anchor | null;
|
|
194
|
-
readonly counter?: TokenCounter;
|
|
195
|
-
readonly boundary?: Partial<BoundaryConfig>;
|
|
196
|
-
readonly policy: ContextPolicy;
|
|
197
|
-
readonly contextWindow: number | undefined;
|
|
198
|
-
readonly outputReserve: number;
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
export interface ContextBudget {
|
|
202
|
-
readonly used: number;
|
|
203
|
-
/** Nothing the provider reported backs the count: before the first response, after a cut. */
|
|
204
|
-
readonly estimated: boolean;
|
|
205
|
-
/** Where the active policy next acts; null when it names no boundary. */
|
|
206
|
-
readonly trigger: number | null;
|
|
207
|
-
readonly inherited: number;
|
|
208
|
-
readonly limits: ContextLimits;
|
|
209
|
-
readonly decision: ContextDecision;
|
|
210
|
-
readonly level: GaugeLevel;
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
/**
|
|
214
|
-
* The numbers the status gauge shows, computed the same way maybeCut does:
|
|
215
|
-
* anchoredCount for the current projection, inheritedTokens from the empty
|
|
216
|
-
* node, contextLimits and decideContext for what the next turn boundary does.
|
|
217
|
-
* The TUI must not recompute the work floor.
|
|
218
|
-
*/
|
|
219
|
-
export function contextBudget(input: BudgetInput): ContextBudget {
|
|
220
|
-
const counter = input.counter ?? estimateTokens;
|
|
221
|
-
const projected = projectContext({
|
|
222
|
-
frozen: input.frozen,
|
|
223
|
-
handoff: input.handoff,
|
|
224
|
-
events: input.events,
|
|
225
|
-
...(input.logHint ? { logHint: input.logHint } : {}),
|
|
226
|
-
});
|
|
227
|
-
const { tokens, exactUpTo } = anchoredCount(
|
|
228
|
-
projected.messages,
|
|
229
|
-
projected.system,
|
|
230
|
-
input.anchor,
|
|
231
|
-
counter,
|
|
232
|
-
input.frozen.tools,
|
|
233
|
-
);
|
|
234
|
-
const floor = projectContext({
|
|
235
|
-
frozen: input.frozen,
|
|
236
|
-
handoff: input.handoff,
|
|
237
|
-
events: [],
|
|
238
|
-
...(input.logHint ? { logHint: input.logHint } : {}),
|
|
239
|
-
});
|
|
240
|
-
const inherited = counter.count(floor.messages, floor.system, input.frozen.tools);
|
|
241
|
-
const limits = contextLimits({
|
|
242
|
-
policy: input.policy,
|
|
243
|
-
contextWindow: input.contextWindow,
|
|
244
|
-
outputReserve: input.outputReserve,
|
|
245
|
-
...(input.boundary ? { boundary: input.boundary } : {}),
|
|
246
|
-
});
|
|
247
|
-
// The footer asks what the next turn boundary does, even mid-turn. Readiness
|
|
248
|
-
// and price are not known here, so adaptive reads as deferrable at its soft
|
|
249
|
-
// trigger and certain only at the hard limit.
|
|
250
|
-
const decision = decideContext({
|
|
251
|
-
policy: input.policy,
|
|
252
|
-
continuationReady: false,
|
|
253
|
-
cost: null,
|
|
254
|
-
tokens,
|
|
255
|
-
inheritedTokens: inherited,
|
|
256
|
-
atTurnBoundary: true,
|
|
257
|
-
...(input.contextWindow === undefined ? {} : { contextWindow: input.contextWindow }),
|
|
258
|
-
outputReserve: input.outputReserve,
|
|
259
|
-
...(input.boundary ? { config: input.boundary } : {}),
|
|
260
|
-
});
|
|
261
|
-
return {
|
|
262
|
-
used: tokens,
|
|
263
|
-
estimated: exactUpTo === 0,
|
|
264
|
-
trigger: nextBoundary(limits),
|
|
265
|
-
inherited,
|
|
266
|
-
limits,
|
|
267
|
-
decision,
|
|
268
|
-
level: gaugeLevel(tokens, limits, decision),
|
|
269
|
-
};
|
|
270
|
-
}
|
|
271
|
-
|
|
272
|
-
export function resolveEditorCommand(
|
|
273
|
-
env: { readonly [key: string]: string | undefined } = process.env,
|
|
274
|
-
): string {
|
|
275
|
-
const visual = env["VISUAL"]?.trim();
|
|
276
|
-
if (visual !== undefined && visual.length > 0) return visual;
|
|
277
|
-
const editor = env["EDITOR"]?.trim();
|
|
278
|
-
if (editor !== undefined && editor.length > 0) return editor;
|
|
279
|
-
return "nano";
|
|
280
|
-
}
|
|
281
|
-
|
|
282
|
-
export type ExternalEditorResult =
|
|
283
|
-
| { ok: true; content: string }
|
|
284
|
-
| { ok: false; message: string };
|
|
285
|
-
|
|
286
|
-
/**
|
|
287
|
-
* The only I/O in the editor-completion card. applyKey stays pure; this
|
|
288
|
-
* writes a temp file, runs $VISUAL/$EDITOR/nano, and deletes the file
|
|
289
|
-
* whether the editor saved or :cq'd. A non-zero exit is a discard — vim's
|
|
290
|
-
* :cq means "I do not want this" and stuffing the half-written file back
|
|
291
|
-
* would turn a cancel into an edit.
|
|
292
|
-
*/
|
|
293
|
-
export async function editInExternalEditor(input: {
|
|
294
|
-
readonly content: string;
|
|
295
|
-
readonly command: string;
|
|
296
|
-
}): Promise<ExternalEditorResult> {
|
|
297
|
-
const dir = await mkdtemp(join(tmpdir(), "xeno-editor-"));
|
|
298
|
-
const filePath = join(dir, "prompt.md");
|
|
299
|
-
try {
|
|
300
|
-
await writeFile(filePath, input.content, "utf8");
|
|
301
|
-
const parts = input.command.trim().split(/\s+/).filter((p) => p.length > 0);
|
|
302
|
-
const bin = parts[0];
|
|
303
|
-
if (bin === undefined) {
|
|
304
|
-
return { ok: false, message: "external editor: no command; discarding changes" };
|
|
305
|
-
}
|
|
306
|
-
const proc = Bun.spawn([...parts, filePath], {
|
|
307
|
-
stdin: "inherit",
|
|
308
|
-
stdout: "inherit",
|
|
309
|
-
stderr: "inherit",
|
|
310
|
-
});
|
|
311
|
-
const code = await proc.exited;
|
|
312
|
-
if (code !== 0) {
|
|
313
|
-
return { ok: false, message: `external editor exited ${code}; discarding changes` };
|
|
314
|
-
}
|
|
315
|
-
const content = await readFile(filePath, "utf8");
|
|
316
|
-
return { ok: true, content };
|
|
317
|
-
} catch (err) {
|
|
318
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
319
|
-
return { ok: false, message: `external editor failed (${msg}); discarding changes` };
|
|
320
|
-
} finally {
|
|
321
|
-
await rm(dir, { recursive: true, force: true });
|
|
322
|
-
}
|
|
323
|
-
}
|
|
324
|
-
|
|
325
|
-
/** Scan once per workspace: a glob on every @ is the stall the card rejected. */
|
|
326
|
-
const fileIndexes = new Map<string, FileIndex>();
|
|
327
|
-
const indexNotes = new Set<string>();
|
|
328
|
-
|
|
329
|
-
async function fileIndexFor(app: App, refresh = false): Promise<FileIndex> {
|
|
330
|
-
const hit = fileIndexes.get(app.workspace);
|
|
331
|
-
if (!refresh && hit !== undefined) return hit;
|
|
332
|
-
const built = await buildFileIndex(app.workspace);
|
|
333
|
-
fileIndexes.set(app.workspace, built);
|
|
334
|
-
const note = indexTruncationNote(built);
|
|
335
|
-
if (note !== null && !indexNotes.has(app.workspace)) {
|
|
336
|
-
indexNotes.add(app.workspace);
|
|
337
|
-
app.io.say(app.io.palette.yellow(` ${note}`));
|
|
338
|
-
}
|
|
339
|
-
return built;
|
|
340
|
-
}
|
|
341
|
-
|
|
342
|
-
export async function runPrompt(app: App, prompt: string): Promise<void> {
|
|
343
|
-
await bindExtensions(app);
|
|
344
|
-
if (app.shutdownRequested) { await closeAppSession(app); return; }
|
|
345
|
-
let ok: boolean;
|
|
346
|
-
try { ok = await run(app, prompt); }
|
|
347
|
-
finally { await closeAppSession(app); }
|
|
348
|
-
printUsage(app);
|
|
349
|
-
// One-shot mode is scriptable, so a failed turn has to be a failed exit.
|
|
350
|
-
if (!ok) process.exit(1);
|
|
351
|
-
}
|
|
352
|
-
|
|
353
|
-
export async function runRepl(app: App, releaseEarlyTty?: () => void): Promise<void> {
|
|
354
|
-
if (stdinIsTty() && stdoutIsTty()) {
|
|
355
|
-
await runTui(app, releaseEarlyTty);
|
|
356
|
-
} else {
|
|
357
|
-
await runLines(app);
|
|
358
|
-
}
|
|
359
|
-
}
|
|
360
|
-
|
|
361
|
-
async function runLines(app: App): Promise<void> {
|
|
362
|
-
await bindExtensions(app);
|
|
363
|
-
const C = app.io.palette;
|
|
364
|
-
if (app.ui?.settings.quietStartup !== true || app.args.verbose) app.io.say(C.dim(HINT));
|
|
365
|
-
/** Set by `/provider <provider>`; the next line typed is the key, not a prompt. */
|
|
366
|
-
let awaitingKeyFor: string | null = null;
|
|
367
|
-
for await (const line of console) {
|
|
368
|
-
const text = line.trim();
|
|
369
|
-
if (awaitingKeyFor !== null) {
|
|
370
|
-
const provider = awaitingKeyFor;
|
|
371
|
-
awaitingKeyFor = null;
|
|
372
|
-
if (text.length === 0) {
|
|
373
|
-
if (provider === OAUTH_PASTE) await cancelPendingOAuth(app);
|
|
374
|
-
app.io.say(C.dim(" cancelled\n"));
|
|
375
|
-
continue;
|
|
376
|
-
}
|
|
377
|
-
if (provider === OAUTH_PASTE) {
|
|
378
|
-
if (!pendingLogins.has(app) || pendingLogins.get(app)?.done) {
|
|
379
|
-
const outcome = await execute(app, parseLine(app, text), null);
|
|
380
|
-
if (outcome === "quit") break;
|
|
381
|
-
if (typeof outcome === "object") awaitingKeyFor = outcome.login;
|
|
382
|
-
continue;
|
|
383
|
-
}
|
|
384
|
-
await completeOAuthPaste(app, text);
|
|
385
|
-
continue;
|
|
386
|
-
}
|
|
387
|
-
await login(app, provider, text);
|
|
388
|
-
continue;
|
|
389
|
-
}
|
|
390
|
-
if (!text) continue;
|
|
391
|
-
const outcome = await execute(app, parseLine(app, text), null);
|
|
392
|
-
if (outcome === "quit" || app.shutdownRequested) break;
|
|
393
|
-
if (typeof outcome === "object") awaitingKeyFor = outcome.login;
|
|
394
|
-
}
|
|
395
|
-
await cancelPendingOAuth(app);
|
|
396
|
-
await closeAppSession(app);
|
|
397
|
-
printUsage(app);
|
|
398
|
-
}
|
|
399
|
-
|
|
400
|
-
async function runTui(app: App, releaseEarlyTty?: () => void): Promise<void> {
|
|
401
|
-
if (!app.args.noSession) await registerSessionRoot({ path: dirname(app.logDir), workspace: app.workspace }).catch(error => app.io.say(String(error)));
|
|
402
|
-
// Process start is the one place `auto` meets the real terminal (core/tui-mode.ts).
|
|
403
|
-
if (app.ui) app.ui.settings = { ...app.ui.settings, tuiMode: resolveTuiMode({ flag: app.args.tuiMode, setting: app.ui.settings.tuiMode, env: process.env }).mode };
|
|
404
|
-
const { runInteractive } = await import("./ui/app.ts");
|
|
405
|
-
await runInteractive(app, interactiveHooks, releaseEarlyTty);
|
|
406
|
-
printUsage(app);
|
|
407
|
-
}
|
|
408
|
-
|
|
409
|
-
/**
|
|
410
|
-
* The business operations the interactive controller drives. Exported as one
|
|
411
|
-
* object so apps/ui/app.ts depends on shell.ts in one direction only and the
|
|
412
|
-
* set of things the UI can do is visible in one place.
|
|
413
|
-
*/
|
|
414
|
-
export const interactiveHooks = {
|
|
415
|
-
handleLine: (app: App, text: string, ui: Ui) => handleLine(app, text, ui),
|
|
416
|
-
run: (app: App, prompt: string, signal: AbortSignal | undefined, ui: Ui | undefined, images: readonly ImageRef[] = [], skill?: Skill, options?: RequestOptions) =>
|
|
417
|
-
run(app, prompt, signal, ui, images, skill, options),
|
|
418
|
-
parseLine: (app: App, text: string) => parseLine(app, text),
|
|
419
|
-
bindExtensions: (app: App, ui?: Ui) => bindExtensions(app, ui),
|
|
420
|
-
extensionContext: (app: App, ui?: Ui) => extensionContext(app, ui),
|
|
421
|
-
cancelPendingOAuth: (app: App) => cancelPendingOAuth(app),
|
|
422
|
-
switchToModel: (app: App, spec: ModelSpec) => switchToModel(app, spec),
|
|
423
|
-
applyThinking: (app: App, effort: Effort, ui: Ui | null) => applyThinking(app, effort, ui),
|
|
424
|
-
cycleThinking: (app: App, ui: Ui | null) => cycleThinking(app, ui),
|
|
425
|
-
cycleScopedModel: (app: App, direction: 1 | -1) => cycleScopedModel(app, direction),
|
|
426
|
-
commitSetting: (app: App, field: string, value: string, ui: Ui | null) => commitSetting(app, field, value, ui),
|
|
427
|
-
startSubscriptionLogin: (app: App, id: string, ui: Ui | null) => startSubscriptionLogin(app, id, ui),
|
|
428
|
-
login: (app: App, provider: string, key: string) => login(app, provider, key),
|
|
429
|
-
logout: (app: App, provider: string) => logout(app, provider),
|
|
430
|
-
resumeSession: (app: App, path: string, ui: Ui) => resumeSession(app, path, ui),
|
|
431
|
-
openNewSession: (app: App, ui: Ui) => openNewSession(app, ui),
|
|
432
|
-
forkSession: (app: App, arg: string, ui: Ui) => forkSession(app, arg, ui),
|
|
433
|
-
trustProject: (app: App, choice: TrustChoice, ui: Ui) => trustProject(app, choice, ui),
|
|
434
|
-
treeNodesFor: (app: App) => treeNodesFor(app),
|
|
435
|
-
copyLastReply: (app: App) => copyLastReply(app),
|
|
436
|
-
effortSupported: (spec: ModelSpec, level: Effort) => effortSupported(spec, level),
|
|
437
|
-
fileIndexFor: (app: App, refresh?: boolean) => fileIndexFor(app, refresh),
|
|
438
|
-
dropFileIndex: (workspace: string) => { fileIndexes.delete(workspace); },
|
|
439
|
-
/** Show a stored image in the system viewer; resolves to why it could not, or null. */
|
|
440
|
-
openImage: (app: App, sha256: string) => openImageFile(app.blobDir, sha256),
|
|
441
|
-
};
|
|
442
|
-
|
|
443
|
-
type Outcome = "continue" | "quit" | { login: string };
|
|
444
|
-
|
|
445
|
-
async function execute(app: App, command: Command, tui: Ui | null): Promise<Outcome> {
|
|
446
|
-
switch (command.kind) {
|
|
447
|
-
case "workflow":
|
|
448
|
-
await setAppWorkflow(app, command.mode); app.io.say(` Workflow: ${command.mode}`); await tui?.refreshResources(); return "continue";
|
|
449
|
-
case "agent":
|
|
450
|
-
if (command.name) { await selectAppAgent(app, command.name); await bindExtensions(app, tui ?? undefined); await tui?.refreshResources(); }
|
|
451
|
-
else app.io.say(await listAppAgents(app)); return "continue";
|
|
452
|
-
case "mcp": {
|
|
453
|
-
const manager = app.foundation?.mcp; if (!manager) throw Error("MCP resources are unavailable");
|
|
454
|
-
if (command.action === "status") app.io.say(JSON.stringify(manager.status(), null, 2));
|
|
455
|
-
else {
|
|
456
|
-
if (!command.name) throw Error(`Use /mcp ${command.action} <server>`);
|
|
457
|
-
if (command.action === "login") await manager.login(command.name, url => { app.io.say(` Complete MCP login: ${url.href}`); }, tui?.signal);
|
|
458
|
-
else await manager.logout(command.name);
|
|
459
|
-
await reloadAppResources(app); await bindExtensions(app, tui ?? undefined); await tui?.refreshResources();
|
|
460
|
-
}
|
|
461
|
-
return "continue";
|
|
462
|
-
}
|
|
463
|
-
case "diff": { const snapshot = await appReviewSnapshot(app, reviewScopeFromText(command.args), tui?.signal); app.io.say(`${snapshot.label}\n${snapshot.diff || "(no captured changes)"}\n${snapshot.limitations.join("\n")}`); return "continue"; }
|
|
464
|
-
case "review": {
|
|
465
|
-
// A background branch (feat-review-branch): the parent stays usable; the result joins when it ends.
|
|
466
|
-
const node = await startReview(app, reviewScopeFromText(command.args));
|
|
467
|
-
app.io.say(`Review running as branch ${node}. Keep working; its findings join this session when it ends.${tui ? " ↓ from the prompt shows it." : " /branches inspects it."}`);
|
|
468
|
-
await tui?.syncTranscript(); return "continue";
|
|
469
|
-
}
|
|
470
|
-
case "extension":
|
|
471
|
-
if (tui) tui.runExtensionCommand(command.name, command.args);
|
|
472
|
-
else { await bindExtensions(app); await app.extensions?.command(command.name, command.args); }
|
|
473
|
-
return "continue";
|
|
474
|
-
case "attachments":
|
|
475
|
-
if (tui) tui.openAttachments();
|
|
476
|
-
else app.io.say("Attachments are edited in the interactive prompt.");
|
|
477
|
-
return "continue";
|
|
478
|
-
case "quit":
|
|
479
|
-
return "quit";
|
|
480
|
-
case "usage":
|
|
481
|
-
printUsage(app);
|
|
482
|
-
return "continue";
|
|
483
|
-
case "state":
|
|
484
|
-
await printState(app);
|
|
485
|
-
return "continue";
|
|
486
|
-
case "control":
|
|
487
|
-
if (tui) await tui.openControl();
|
|
488
|
-
else app.io.say(JSON.stringify(await sessionControl(app.session).snapshot(), null, 2));
|
|
489
|
-
return "continue";
|
|
490
|
-
case "context":
|
|
491
|
-
printContext(app);
|
|
492
|
-
return "continue";
|
|
493
|
-
case "acceptance":
|
|
494
|
-
await printAcceptance(app);
|
|
495
|
-
return "continue";
|
|
496
|
-
case "tasks": {
|
|
497
|
-
const items = await app.session.workItems();
|
|
498
|
-
app.io.say(`\nwork tasks: ${items.length}`);
|
|
499
|
-
for (const item of items) {
|
|
500
|
-
const ready = workReadiness(items, item.id);
|
|
501
|
-
app.io.say(` ${item.id}: ${ready.reason} · ${item.what} · verification=${item.verification}${item.owner ? ` · owner=${item.owner}` : ""}`);
|
|
502
|
-
if (ready.waiting_for.length) app.io.say(` waiting for: ${ready.waiting_for.join(", ")}`);
|
|
503
|
-
}
|
|
504
|
-
return "continue";
|
|
505
|
-
}
|
|
506
|
-
case "branches": {
|
|
507
|
-
if (command.action !== undefined && command.node !== undefined) app.io.say((await app.session.resolveBranch(command.node, command.action)).content);
|
|
508
|
-
for (const item of await app.session.branchStatuses()) app.io.say(` branch ${item.node}: ${item.status} · ${item.purpose}${item.receipt_ref ? ` · receipt ${item.receipt_ref}` : ""}`);
|
|
509
|
-
return "continue";
|
|
510
|
-
}
|
|
511
|
-
case "processes": {
|
|
512
|
-
for (const item of await app.session.processStatuses()) app.io.say(` process ${item.session}: ${item.status} · last observation=${item.observed}${item.exit !== undefined ? ` · exit=${item.exit}` : ""}${item.output ? ` · output ${item.output.sha256}` : ""}`);
|
|
513
|
-
return "continue";
|
|
514
|
-
}
|
|
515
|
-
case "accept":
|
|
516
|
-
await app.session.acceptCriterion(command.criterion);
|
|
517
|
-
await printAcceptance(app);
|
|
518
|
-
return "continue";
|
|
519
|
-
case "goal":
|
|
520
|
-
await goalCommand(app, command, tui ?? undefined);
|
|
521
|
-
return "continue";
|
|
522
|
-
case "help":
|
|
523
|
-
printHelp(app);
|
|
524
|
-
return "continue";
|
|
525
|
-
case "hotkeys":
|
|
526
|
-
printHotkeys(app);
|
|
527
|
-
return "continue";
|
|
528
|
-
case "settings":
|
|
529
|
-
return applySettingsCommand(app, command.field, command.value, tui);
|
|
530
|
-
case "reload": {
|
|
531
|
-
const cut = await reloadAppResources(app);
|
|
532
|
-
if (tui) await tui.refreshResources(); else await bindExtensions(app);
|
|
533
|
-
app.io.say(app.io.palette.green(` Resources reloaded${cut ? " at a context checkpoint" : ""}`));
|
|
534
|
-
return "continue";
|
|
535
|
-
}
|
|
536
|
-
case "config":
|
|
537
|
-
if (tui) await tui.openResources();
|
|
538
|
-
else {
|
|
539
|
-
const resources = await discoverResources({ ...app.args, workspace: app.workspace, trusted: (await projectTrust(app.workspace, app.projectTrusted ?? app.args.approve)).trusted });
|
|
540
|
-
for (const entry of resources.entries) app.io.say(`${entry.enabled ? "[x]" : "[ ]"} ${entry.kind} ${entry.scope} ${entry.path}`);
|
|
541
|
-
}
|
|
542
|
-
return "continue";
|
|
543
|
-
case "session":
|
|
544
|
-
await printSession(app);
|
|
545
|
-
return "continue";
|
|
546
|
-
case "model":
|
|
547
|
-
if (command.pattern !== undefined) {
|
|
548
|
-
await switchByPattern(app, command.pattern);
|
|
549
|
-
return "continue";
|
|
550
|
-
}
|
|
551
|
-
if (tui) {
|
|
552
|
-
tui.openModel();
|
|
553
|
-
return "continue";
|
|
554
|
-
}
|
|
555
|
-
printModels(app);
|
|
556
|
-
return "continue";
|
|
557
|
-
case "scoped-models":
|
|
558
|
-
if (tui) tui.openScopedModels();
|
|
559
|
-
else app.io.say((app.ui?.settings.scopedModels ?? app.catalog.filter(spec => app.credentialOf(spec).ok).map(modelKey)).join("\n"));
|
|
560
|
-
return "continue";
|
|
561
|
-
case "thinking":
|
|
562
|
-
return applyThinkingCommand(app, command.level, tui);
|
|
563
|
-
case "login": {
|
|
564
|
-
if (command.provider === undefined) {
|
|
565
|
-
if (tui) { tui.openLogin(); return "continue"; }
|
|
566
|
-
const C = app.io.palette;
|
|
567
|
-
const providers = [...new Set(app.catalog.map((m) => m.provider))].filter(provider => !subscriptionById(provider));
|
|
568
|
-
app.io.say(C.bold("\nproviders"));
|
|
569
|
-
app.io.say(C.bold(" API keys"));
|
|
570
|
-
for (const p of providers) {
|
|
571
|
-
app.io.say(` ${p.padEnd(12)}${C.dim(envLabel(p))}`);
|
|
572
|
-
}
|
|
573
|
-
app.io.say(C.bold(" Subscriptions"));
|
|
574
|
-
for (const s of SUBSCRIPTION_OPTIONS) {
|
|
575
|
-
app.io.say(` ${s.id.padEnd(16)}${C.dim(s.label)}`);
|
|
576
|
-
}
|
|
577
|
-
app.io.say(C.dim("\n /provider <provider> then paste the key. Empty line cancels."));
|
|
578
|
-
app.io.say(C.dim(` /provider ${SUBSCRIPTION_OPTIONS.map(option => option.id).join("|")} starts an account login.\n`));
|
|
579
|
-
return "continue";
|
|
580
|
-
}
|
|
581
|
-
const subscription = subscriptionById(command.provider);
|
|
582
|
-
if (subscription) {
|
|
583
|
-
return startSubscriptionLogin(app, subscription.id, tui);
|
|
584
|
-
}
|
|
585
|
-
if (!app.catalog.some(model => model.provider === command.provider)) { app.io.say(app.io.palette.red(` unknown provider: ${command.provider}. Use /provider to choose one.`)); return "continue"; }
|
|
586
|
-
if (tui) {
|
|
587
|
-
const provider = command.provider;
|
|
588
|
-
const key = await tui.askSecret(`API key · ${provider}`, "Key", "paste the key · $VAR reads the environment · !cmd runs a command · enter save · esc cancel");
|
|
589
|
-
if (key) await login(app, provider, key); else if (key !== undefined) tui.notice(" cancelled");
|
|
590
|
-
return "continue";
|
|
591
|
-
}
|
|
592
|
-
const C = app.io.palette;
|
|
593
|
-
// Line mode echoes nothing for this line; the sentence must say so,
|
|
594
|
-
// because "it will be visible here" is how a secret ends up in a screenshot.
|
|
595
|
-
app.io.say(C.yellow(` paste the ${command.provider} key and press enter. It is not shown.`));
|
|
596
|
-
app.io.say(C.dim(" a value starting with ! runs as a command; $VAR reads the environment."));
|
|
597
|
-
app.io.say(C.dim(" empty line cancels."));
|
|
598
|
-
return { login: command.provider };
|
|
599
|
-
}
|
|
600
|
-
case "logout": {
|
|
601
|
-
if (tui && command.provider === undefined) { tui.openLogout(); return "continue"; }
|
|
602
|
-
await logout(app, command.provider);
|
|
603
|
-
return "continue";
|
|
604
|
-
}
|
|
605
|
-
case "back": {
|
|
606
|
-
if (command.arg === undefined) { await printBack(app); return "continue"; }
|
|
607
|
-
const points = await app.session.restorePoints();
|
|
608
|
-
const i = Number(command.arg);
|
|
609
|
-
const point = Number.isInteger(i) ? points[i - 1] : undefined;
|
|
610
|
-
if (!point) {
|
|
611
|
-
app.io.say(app.io.palette.red(` no such point: ${command.arg}. /back lists them.`));
|
|
612
|
-
return "continue";
|
|
613
|
-
}
|
|
614
|
-
await printRewind(app, point, command.mode, command.confirm);
|
|
615
|
-
await tui?.syncTranscript();
|
|
616
|
-
return "continue";
|
|
617
|
-
}
|
|
618
|
-
case "tree":
|
|
619
|
-
if (tui) {
|
|
620
|
-
await tui.openTree();
|
|
621
|
-
return "continue";
|
|
622
|
-
}
|
|
623
|
-
await printTree(app);
|
|
624
|
-
return "continue";
|
|
625
|
-
case "new":
|
|
626
|
-
await openNewSession(app, tui ?? undefined);
|
|
627
|
-
return "continue";
|
|
628
|
-
case "resume":
|
|
629
|
-
if (command.id !== undefined) {
|
|
630
|
-
await resumeSession(app, command.id, tui ?? undefined);
|
|
631
|
-
return "continue";
|
|
632
|
-
}
|
|
633
|
-
if (tui) {
|
|
634
|
-
void tui.openResume();
|
|
635
|
-
return "continue";
|
|
636
|
-
}
|
|
637
|
-
await printSessionList(app);
|
|
638
|
-
return "continue";
|
|
639
|
-
case "name":
|
|
640
|
-
await nameSession(app, command.name, tui ?? undefined);
|
|
641
|
-
return "continue";
|
|
642
|
-
case "export":
|
|
643
|
-
await exportSession(app, command.file);
|
|
644
|
-
return "continue";
|
|
645
|
-
case "import": {
|
|
646
|
-
if (!command.file) { app.io.say(" Usage: /import <session.jsonl>"); return "continue"; }
|
|
647
|
-
const importing = async () => {
|
|
648
|
-
if (tui && !await tui.confirm("Import session", "Open the archive as a separate session? The current session stays saved.")) { app.io.say(" Import cancelled"); return; }
|
|
649
|
-
try {
|
|
650
|
-
const path = await importSessionArchive(resolve(app.workspace, command.file!), sessionsDir(app.workspace, sessionDirFor(app.args, app.workspace)), app.workspace);
|
|
651
|
-
if (await adoptLogDir(app, path)) { await bindExtensions(app, tui ?? undefined); await tui?.refreshResources(); await tui?.syncTranscript(); app.io.say(` Imported session: ${path}`); }
|
|
652
|
-
} catch (error) { app.io.say(app.io.palette.red(` Cannot import: ${describeError(error)}`)); }
|
|
653
|
-
};
|
|
654
|
-
if (tui) tui.runSessionTransition(importing); else await importing();
|
|
655
|
-
return "continue";
|
|
656
|
-
}
|
|
657
|
-
case "debug": {
|
|
658
|
-
const file = resolve(app.workspace, command.file ?? join(configDir(), "debug.log"));
|
|
659
|
-
const frame = tui?.snapshot() ?? { width: 80, height: 0, lines: [] };
|
|
660
|
-
const { width, height, lines } = frame;
|
|
661
|
-
const messages = projectContext({ frozen: app.session.config.frozen, handoff: app.session.state.handoff, events: app.session.state.events });
|
|
662
|
-
const body = [`xeno diagnostics ${app.session.clock.now()}`, `Terminal: ${width}x${height}`, `Total lines: ${lines.length}`,
|
|
663
|
-
...lines.map((line, i) => `[${i}] ${JSON.stringify(line)}`), "", "Agent messages (JSONL)", ...messages.messages.map(message => JSON.stringify(message)), ""].join("\n");
|
|
664
|
-
try { await mkdir(dirname(file), { recursive: true }); await writeFile(file, body, { mode: 0o600 }); app.io.say(` Debug log written: ${file}`); }
|
|
665
|
-
catch (error) { app.io.say(` Cannot write debug log: ${describeError(error)}`); }
|
|
666
|
-
return "continue";
|
|
667
|
-
}
|
|
668
|
-
case "changelog": {
|
|
669
|
-
const text = await readFile(new URL("../../CHANGELOG.md", import.meta.url), "utf8");
|
|
670
|
-
if (tui) tui.showDocument("Changelog", text); else app.io.say(text);
|
|
671
|
-
return "continue";
|
|
672
|
-
}
|
|
673
|
-
case "update":
|
|
674
|
-
if (tui) tui.checkForUpdate();
|
|
675
|
-
else await runManualUpdate({ offline: app.args.offline === true, install: true }, app.io.say);
|
|
676
|
-
return "continue";
|
|
677
|
-
case "copy":
|
|
678
|
-
await copyLastReply(app, tui ?? undefined);
|
|
679
|
-
return "continue";
|
|
680
|
-
case "fork":
|
|
681
|
-
if (tui && command.arg === undefined) { await tui.openFork(); return "continue"; }
|
|
682
|
-
await forkSession(app, command.arg, tui ?? undefined);
|
|
683
|
-
return "continue";
|
|
684
|
-
case "clone":
|
|
685
|
-
await cloneSession(app);
|
|
686
|
-
await tui?.syncTranscript();
|
|
687
|
-
return "continue";
|
|
688
|
-
case "trust":
|
|
689
|
-
if (command.target) await trustProject(app, command.target, tui ?? undefined);
|
|
690
|
-
else if (tui) await tui.openTrust();
|
|
691
|
-
else app.io.say(" /trust once|here|parent|never controls project resources; tool permissions are separate.");
|
|
692
|
-
return "continue";
|
|
693
|
-
case "models-refresh":
|
|
694
|
-
await refreshModels(app);
|
|
695
|
-
return "continue";
|
|
696
|
-
case "unknown":
|
|
697
|
-
app.io.say(app.io.palette.yellow(` unknown command: ${command.input}. /help lists them.`));
|
|
698
|
-
return "continue";
|
|
699
|
-
case "prompt":
|
|
700
|
-
await run(app, command.text, tui?.signal, tui ?? undefined, [], command.skill);
|
|
701
|
-
app.io.say("");
|
|
702
|
-
return "continue";
|
|
703
|
-
default:
|
|
704
|
-
return assertNever(command, "execute");
|
|
705
|
-
}
|
|
706
|
-
}
|
|
707
|
-
|
|
708
|
-
async function handleLine(app: App, text: string, tui: Ui): Promise<Outcome> {
|
|
709
|
-
return execute(app, parseLine(app, text), tui);
|
|
710
|
-
}
|
|
711
|
-
|
|
712
|
-
/**
|
|
713
|
-
* S.2 / S.5. Line mode keeps a drafted goal until `/goal accept` names the
|
|
714
|
-
* criteria to keep; the TUI asks with a checklist instead.
|
|
715
|
-
*/
|
|
716
|
-
const pendingGoals = new WeakMap<App, PendingGoal>();
|
|
717
|
-
|
|
718
|
-
async function goalCommand(app: App, command: Extract<Command, { kind: "goal" }>, tui?: Ui): Promise<void> {
|
|
719
|
-
const C = app.io.palette;
|
|
720
|
-
switch (command.action) {
|
|
721
|
-
case "status": await printGoal(app); return;
|
|
722
|
-
case "clear": app.io.say(await app.session.clearGoal() ? C.green(" goal cleared") : C.dim(" no active goal")); return;
|
|
723
|
-
case "set": {
|
|
724
|
-
if (tui) { tui.startGoal(command.objective); return; }
|
|
725
|
-
const drafted = await draftGoal(app, command.objective, (prompt, options) => run(app, prompt, undefined, undefined, [], undefined, options));
|
|
726
|
-
if (!drafted) return;
|
|
727
|
-
pendingGoals.set(app, drafted);
|
|
728
|
-
app.io.say(C.bold("\ndrafted criteria") + C.dim(" (accept with /goal accept all, or /goal accept 1,3)"));
|
|
729
|
-
drafted.criteria.forEach((item, index) => app.io.say(` ${index + 1}. ${item.id} · ${criterionSummary(item)}`));
|
|
730
|
-
return;
|
|
731
|
-
}
|
|
732
|
-
case "accept": {
|
|
733
|
-
const drafted = pendingGoals.get(app);
|
|
734
|
-
if (!drafted) { app.io.say(C.yellow(" no drafted goal; run /goal <objective> first")); return; }
|
|
735
|
-
const picked = pickCriteria(drafted.criteria, command.pick);
|
|
736
|
-
if (!picked.ok) { app.io.say(C.yellow(` ${picked.error}`)); return; }
|
|
737
|
-
pendingGoals.delete(app);
|
|
738
|
-
await app.session.setGoal({ ...drafted, accepted: picked.ids });
|
|
739
|
-
app.io.say(C.green(` goal set with ${picked.ids.length} accepted criteria`));
|
|
740
|
-
await run(app, drafted.objective);
|
|
741
|
-
await printGoal(app);
|
|
742
|
-
return;
|
|
743
|
-
}
|
|
744
|
-
default: return assertNever(command, "goal command");
|
|
745
|
-
}
|
|
746
|
-
}
|
|
747
|
-
|
|
748
|
-
async function printGoal(app: App): Promise<void> {
|
|
749
|
-
const C = app.io.palette, goal = await app.session.goal();
|
|
750
|
-
if (!goal) { app.io.say(C.dim(" no active goal; /goal <objective> drafts one")); return; }
|
|
751
|
-
const request = await app.session.requestState();
|
|
752
|
-
const current = request?.acceptance?.contract_id === goal.contract.id ? request : undefined;
|
|
753
|
-
app.io.say(C.bold("\ngoal") + ` ${goal.objective}`);
|
|
754
|
-
for (const item of goal.contract.criteria) {
|
|
755
|
-
const criterion = current?.criteria.find(entry => entry.id === item.id), status = criterion?.status ?? "not_run";
|
|
756
|
-
const guard = item.kind === "command" && item.guard === true;
|
|
757
|
-
app.io.say(` ${status === "pass" || status === "accepted" ? C.green("✓") : C.yellow("○")} ${item.id}${guard ? " (guard)" : ""} · ${status} · ${item.description}` +
|
|
758
|
-
(criterion?.counter ? counterSuffix(C, criterion.counter) : ""));
|
|
759
|
-
}
|
|
760
|
-
for (const blocker of current?.reported_blockers ?? []) app.io.say(C.yellow(` reported blocker: ${blocker.reason}`));
|
|
761
|
-
if (current && current.evidence.independent !== "not_applicable") app.io.say(` blind checks ${current.evidence.independent}`);
|
|
762
|
-
}
|
|
763
|
-
|
|
764
|
-
/** One counter-check verdict after a criterion, in the words of its role (S.7). */
|
|
765
|
-
function counterSuffix(C: App["io"]["palette"], verdict: CounterVerdict): string {
|
|
766
|
-
switch (verdict) {
|
|
767
|
-
case "vacuous": return C.yellow(" · also passes without the changes; it does not test them");
|
|
768
|
-
case "discriminating": return C.green(" · fails without the changes");
|
|
769
|
-
case "held": return C.green(" · held before the changes too");
|
|
770
|
-
case "not_held": return C.dim(" · did not hold before the changes; it tests them rather than guarding");
|
|
771
|
-
case "unavailable": return C.dim(" · counter-check unavailable");
|
|
772
|
-
default: return assertNever(verdict, "counter verdict");
|
|
773
|
-
}
|
|
774
|
-
}
|
|
775
|
-
|
|
776
|
-
function printUsage(app: App): void {
|
|
777
|
-
const C = app.io.palette;
|
|
778
|
-
const t = app.session.totals;
|
|
779
|
-
const spec = app.session.provider.config.spec;
|
|
780
|
-
const rates = spec.rates;
|
|
781
|
-
app.io.say(C.bold("\nusage"));
|
|
782
|
-
app.io.say(` requests ${t.requests}`);
|
|
783
|
-
app.io.say(` prompt tokens ${t.prompt_tokens.toLocaleString()}`);
|
|
784
|
-
app.io.say(` uncached ${t.input_tokens.toLocaleString()}`);
|
|
785
|
-
if (rates === undefined) {
|
|
786
|
-
app.io.say(` cache write ${t.cache_creation_input_tokens.toLocaleString()} (unknown)`);
|
|
787
|
-
app.io.say(` cache read ${t.cache_read_input_tokens.toLocaleString()} (unknown)`);
|
|
788
|
-
} else {
|
|
789
|
-
// The multiples come from the model that actually ran. They used to be
|
|
790
|
-
// Anthropic's, printed verbatim beside a DeepSeek total, where the write
|
|
791
|
-
// rate is 0 and the read rate is a third of what the label claimed.
|
|
792
|
-
app.io.say(
|
|
793
|
-
` cache write ${t.cache_creation_input_tokens.toLocaleString()} ` +
|
|
794
|
-
`(${rates.write}x${rates.write === 0 ? ", not billed" : ""})`,
|
|
795
|
-
);
|
|
796
|
-
app.io.say(` cache read ${t.cache_read_input_tokens.toLocaleString()} (${rates.read.toFixed(3)}x)`);
|
|
797
|
-
}
|
|
798
|
-
app.io.say(` output tokens ${t.output_tokens.toLocaleString()}`);
|
|
799
|
-
const rate = (t.cache_hit_rate * 100).toFixed(1);
|
|
800
|
-
const rateColour = t.cache_hit_rate > 0.7 ? C.green : t.cache_hit_rate > 0.3 ? C.yellow : C.red;
|
|
801
|
-
app.io.say(` cache hit rate ${rateColour(rate + "%")}`);
|
|
802
|
-
if (rates !== undefined) {
|
|
803
|
-
const cost = equivalentCost(t, rates);
|
|
804
|
-
app.io.say(
|
|
805
|
-
` input equivalent ${Math.round(cost.input_equivalent).toLocaleString()} ` +
|
|
806
|
-
C.dim(`(vs ${Math.round(cost.uncached_equivalent).toLocaleString()} uncached, ` +
|
|
807
|
-
`${cost.savings_ratio.toFixed(2)}x saved)`),
|
|
808
|
-
);
|
|
809
|
-
}
|
|
810
|
-
const dollars = reportedCost(t, spec.price);
|
|
811
|
-
if (dollars.kind === "unknown") {
|
|
812
|
-
app.io.say(` cost unknown`);
|
|
813
|
-
} else if (dollars.kind === "usd") {
|
|
814
|
-
app.io.say(` cost $${dollars.amount.toFixed(4)}`);
|
|
815
|
-
} else if (dollars.kind === "partial") {
|
|
816
|
-
// `known` is not a complete bill. Printing it on the usd path would
|
|
817
|
-
// look like cache had been free.
|
|
818
|
-
app.io.say(` cost $${dollars.known.toFixed(4)} (cache not counted)`);
|
|
819
|
-
} else {
|
|
820
|
-
return assertNever(dollars, "printUsage");
|
|
821
|
-
}
|
|
822
|
-
app.io.say(` model time ${(t.ms / 1000).toFixed(1)}s`);
|
|
823
|
-
app.io.say("");
|
|
824
|
-
}
|
|
825
|
-
|
|
826
|
-
async function printState(app: App): Promise<void> {
|
|
827
|
-
const C = app.io.palette;
|
|
828
|
-
const s = app.session.state;
|
|
829
|
-
app.io.say(C.bold("\nstate"));
|
|
830
|
-
app.io.say(` node ${s.node} (depth ${s.depth})`);
|
|
831
|
-
app.io.say(` events in node ${s.events.length}`);
|
|
832
|
-
const request = await app.session.requestState();
|
|
833
|
-
if (request) {
|
|
834
|
-
app.io.say(` request ${request.request.event} · ${request.status}`);
|
|
835
|
-
app.io.say(` execution ${request.execution?.reason ?? "in progress or unknown"}`);
|
|
836
|
-
app.io.say(` command evidence ${request.commands.filter(command => command.status === "pass").length} current passes / ${request.commands.length} receipts`);
|
|
837
|
-
app.io.say(` observations ${request.progress.observations.length} new; artifact changes ${request.progress.artifact_changes.length}; acceptance advances ${request.progress.acceptance_advances.length}`);
|
|
838
|
-
app.io.say(` diagnostics ${request.progress.new_diagnostics.length} new; unchanged retries ${request.progress.unchanged_retries.length}; unknown ${request.progress.unknown_diagnostics.length}`);
|
|
839
|
-
app.io.say(` evidence ran ${request.evidence.ran} · discriminating ${request.evidence.discriminating} · independent ${request.evidence.independent} · human ${request.evidence.human}`);
|
|
840
|
-
for (const gap of request.unmet) app.io.say(` unmet ${gap}`);
|
|
841
|
-
for (const blocker of request.reported_blockers) app.io.say(` reported blocker ${blocker.reason}`);
|
|
842
|
-
if (!request.acceptance) app.io.say(" task verification no declared contract; a fresh check shows only that the changes ran, not that the request was met");
|
|
843
|
-
}
|
|
844
|
-
if (s.handoff) {
|
|
845
|
-
const h = s.handoff;
|
|
846
|
-
app.io.say(` completed ${h.state.completed.length}`);
|
|
847
|
-
app.io.say(` unverified ${h.state.unverified_claims.length}`);
|
|
848
|
-
app.io.say(` in flight ${h.state.in_flight.length}`);
|
|
849
|
-
app.io.say(` blocked ${h.state.blocked.length}`);
|
|
850
|
-
app.io.say(
|
|
851
|
-
` gates ${h.verification.map((v) => `${v.gate}=${v.status}`).join(" ") || "none"}`,
|
|
852
|
-
);
|
|
853
|
-
app.io.say(` no progress for ${h.budget.nodes_without_progress} node(s)`);
|
|
854
|
-
} else {
|
|
855
|
-
app.io.say(C.dim(" no handoff yet (still in the first node)"));
|
|
856
|
-
}
|
|
857
|
-
app.io.say("");
|
|
858
|
-
}
|
|
859
|
-
|
|
860
|
-
function printContext(app: App): void {
|
|
861
|
-
const session = app.session;
|
|
862
|
-
const detail = contextDetail({ ...session.contextInput(), anchor: session.state.anchor,
|
|
863
|
-
policy: session.config.contextPolicy ?? DEFAULT_CONTEXT_POLICY,
|
|
864
|
-
contextWindow: session.provider.config.spec.contextWindow, outputReserve: session.provider.config.maxTokens,
|
|
865
|
-
...(session.config.counter ? { counter: session.config.counter } : {}),
|
|
866
|
-
...(session.config.boundary ? { boundary: session.config.boundary } : {}),
|
|
867
|
-
});
|
|
868
|
-
app.io.say(app.io.palette.bold(`\ncontext v${detail.version} · node ${session.state.node} · ${detail.policy}`));
|
|
869
|
-
for (const [name, tokens] of Object.entries(detail.components)) app.io.say(` ${name.padEnd(18)} ~${tokens} tokens`);
|
|
870
|
-
app.io.say(` total ${detail.used} (${detail.countKind}; anchor correction ${detail.anchorCorrection}; measured through ${detail.measuredThrough} messages)`);
|
|
871
|
-
app.io.say(` limits ${JSON.stringify(detail.limits)}`);
|
|
872
|
-
const sources = detail.sources.flat();
|
|
873
|
-
app.io.say(` sources ${sources.length} references: ${sources.slice(-12).join(", ") || "none"}`);
|
|
874
|
-
if (sources.length > 12) app.io.say(` earlier sources ${sources.length - 12}; use history(recent=true) or history(event=ID)`);
|
|
875
|
-
for (const section of detail.sections.filter(s => s.omitted > 0)) {
|
|
876
|
-
app.io.say(` ${section.section} retained ${section.retained}/${section.total}; omitted ${section.omitted}; history(${JSON.stringify(section.recovery)})`);
|
|
877
|
-
}
|
|
878
|
-
app.io.say(` metadata ${detail.omittedEvents.length} events do not replay as messages; available in the log`);
|
|
879
|
-
if (detail.violations.length) app.io.say(` violations ${JSON.stringify(detail.violations)}`);
|
|
880
|
-
app.io.say("");
|
|
881
|
-
}
|
|
882
|
-
|
|
883
|
-
async function printAcceptance(app: App): Promise<void> {
|
|
884
|
-
const state = await app.session.acceptanceState();
|
|
885
|
-
app.io.say(state ? `\nacceptance ${state.contract_id}: ${state.status}` : "\nacceptance: no declared task contract");
|
|
886
|
-
for (const target of state?.targets ?? []) {
|
|
887
|
-
app.io.say(` target ${target.id}: ${target.status} · entry ${target.entry ?? "unobserved"} · pid ${target.pid ?? "unknown"} · session ${target.session ?? "none"} · port ${target.port ?? "not declared"}`);
|
|
888
|
-
app.io.say(` build ${target.build_event ?? "none"}; instance ${target.binding ?? "unknown"}; observation ${target.observation ?? "none"}; ${target.reason ?? "start the declared target after its build"}`);
|
|
889
|
-
}
|
|
890
|
-
for (const item of state?.criteria ?? []) {
|
|
891
|
-
app.io.say(` ${item.id}${item.kind === "command" && item.guard ? " (guard)" : ""}: ${item.status} · ${item.description}`);
|
|
892
|
-
if (item.kind === "command") app.io.say(` command: ${item.command}; evidence: ${item.evidence?.event_id ?? "none"}`);
|
|
893
|
-
else if (item.status !== "accepted") app.io.say(` /accept ${item.id} records your acceptance${item.target ? ` of the observed target ${item.target}` : ""}`);
|
|
894
|
-
}
|
|
895
|
-
}
|
|
896
|
-
|
|
897
|
-
function printHotkeys(app: App): void {
|
|
898
|
-
const C = app.io.palette;
|
|
899
|
-
const map = app.ui?.keymap ?? defaultKeymap();
|
|
900
|
-
const enabled = app.session.config.frozen.tools.length;
|
|
901
|
-
app.io.say(C.bold("\nhotkeys"));
|
|
902
|
-
app.io.say(` tools enabled ${enabled}`);
|
|
903
|
-
for (const row of listBindings(map)) {
|
|
904
|
-
app.io.say(` ${row.action.padEnd(28)}${row.keys.join(", ")}`);
|
|
905
|
-
}
|
|
906
|
-
app.io.say("");
|
|
907
|
-
}
|
|
908
|
-
|
|
909
|
-
async function applySettingsCommand(
|
|
910
|
-
app: App,
|
|
911
|
-
field: string | undefined,
|
|
912
|
-
value: string | undefined,
|
|
913
|
-
tui: Ui | null,
|
|
914
|
-
): Promise<Outcome> {
|
|
915
|
-
if (field !== undefined && value !== undefined) {
|
|
916
|
-
await commitSetting(app, field, value, tui);
|
|
917
|
-
return "continue";
|
|
918
|
-
}
|
|
919
|
-
if (tui) {
|
|
920
|
-
tui.openSettings(field);
|
|
921
|
-
return "continue";
|
|
922
|
-
}
|
|
923
|
-
const s = app.ui?.settings;
|
|
924
|
-
app.io.say(app.io.palette.bold("\nsettings"));
|
|
925
|
-
app.io.say(` theme ${s?.theme ?? "dark"}`);
|
|
926
|
-
app.io.say(` thinking ${s?.thinking ?? app.providerConfig.effort}`);
|
|
927
|
-
app.io.say(app.io.palette.dim(" /settings <theme|thinking> <value> to change; /settings alone opens every option.\n"));
|
|
928
|
-
return "continue";
|
|
929
|
-
}
|
|
930
|
-
|
|
931
|
-
async function commitSetting(app: App, field: string, value: string, tui: Ui | null): Promise<void> {
|
|
932
|
-
const C = app.io.palette;
|
|
933
|
-
const key = field.toLowerCase();
|
|
934
|
-
if (app.ui === undefined) {
|
|
935
|
-
app.io.say(C.yellow(" settings are not available in this session"));
|
|
936
|
-
return;
|
|
937
|
-
}
|
|
938
|
-
if (key === "thinking") {
|
|
939
|
-
const effort = asEffort(value);
|
|
940
|
-
if (effort) await applyThinking(app, effort, tui);
|
|
941
|
-
return;
|
|
942
|
-
}
|
|
943
|
-
const parsed = settingPatch(field, value);
|
|
944
|
-
if (parsed.diagnostics.length) { app.io.say(C.yellow(parsed.diagnostics.join("\n"))); return; }
|
|
945
|
-
if (key === "theme") {
|
|
946
|
-
const diagnostics: string[] = [];
|
|
947
|
-
const theme = await resolveTheme(app.resources ? themeResourcePath(app.resources, value) ?? value : value, diagnostics);
|
|
948
|
-
if (diagnostics.length) { app.io.say(C.yellow(diagnostics.join("\n"))); return; }
|
|
949
|
-
app.ui.theme = theme;
|
|
950
|
-
app.ui.style = colorEnabled() ? createStyle(theme.colors) : IDENTITY;
|
|
951
|
-
}
|
|
952
|
-
const saved = await commitUserSettings(app, parsed.settings);
|
|
953
|
-
if (saved && key === "enableskillcommands") { await reloadAppResources(app); await tui?.refreshResources(); }
|
|
954
|
-
if (saved) {
|
|
955
|
-
if (tui) tui.toast(`${field} · ${value}`, "success");
|
|
956
|
-
else app.io.say(C.dim(` ${field} ${value}`));
|
|
957
|
-
}
|
|
958
|
-
tui?.applyConfiguration();
|
|
959
|
-
if (key === "theme") tui?.followTheme();
|
|
960
|
-
tui?.redraw();
|
|
961
|
-
}
|
|
962
|
-
|
|
963
|
-
const EFFORTS = THINKING_LEVELS;
|
|
964
|
-
|
|
965
|
-
/** Explicit user choices are refused; automatic model changes use the pure clamp policy. */
|
|
966
|
-
function effortSupported(spec: ModelSpec, level: Effort): boolean {
|
|
967
|
-
return supportedThinkingLevels(spec).includes(level);
|
|
968
|
-
}
|
|
969
|
-
|
|
970
|
-
/**
|
|
971
|
-
* Effort is a request parameter on the current provider. Switching it
|
|
972
|
-
* must not cut: a cut drops the prefix cache, and thinking blocks stay
|
|
973
|
-
* valid because the model did not change.
|
|
974
|
-
*/
|
|
975
|
-
async function applyThinking(app: App, level: Effort, tui: Ui | null, origin: PreferenceOrigin = "user"): Promise<boolean> {
|
|
976
|
-
const C = app.io.palette;
|
|
977
|
-
if (!effortSupported(app.providerConfig.spec, level)) {
|
|
978
|
-
app.io.say(
|
|
979
|
-
C.yellow(
|
|
980
|
-
` ${level} is not supported by ${app.providerConfig.spec.id}; thinking unchanged`,
|
|
981
|
-
),
|
|
982
|
-
);
|
|
983
|
-
return false;
|
|
984
|
-
}
|
|
985
|
-
app.providerConfig = { ...app.providerConfig, effort: level };
|
|
986
|
-
if (app.credential.ok) {
|
|
987
|
-
app.session.provider = makeProvider(app, app.providerConfig, app.credential.key);
|
|
988
|
-
}
|
|
989
|
-
await app.session.setThinking(level);
|
|
990
|
-
if (app.ui !== undefined) {
|
|
991
|
-
app.ui.settings = { ...app.ui.settings, thinking: level };
|
|
992
|
-
}
|
|
993
|
-
if (origin === "user") await commitUserSettings(app, thinkingPreferencePatch(app.providerConfig.spec, level));
|
|
994
|
-
app.io.say(C.dim(` thinking ${level}`));
|
|
995
|
-
tui?.redraw();
|
|
996
|
-
return true;
|
|
997
|
-
}
|
|
998
|
-
|
|
999
|
-
async function applyThinkingCommand(app: App, level: string | undefined, tui: Ui | null, origin: PreferenceOrigin = "user"): Promise<Outcome> {
|
|
1000
|
-
if (level !== undefined) {
|
|
1001
|
-
const effort = asEffort(level);
|
|
1002
|
-
if (effort === undefined) {
|
|
1003
|
-
app.io.say(app.io.palette.yellow(" thinking must be " + THINKING_LEVELS.join("|")));
|
|
1004
|
-
return "continue";
|
|
1005
|
-
}
|
|
1006
|
-
await applyThinking(app, effort, tui, origin);
|
|
1007
|
-
return "continue";
|
|
1008
|
-
}
|
|
1009
|
-
if (tui) {
|
|
1010
|
-
tui.openThinking();
|
|
1011
|
-
return "continue";
|
|
1012
|
-
}
|
|
1013
|
-
printThinking(app);
|
|
1014
|
-
return "continue";
|
|
1015
|
-
}
|
|
1016
|
-
|
|
1017
|
-
function printThinking(app: App): void {
|
|
1018
|
-
const C = app.io.palette;
|
|
1019
|
-
const spec = app.providerConfig.spec;
|
|
1020
|
-
app.io.say(C.bold("\nthinking"));
|
|
1021
|
-
for (const level of EFFORTS) {
|
|
1022
|
-
const supported = effortSupported(spec, level);
|
|
1023
|
-
const here = level === app.providerConfig.effort;
|
|
1024
|
-
const mark = !supported ? C.dim(" -- ") : here ? C.cyan(" now ") : C.green(" ");
|
|
1025
|
-
const note = supported ? "" : C.dim(" unsupported");
|
|
1026
|
-
app.io.say(` ${mark} ${level}${note}`);
|
|
1027
|
-
}
|
|
1028
|
-
app.io.say(C.dim("\n /thinking <level> to switch. Shift+Tab cycles supported levels.\n"));
|
|
1029
|
-
}
|
|
1030
|
-
|
|
1031
|
-
async function cycleThinking(app: App, tui: Ui | null): Promise<void> {
|
|
1032
|
-
const supported = EFFORTS.filter((level) => effortSupported(app.providerConfig.spec, level));
|
|
1033
|
-
const next = cycle(supported, app.providerConfig.effort, 1);
|
|
1034
|
-
if (next === undefined) return;
|
|
1035
|
-
await applyThinking(app, next, tui);
|
|
1036
|
-
}
|
|
1037
|
-
|
|
1038
|
-
async function cycleScopedModel(app: App, dir: 1 | -1): Promise<void> {
|
|
1039
|
-
const C = app.io.palette;
|
|
1040
|
-
const fromFlag = splitModelPatterns(app.args.models);
|
|
1041
|
-
const names = app.args.modelsExplicit || fromFlag.length > 0 ? fromFlag : app.ui?.settings.scopedModels ?? app.catalog.filter(spec => app.credentialOf(spec).ok).map(modelKey);
|
|
1042
|
-
if (names.length === 0) {
|
|
1043
|
-
app.io.say(C.dim(" no scoped models. Pass --models a,b or set scopedModels."));
|
|
1044
|
-
return;
|
|
1045
|
-
}
|
|
1046
|
-
const scope = scopedModels(names, app.catalog.filter(spec => app.credentialOf(spec).ok));
|
|
1047
|
-
for (const message of scope.diagnostics) app.io.say(C.yellow(` ${message}`));
|
|
1048
|
-
const ids = scope.entries.map(entry => modelKey(entry.model));
|
|
1049
|
-
if (ids.length === 0) {
|
|
1050
|
-
app.io.say(C.yellow(" --models matched nothing"));
|
|
1051
|
-
return;
|
|
1052
|
-
}
|
|
1053
|
-
const next = cycle(ids, modelKey(app.providerConfig.spec), dir);
|
|
1054
|
-
if (next === undefined) return;
|
|
1055
|
-
const selected = scope.entries.find(entry => modelKey(entry.model) === next)!;
|
|
1056
|
-
await switchToModel(app, selected.model, selected.thinking);
|
|
1057
|
-
}
|
|
1058
|
-
|
|
1059
|
-
function printHelp(app: App): void {
|
|
1060
|
-
const C = app.io.palette;
|
|
1061
|
-
app.io.say(C.bold("\ncommands"));
|
|
1062
|
-
for (const command of COMMANDS) {
|
|
1063
|
-
const hint = "argumentHint" in command ? ` ${command.argumentHint}` : "";
|
|
1064
|
-
app.io.say(` /${(command.name + hint).padEnd(34)} ${command.description}`);
|
|
1065
|
-
}
|
|
1066
|
-
for (const [name, command] of app.extensions?.commands ?? []) app.io.say(` /${name.padEnd(34)} ${command.description ?? "Extension command"}`);
|
|
1067
|
-
app.io.say(' @file or @"file name" attach a project file anywhere in the prompt');
|
|
1068
|
-
app.io.say(" !cmd run in the workspace and send stdout/stderr/exit to the model");
|
|
1069
|
-
app.io.say(" !!cmd run without sending output");
|
|
1070
|
-
app.io.say("");
|
|
1071
|
-
}
|
|
1072
|
-
|
|
1073
|
-
async function printSession(app: App): Promise<void> {
|
|
1074
|
-
const C = app.io.palette;
|
|
1075
|
-
const cursor = await readCursor(app.logDir);
|
|
1076
|
-
app.io.say(C.bold("\nsession"));
|
|
1077
|
-
app.io.say(` id ${basename(app.logDir)}`);
|
|
1078
|
-
app.io.say(` name ${cursor?.name ?? "(unnamed)"}`);
|
|
1079
|
-
app.io.say(` path ${app.logDir}`);
|
|
1080
|
-
app.io.say(` node ${app.session.state.node}`);
|
|
1081
|
-
app.io.say(` events ${app.session.state.events.length}`);
|
|
1082
|
-
app.io.say(` model ${app.providerConfig.spec.id}`);
|
|
1083
|
-
printUsage(app);
|
|
1084
|
-
}
|
|
1085
|
-
|
|
1086
|
-
async function printSessionList(app: App): Promise<void> {
|
|
1087
|
-
const C = app.io.palette;
|
|
1088
|
-
const { sessions, truncated } = await listSessions(app.workspace, sessionDirOf(app), Infinity);
|
|
1089
|
-
const note = truncationNote(truncated);
|
|
1090
|
-
app.io.say(C.bold("\nsessions"));
|
|
1091
|
-
if (note) app.io.say(C.yellow(` ${note}`));
|
|
1092
|
-
if (sessions.length === 0) {
|
|
1093
|
-
app.io.say(C.dim(" none yet -- /new starts one"));
|
|
1094
|
-
app.io.say("");
|
|
1095
|
-
return;
|
|
1096
|
-
}
|
|
1097
|
-
for (const s of sessions) {
|
|
1098
|
-
if (!s.ok) {
|
|
1099
|
-
app.io.say(C.red(` ${s.id} unreadable ${s.error}`));
|
|
1100
|
-
continue;
|
|
1101
|
-
}
|
|
1102
|
-
// A session left without a word is noise here; the current one stays (fix-skip-empty-sessions).
|
|
1103
|
-
if (s.path !== app.logDir && !await hasConversation(s.path)) continue;
|
|
1104
|
-
const here = s.path === app.logDir ? C.cyan(" now ") : " ";
|
|
1105
|
-
const label = (s.name ?? s.id).padEnd(24);
|
|
1106
|
-
app.io.say(` ${here} ${label}${C.dim(`${s.events} events ${s.model ?? ""}`)}`);
|
|
1107
|
-
}
|
|
1108
|
-
app.io.say(C.dim("\n /resume <id> to switch\n"));
|
|
1109
|
-
}
|
|
1110
|
-
|
|
1111
|
-
/**
|
|
1112
|
-
* Lists somewhere to go back to, newest last, numbered for typing.
|
|
1113
|
-
*
|
|
1114
|
-
* Numbered rather than addressed by node and event id: the address is an
|
|
1115
|
-
* implementation detail and nobody can type `e00017`. The numbering is of
|
|
1116
|
-
* the list as printed, so it is re-read on every command rather than
|
|
1117
|
-
* remembered -- a rewind changes the ancestry and with it the list.
|
|
1118
|
-
*/
|
|
1119
|
-
async function printBack(app: App): Promise<void> {
|
|
1120
|
-
const C = app.io.palette;
|
|
1121
|
-
const points = await app.session.restorePoints();
|
|
1122
|
-
app.io.say(C.bold("\nsomewhere to go back to") + C.dim(" (newest last)"));
|
|
1123
|
-
if (points.length === 0) {
|
|
1124
|
-
app.io.say(C.dim(" nothing yet -- no exchange has changed a file"));
|
|
1125
|
-
}
|
|
1126
|
-
points.forEach((p, i) => {
|
|
1127
|
-
const n = String(i + 1).padStart(2);
|
|
1128
|
-
const files = `${p.changed} file${p.changed === 1 ? "" : "s"}`;
|
|
1129
|
-
// Off-path entries are branches this session has left, usually by
|
|
1130
|
-
// rewinding. Listed, because a node that survives on disk and cannot be
|
|
1131
|
-
// found again is lost in every sense that matters to a person.
|
|
1132
|
-
const mark = p.onPath ? " " : C.yellow("↩ ");
|
|
1133
|
-
app.io.say(` ${C.bold(n)} ${mark} ${C.dim(files.padEnd(9))}${firstLine(p.prompt, 60)}`);
|
|
1134
|
-
});
|
|
1135
|
-
if (points.some((p) => !p.onPath)) {
|
|
1136
|
-
app.io.say(C.dim(`\n ${C.yellow("↩")} left behind by an earlier /back -- still reachable`));
|
|
1137
|
-
}
|
|
1138
|
-
app.io.say(C.dim("\n /back <n> move the files and the conversation"));
|
|
1139
|
-
app.io.say(C.dim(" /back <n> files move the files, keep what was learned"));
|
|
1140
|
-
app.io.say(C.dim(" /back <n> context move the conversation, keep the code"));
|
|
1141
|
-
// Restated from the write-ups rather than left for the person to work
|
|
1142
|
-
// out, because picking wrong is how a rewind makes things worse: the
|
|
1143
|
-
// documented trap is keeping bad code with no memory of writing it.
|
|
1144
|
-
app.io.say(C.dim("\n code broken + model confused -> plain /back"));
|
|
1145
|
-
app.io.say(C.dim(" code broken, model was right -> files"));
|
|
1146
|
-
app.io.say(C.dim(" code fine, model went wrong -> context\n"));
|
|
1147
|
-
}
|
|
1148
|
-
|
|
1149
|
-
/**
|
|
1150
|
-
* Non-tty /tree: the same layout as the live view, committed to scrollback.
|
|
1151
|
-
* Jumping still needs the TUI; this is so a pipe can see the structure.
|
|
1152
|
-
*/
|
|
1153
|
-
async function printTree(app: App): Promise<void> {
|
|
1154
|
-
const rows = flattenTree(await treeNodesFor(app));
|
|
1155
|
-
if (!rows.length) { app.io.say(EMPTY_TREE_MESSAGE); return; }
|
|
1156
|
-
for (const row of rows) {
|
|
1157
|
-
const mark = row.node.current ? "● " : row.node.onPath ? " " : `${LEFT_BRANCH_MARK} `;
|
|
1158
|
-
app.io.say(row.branch + mark + (row.node.label ? `[${row.node.label}] ` : "") + firstLine(row.node.prompt, 72));
|
|
1159
|
-
}
|
|
1160
|
-
}
|
|
1161
|
-
|
|
1162
|
-
async function treeNodesFor(app: App): Promise<TreeNode[]> {
|
|
1163
|
-
const parents = await loadNodeParents(app.logDir);
|
|
1164
|
-
const manifest = await readManifest(app.logDir);
|
|
1165
|
-
const history = await readNodeHistory(app.logDir, app.session.state.node);
|
|
1166
|
-
if (!history.ok) throw new Error(history.error);
|
|
1167
|
-
const onPath = new Set(history.nodes.flatMap(n => n.events.map(e => `${n.node}:${e.id}`)));
|
|
1168
|
-
const points: TreePoint[] = [];
|
|
1169
|
-
for (const node of parents.keys()) {
|
|
1170
|
-
const { events, corrupt } = await readNode(app.logDir, node);
|
|
1171
|
-
if (corrupt.length) throw new Error("Cannot display corrupt session events");
|
|
1172
|
-
for (const event of events) {
|
|
1173
|
-
const content = event.t === "user" ? [event.text, ...(event.images ?? []).map(i => `[Image: ${i.name}]`)].filter(Boolean).join("\n") :
|
|
1174
|
-
event.t === "assistant" ? event.blocks.map(b => b.type === "tool_use" ? `tool · ${b.name} ${JSON.stringify(b.input)}` : `${b.type === "thinking" ? "thinking · " : ""}${b.text}`).join("\n") :
|
|
1175
|
-
event.t === "tool_result" ? event.content : event.t === "user_bash" ? `$ ${event.command}\n${event.content}` :
|
|
1176
|
-
event.t === "cut" ? `context boundary · ${event.reason}` : JSON.stringify(event);
|
|
1177
|
-
const tool = event.t === "tool_result" || event.t === "user_bash" || event.t === "assistant" && event.blocks.every(b => b.type === "tool_use");
|
|
1178
|
-
const category = tool ? "tool" : event.t === "user" || event.t === "assistant" ? "message" : "metadata";
|
|
1179
|
-
const label = manifest.labels?.[event.id];
|
|
1180
|
-
points.push({ node, atEvent: event.id, target: event.id, prompt: `${event.t === "user" ? "" : event.t === "assistant" ? "assistant · " : `${event.t} · `}${firstLine(content, 160)}`,
|
|
1181
|
-
onPath: onPath.has(`${node}:${event.id}`), kind: event.t === "user" ? "user" : "other", category, content, at: event.at,
|
|
1182
|
-
...(label ? { label: label.text, labelAt: label.at } : {}) });
|
|
1183
|
-
}
|
|
1184
|
-
}
|
|
1185
|
-
return treeFromPoints(points, parents, app.session.state.node);
|
|
1186
|
-
}
|
|
1187
|
-
|
|
1188
|
-
async function loadNodeParents(logDir: string): Promise<Map<number, NodeParent>> {
|
|
1189
|
-
const map = new Map<number, NodeParent>();
|
|
1190
|
-
for (const id of await listNodes(logDir)) {
|
|
1191
|
-
const { header } = await readNode(logDir, id);
|
|
1192
|
-
if (header === null) continue;
|
|
1193
|
-
if (header.parent === null) {
|
|
1194
|
-
map.set(id, null);
|
|
1195
|
-
continue;
|
|
1196
|
-
}
|
|
1197
|
-
map.set(
|
|
1198
|
-
id,
|
|
1199
|
-
header.parent.at_event === undefined
|
|
1200
|
-
? { node: header.parent.node }
|
|
1201
|
-
: { node: header.parent.node, at_event: header.parent.at_event },
|
|
1202
|
-
);
|
|
1203
|
-
}
|
|
1204
|
-
return map;
|
|
1205
|
-
}
|
|
1206
|
-
|
|
1207
|
-
/**
|
|
1208
|
-
* Goes back: the files and the conversation move together.
|
|
1209
|
-
*
|
|
1210
|
-
* Preview first, always. The half of this that nobody asked for is the
|
|
1211
|
-
* deletion of everything the target did not have -- including a file a
|
|
1212
|
-
* person wrote by hand while the session sat idle -- and that half is
|
|
1213
|
-
* invisible in the request.
|
|
1214
|
-
*/
|
|
1215
|
-
async function printRewind(
|
|
1216
|
-
app: App,
|
|
1217
|
-
point: RestorePoint,
|
|
1218
|
-
mode: RewindMode,
|
|
1219
|
-
confirmed: boolean,
|
|
1220
|
-
): Promise<void> {
|
|
1221
|
-
const C = app.io.palette;
|
|
1222
|
-
const label = firstLine(point.prompt, 60);
|
|
1223
|
-
const plan = await app.session.previewRewind(point, mode);
|
|
1224
|
-
if (!plan.ok) {
|
|
1225
|
-
app.io.say(C.red(`\n cannot go back: ${plan.error}\n`));
|
|
1226
|
-
return;
|
|
1227
|
-
}
|
|
1228
|
-
|
|
1229
|
-
const what =
|
|
1230
|
-
mode === "both" ? "the files and the conversation"
|
|
1231
|
-
: mode === "files" ? "the files only"
|
|
1232
|
-
: "the conversation only";
|
|
1233
|
-
app.io.say(C.bold("\ngo back to") + ` ${label}` + C.dim(` (${what})`));
|
|
1234
|
-
|
|
1235
|
-
if (mode === "context") {
|
|
1236
|
-
// Nothing on disk changes, so listing what the target HAD would read
|
|
1237
|
-
// like a plan to write it. What matters is the gap the model is about
|
|
1238
|
-
// to be told about.
|
|
1239
|
-
const extra = plan.willDelete;
|
|
1240
|
-
app.io.say(C.dim(` no file is touched`));
|
|
1241
|
-
for (const p of extra.slice(0, 20)) app.io.say(C.yellow(` stays ${p} (not in that context)`));
|
|
1242
|
-
if (extra.length > 20) app.io.say(C.yellow(` ... ${extra.length - 20} more`));
|
|
1243
|
-
} else {
|
|
1244
|
-
for (const p of plan.willWrite.slice(0, 20)) app.io.say(C.dim(` restore ${p}`));
|
|
1245
|
-
if (plan.willWrite.length > 20) app.io.say(C.dim(` ... ${plan.willWrite.length - 20} more`));
|
|
1246
|
-
for (const p of plan.willDelete.slice(0, 20)) app.io.say(C.red(` delete ${p}`));
|
|
1247
|
-
if (plan.willDelete.length > 20) app.io.say(C.red(` ... ${plan.willDelete.length - 20} more`));
|
|
1248
|
-
}
|
|
1249
|
-
|
|
1250
|
-
if (!confirmed) {
|
|
1251
|
-
app.io.say(C.yellow('\n nothing changed. add "confirm" to go back.'));
|
|
1252
|
-
if (mode === "both") {
|
|
1253
|
-
app.io.say(C.dim(" the turns after this point stop being what the model sees."));
|
|
1254
|
-
app.io.say(C.dim(" They are not deleted -- this list will still offer them."));
|
|
1255
|
-
} else {
|
|
1256
|
-
app.io.say(C.dim(" the two will disagree afterwards. The model is told which"));
|
|
1257
|
-
app.io.say(C.dim(" paths, and to re-read before editing -- it is not left to"));
|
|
1258
|
-
app.io.say(C.dim(" work it out from a context that no longer matches disk."));
|
|
1259
|
-
}
|
|
1260
|
-
app.io.say("");
|
|
1261
|
-
return;
|
|
1262
|
-
}
|
|
1263
|
-
|
|
1264
|
-
const done = await app.session.rewind(point, mode);
|
|
1265
|
-
if (!done.ok) {
|
|
1266
|
-
app.io.say(C.red(`\n could not go back: ${done.error}\n`));
|
|
1267
|
-
return;
|
|
1268
|
-
}
|
|
1269
|
-
if (done.unrecoverable.length > 0) {
|
|
1270
|
-
app.io.say(C.red(`\n restore not applied: missing content for ${done.unrecoverable.join(", ")}`));
|
|
1271
|
-
app.io.say(C.dim(` ${done.note}\n`));
|
|
1272
|
-
return;
|
|
1273
|
-
}
|
|
1274
|
-
app.io.say(
|
|
1275
|
-
C.green(`\n back at node ${done.node}`) +
|
|
1276
|
-
C.dim(
|
|
1277
|
-
mode === "context"
|
|
1278
|
-
? " no file touched"
|
|
1279
|
-
: ` ${done.restored.length} restored, ${done.removed.length} removed`,
|
|
1280
|
-
),
|
|
1281
|
-
);
|
|
1282
|
-
if (done.unrecoverable.length > 0) {
|
|
1283
|
-
app.io.say(C.red(` content no longer stored: ${done.unrecoverable.join(", ")}`));
|
|
1284
|
-
}
|
|
1285
|
-
app.io.say(C.dim(indent(wrap(done.note, 72), " ")));
|
|
1286
|
-
app.io.say("");
|
|
1287
|
-
}
|
|
1288
|
-
|
|
1289
|
-
function printModels(app: App): void {
|
|
1290
|
-
const C = app.io.palette;
|
|
1291
|
-
app.io.say(C.bold("\nmodels"));
|
|
1292
|
-
for (const group of groupedModels(app.catalog)) {
|
|
1293
|
-
app.io.say(C.bold(` ${group.provider}`));
|
|
1294
|
-
for (const spec of group.models) {
|
|
1295
|
-
const got = app.credentialOf(spec);
|
|
1296
|
-
const here = modelKey(spec) === modelKey(app.providerConfig.spec);
|
|
1297
|
-
const mark = here ? C.cyan(" now ") : got.ok ? C.green("ready") : C.dim(" -- ");
|
|
1298
|
-
app.io.say(` ${mark} ${spec.id.padEnd(28)}${C.dim(spec.name.padEnd(28))}${whyNot(spec, got, app.auth, C)}`);
|
|
1299
|
-
}
|
|
1300
|
-
}
|
|
1301
|
-
app.io.say(C.dim("\n /model <id> to switch. Switching cuts this node so the next"));
|
|
1302
|
-
app.io.say(C.dim(" model starts from a handoff, not from this model's thinking blocks.\n"));
|
|
1303
|
-
}
|
|
1304
|
-
|
|
1305
|
-
async function refreshModels(app: App): Promise<void> {
|
|
1306
|
-
const C = app.io.palette;
|
|
1307
|
-
const result = await runModelsRefresh({
|
|
1308
|
-
fetch: globalThis.fetch,
|
|
1309
|
-
write: writeCatalogCache,
|
|
1310
|
-
});
|
|
1311
|
-
if (!result.ok) {
|
|
1312
|
-
app.io.say(C.yellow(` ${result.reason}`));
|
|
1313
|
-
app.io.say(C.dim(" existing models.json left unchanged\n"));
|
|
1314
|
-
return;
|
|
1315
|
-
}
|
|
1316
|
-
const loaded = loadCatalog(await readModelSources());
|
|
1317
|
-
await publishModelCatalog(app, loaded.models);
|
|
1318
|
-
for (const warning of loaded.warnings) app.io.say(C.yellow(` ${warning}`));
|
|
1319
|
-
app.io.say(
|
|
1320
|
-
C.green(` added ${result.added}, changed ${result.changed} (${result.count} total) → ${catalogCachePath()}`),
|
|
1321
|
-
);
|
|
1322
|
-
app.io.say(C.dim(` ${app.catalog.length} reachable after overlay\n`));
|
|
1323
|
-
}
|
|
1324
|
-
|
|
1325
|
-
/** Stores a key under a provider, 0600, after saying what is about to happen. */
|
|
1326
|
-
async function login(app: App, provider: string, key: string): Promise<void> {
|
|
1327
|
-
const C = app.io.palette;
|
|
1328
|
-
if (!app.catalog.some((m) => m.provider === provider)) {
|
|
1329
|
-
app.io.say(C.red(` unknown provider: ${provider}`));
|
|
1330
|
-
app.io.say(C.dim(` known: ${[...new Set(app.catalog.map((m) => m.provider))].join(", ")}\n`));
|
|
1331
|
-
return;
|
|
1332
|
-
}
|
|
1333
|
-
const next = { ...app.auth, [provider]: { type: "api_key" as const, key } };
|
|
1334
|
-
await writeAuthFile(next);
|
|
1335
|
-
app.auth[provider] = next[provider]!;
|
|
1336
|
-
// The editor masked the paste. Printing `key` here would be the only
|
|
1337
|
-
// remaining way it reaches scrollback.
|
|
1338
|
-
app.io.say(C.green(` stored ${provider} in ${authPath()}`) + C.dim(" (mode 0600)"));
|
|
1339
|
-
const ready = app.catalog.filter((m) => m.provider === provider && app.credentialOf(m).ok);
|
|
1340
|
-
if (ready.length === 0) {
|
|
1341
|
-
app.io.say(C.yellow(" stored, but it still does not resolve -- check the value.\n"));
|
|
1342
|
-
return;
|
|
1343
|
-
}
|
|
1344
|
-
app.io.say(C.dim(` now ready: ${ready.map((m) => m.id).join(", ")}`));
|
|
1345
|
-
await adoptCredential(app, ready);
|
|
1346
|
-
app.io.say(C.dim(" this session can send now.\n"));
|
|
1347
|
-
}
|
|
1348
|
-
|
|
1349
|
-
async function logout(app: App, provider: string | undefined): Promise<void> {
|
|
1350
|
-
const C = app.io.palette;
|
|
1351
|
-
if (provider === undefined) {
|
|
1352
|
-
const names = Object.keys(app.auth);
|
|
1353
|
-
if (names.length === 0) {
|
|
1354
|
-
app.io.say(C.dim(" nothing stored\n"));
|
|
1355
|
-
return;
|
|
1356
|
-
}
|
|
1357
|
-
app.io.say(C.bold("\nlogout"));
|
|
1358
|
-
for (const name of names) app.io.say(` ${name}`);
|
|
1359
|
-
app.io.say(C.dim("\n /logout <provider>\n"));
|
|
1360
|
-
return;
|
|
1361
|
-
}
|
|
1362
|
-
if (app.auth[provider] === undefined) {
|
|
1363
|
-
app.io.say(C.dim(` no stored credential for ${provider}\n`));
|
|
1364
|
-
return;
|
|
1365
|
-
}
|
|
1366
|
-
const next = { ...app.auth };
|
|
1367
|
-
delete next[provider];
|
|
1368
|
-
await writeAuthFile(next);
|
|
1369
|
-
delete app.auth[provider];
|
|
1370
|
-
const resolved = app.credentialOf(app.providerConfig.spec);
|
|
1371
|
-
app.credential = resolved;
|
|
1372
|
-
app.session.provider = makeProvider(app, app.providerConfig, resolved.ok ? resolved.key : "");
|
|
1373
|
-
app.io.say(C.green(` removed ${provider} from ${authPath()}\n`));
|
|
1374
|
-
}
|
|
1375
|
-
|
|
1376
|
-
function providerOAuthOpts(app: App, spec: ModelSpec): { oauth: true } | undefined {
|
|
1377
|
-
if (app.args.apiKey.length > 0) return undefined;
|
|
1378
|
-
return storedIsOAuth(app.auth, spec.provider) ? { oauth: true } : undefined;
|
|
1379
|
-
}
|
|
1380
|
-
|
|
1381
|
-
function makeProvider(app: App, config: ProviderConfig, key: string) {
|
|
1382
|
-
return createProvider(config, key, retryHandler(app.io), { sessionId: providerSessionId(app.session.config.logDir), ...providerOAuthOpts(app, config.spec), effects: app.effects });
|
|
1383
|
-
}
|
|
1384
|
-
|
|
1385
|
-
interface PendingOAuth {
|
|
1386
|
-
id: SubscriptionId;
|
|
1387
|
-
provider: string;
|
|
1388
|
-
state: string;
|
|
1389
|
-
verifier: string;
|
|
1390
|
-
redirectUri: string;
|
|
1391
|
-
exchange: "anthropic" | "openai";
|
|
1392
|
-
server: CallbackServer;
|
|
1393
|
-
done: boolean;
|
|
1394
|
-
tui: Ui | null;
|
|
1395
|
-
abort: AbortController;
|
|
1396
|
-
/** Closes the UI's waiting dialog, if any. */
|
|
1397
|
-
close?: () => void;
|
|
1398
|
-
}
|
|
1399
|
-
|
|
1400
|
-
// Authentication is scoped to its application, independent of the model/session log.
|
|
1401
|
-
const pendingLogins = new WeakMap<App, PendingOAuth>();
|
|
1402
|
-
const pendingDevices = new WeakMap<App, { abort: AbortController; tui: Ui | null; close?: () => void }>();
|
|
1403
|
-
const loginEffects = { fetch: ((input, init) => globalThis.fetch(input, init)) as typeof globalThis.fetch,
|
|
1404
|
-
openBrowser: tryOpenBrowser, callback: startCallbackServer, device: loginGitHubCopilot };
|
|
1405
|
-
|
|
1406
|
-
async function cancelPendingOAuth(app: App): Promise<void> {
|
|
1407
|
-
const pending = pendingLogins.get(app), device = pendingDevices.get(app);
|
|
1408
|
-
pendingLogins.delete(app); pendingDevices.delete(app);
|
|
1409
|
-
device?.abort.abort(); pending?.abort.abort();
|
|
1410
|
-
pending?.close?.(); device?.close?.();
|
|
1411
|
-
if (pending) { pending.done = true; await pending.server.close(); }
|
|
1412
|
-
}
|
|
1413
|
-
|
|
1414
|
-
async function storeOAuth(app: App, provider: string, credential: OAuthCredential): Promise<void> {
|
|
1415
|
-
const C = app.io.palette;
|
|
1416
|
-
await writeAuthFile({ ...app.auth, [provider]: credential });
|
|
1417
|
-
app.auth[provider] = credential;
|
|
1418
|
-
app.io.say(C.green(` stored ${provider} in ${authPath()}`) + C.dim(" (mode 0600)"));
|
|
1419
|
-
const ready = app.catalog.filter((m) => m.provider === provider && app.credentialOf(m).ok);
|
|
1420
|
-
if (ready.length === 0) {
|
|
1421
|
-
app.io.say(C.dim(" stored. No catalog model uses this provider yet.\n"));
|
|
1422
|
-
return;
|
|
1423
|
-
}
|
|
1424
|
-
app.io.say(C.dim(` now ready: ${ready.map((m) => m.id).join(", ")}`));
|
|
1425
|
-
await adoptCredential(app, ready);
|
|
1426
|
-
app.io.say(C.dim(" this session can send now.\n"));
|
|
1427
|
-
}
|
|
1428
|
-
|
|
1429
|
-
async function finishOAuthCode(app: App, pending: PendingOAuth, code: string): Promise<void> {
|
|
1430
|
-
if (pending.done) return;
|
|
1431
|
-
pending.done = true;
|
|
1432
|
-
const effects = pending.tui?.oauthEffects ?? loginEffects;
|
|
1433
|
-
try {
|
|
1434
|
-
const credential =
|
|
1435
|
-
pending.exchange === "anthropic"
|
|
1436
|
-
? await exchangeAnthropicCode({
|
|
1437
|
-
code,
|
|
1438
|
-
state: pending.state,
|
|
1439
|
-
verifier: pending.verifier,
|
|
1440
|
-
redirectUri: pending.redirectUri,
|
|
1441
|
-
fetch: effects.fetch,
|
|
1442
|
-
signal: pending.abort.signal,
|
|
1443
|
-
})
|
|
1444
|
-
: await exchangeOpenAICode({
|
|
1445
|
-
code,
|
|
1446
|
-
verifier: pending.verifier,
|
|
1447
|
-
redirectUri: pending.redirectUri,
|
|
1448
|
-
fetch: effects.fetch,
|
|
1449
|
-
signal: pending.abort.signal,
|
|
1450
|
-
});
|
|
1451
|
-
if (pending.abort.signal.aborted || pendingLogins.get(app) !== pending) return;
|
|
1452
|
-
await storeOAuth(app, pending.provider, credential);
|
|
1453
|
-
} catch (err) {
|
|
1454
|
-
if (pending.abort.signal.aborted || pendingLogins.get(app) !== pending) return;
|
|
1455
|
-
const message = err instanceof Error ? err.message : "token exchange failed";
|
|
1456
|
-
app.io.say(app.io.palette.red(` ${message}`));
|
|
1457
|
-
app.io.say(app.io.palette.dim(" nothing was stored.\n"));
|
|
1458
|
-
} finally {
|
|
1459
|
-
if (pendingLogins.get(app) === pending) {
|
|
1460
|
-
pendingLogins.delete(app);
|
|
1461
|
-
pending.close?.();
|
|
1462
|
-
}
|
|
1463
|
-
await pending.server.close();
|
|
1464
|
-
}
|
|
1465
|
-
}
|
|
1466
|
-
|
|
1467
|
-
async function completeOAuthPaste(app: App, input: string): Promise<void> {
|
|
1468
|
-
const pending = pendingLogins.get(app);
|
|
1469
|
-
if (!pending || pending.done) return;
|
|
1470
|
-
const parsed = parseAuthorizationInput(input);
|
|
1471
|
-
if (parsed.state !== undefined && parsed.state !== pending.state) {
|
|
1472
|
-
app.io.say(app.io.palette.red(" OAuth state mismatch"));
|
|
1473
|
-
await cancelPendingOAuth(app);
|
|
1474
|
-
return;
|
|
1475
|
-
}
|
|
1476
|
-
if (!parsed.code) {
|
|
1477
|
-
app.io.say(app.io.palette.red(" Missing authorization code"));
|
|
1478
|
-
await cancelPendingOAuth(app);
|
|
1479
|
-
return;
|
|
1480
|
-
}
|
|
1481
|
-
await finishOAuthCode(app, pending, parsed.code);
|
|
1482
|
-
}
|
|
1483
|
-
|
|
1484
|
-
function tryOpenBrowser(url: string): void {
|
|
1485
|
-
try {
|
|
1486
|
-
Bun.spawn(["open", url], { stdout: "ignore", stderr: "ignore", stdin: "ignore" });
|
|
1487
|
-
} catch {
|
|
1488
|
-
// Printed URL is the fallback.
|
|
1489
|
-
}
|
|
1490
|
-
}
|
|
1491
|
-
|
|
1492
|
-
async function startSubscriptionLogin(app: App, id: string, tui: Ui | null): Promise<Outcome> {
|
|
1493
|
-
const C = app.io.palette;
|
|
1494
|
-
const sub = subscriptionById(id);
|
|
1495
|
-
if (sub === undefined) return "continue";
|
|
1496
|
-
await cancelPendingOAuth(app);
|
|
1497
|
-
const effects = tui?.oauthEffects ?? loginEffects;
|
|
1498
|
-
app.io.say(C.yellow(` ${SUBSCRIPTION_NOTICE}`));
|
|
1499
|
-
if (sub.id === "github-copilot" || sub.id === "grok") {
|
|
1500
|
-
const pending: { abort: AbortController; tui: Ui | null; close?: () => void } = { abort: new AbortController(), tui };
|
|
1501
|
-
pendingDevices.set(app, pending);
|
|
1502
|
-
const work = async () => { try {
|
|
1503
|
-
const credential = await (sub.id === "grok" ? loginGrok : effects.device)({
|
|
1504
|
-
notify: (message) => {
|
|
1505
|
-
if (pendingDevices.get(app) !== pending) return;
|
|
1506
|
-
if (tui) { pending.close?.(); pending.close = tui.waiting(sub.label, message.split("\n"), () => { void cancelPendingOAuth(app); }); }
|
|
1507
|
-
else app.io.say(` ${message}`);
|
|
1508
|
-
},
|
|
1509
|
-
prompt: async () => "",
|
|
1510
|
-
fetch: effects.fetch,
|
|
1511
|
-
signal: pending.abort.signal,
|
|
1512
|
-
openBrowser: effects.openBrowser,
|
|
1513
|
-
});
|
|
1514
|
-
if (pending.abort.signal.aborted || pendingDevices.get(app) !== pending) return;
|
|
1515
|
-
await storeOAuth(app, sub.provider, credential);
|
|
1516
|
-
} catch (err) {
|
|
1517
|
-
if (pending.abort.signal.aborted || pendingDevices.get(app) !== pending) return;
|
|
1518
|
-
const message = err instanceof Error ? err.message : "login failed";
|
|
1519
|
-
app.io.say(C.red(` ${message}\n`));
|
|
1520
|
-
} finally {
|
|
1521
|
-
if (pendingDevices.get(app) === pending) {
|
|
1522
|
-
pendingDevices.delete(app);
|
|
1523
|
-
pending.close?.();
|
|
1524
|
-
}
|
|
1525
|
-
} };
|
|
1526
|
-
if (tui) { pending.close = tui.waiting(sub.label, ["Requesting a device code…"], () => { void cancelPendingOAuth(app); }); tui.runExtensionAction(work); return "continue"; }
|
|
1527
|
-
await work();
|
|
1528
|
-
return "continue";
|
|
1529
|
-
}
|
|
1530
|
-
const pkce = await generatePKCE();
|
|
1531
|
-
const state = generateState();
|
|
1532
|
-
const anthropic = sub.id === "claude-pro";
|
|
1533
|
-
const url = anthropic
|
|
1534
|
-
? anthropicAuthorizeUrl({ challenge: pkce.challenge, state })
|
|
1535
|
-
: openaiCodexAuthorizeUrl({ challenge: pkce.challenge, state });
|
|
1536
|
-
let server: CallbackServer;
|
|
1537
|
-
try {
|
|
1538
|
-
server = await effects.callback({
|
|
1539
|
-
expectedState: state,
|
|
1540
|
-
host: "127.0.0.1",
|
|
1541
|
-
port: anthropic ? ANTHROPIC_CALLBACK_PORT : OPENAI_CALLBACK_PORT,
|
|
1542
|
-
path: anthropic ? ANTHROPIC_CALLBACK_PATH : OPENAI_CALLBACK_PATH,
|
|
1543
|
-
});
|
|
1544
|
-
} catch (err) {
|
|
1545
|
-
const message = err instanceof Error ? err.message : "could not bind callback port";
|
|
1546
|
-
app.io.say(C.yellow(` ${message}`));
|
|
1547
|
-
app.io.say(C.dim(" paste the redirect URL after signing in.\n"));
|
|
1548
|
-
// Still allow paste without a server: we just won't get a browser callback.
|
|
1549
|
-
server = {
|
|
1550
|
-
host: "127.0.0.1",
|
|
1551
|
-
port: 0,
|
|
1552
|
-
waitForCode: () => new Promise(() => {}),
|
|
1553
|
-
close: async () => {},
|
|
1554
|
-
};
|
|
1555
|
-
}
|
|
1556
|
-
if (!tui) {
|
|
1557
|
-
app.io.say(` ${url}`);
|
|
1558
|
-
app.io.say(C.dim(" complete login in the browser, or paste the redirect URL. It is not shown."));
|
|
1559
|
-
}
|
|
1560
|
-
effects.openBrowser(url);
|
|
1561
|
-
const pending: PendingOAuth = {
|
|
1562
|
-
id: sub.id,
|
|
1563
|
-
provider: sub.provider,
|
|
1564
|
-
state,
|
|
1565
|
-
verifier: pkce.verifier,
|
|
1566
|
-
redirectUri: anthropic ? ANTHROPIC_REDIRECT_URI : OPENAI_REDIRECT_URI,
|
|
1567
|
-
exchange: anthropic ? "anthropic" : "openai",
|
|
1568
|
-
server,
|
|
1569
|
-
done: false,
|
|
1570
|
-
tui,
|
|
1571
|
-
abort: new AbortController(),
|
|
1572
|
-
};
|
|
1573
|
-
pendingLogins.set(app, pending);
|
|
1574
|
-
void pending.server.waitForCode().then(async (got) => {
|
|
1575
|
-
if (!got.ok || pending.done) return;
|
|
1576
|
-
await finishOAuthCode(app, pending, got.code);
|
|
1577
|
-
});
|
|
1578
|
-
if (tui) {
|
|
1579
|
-
// The URL is shown (it is not a secret); the pasted redirect is, so it goes through a masked field.
|
|
1580
|
-
pending.close = tui.waiting(sub.label, ["Finish signing in in your browser.", "", url, "", "Or paste the redirect URL here if the browser cannot reach this machine."],
|
|
1581
|
-
() => { void cancelPendingOAuth(app); }, "Redirect URL", value => tui.runExtensionAction(() => completeOAuthPaste(app, value)));
|
|
1582
|
-
return "continue";
|
|
1583
|
-
}
|
|
1584
|
-
return { login: OAUTH_PASTE };
|
|
1585
|
-
}
|
|
1586
|
-
|
|
1587
|
-
/**
|
|
1588
|
-
* Make the just-stored key the one the next prompt uses, without a restart.
|
|
1589
|
-
*
|
|
1590
|
-
* Same model: swap the adapter in place (no thinking blocks to isolate).
|
|
1591
|
-
* Placeholder session that never sent: swap onto an unlocked model in place
|
|
1592
|
-
* so the first prompt is the first event, not a cut of an empty node.
|
|
1593
|
-
* Already talking to a different live model: /model is the cut.
|
|
1594
|
-
*/
|
|
1595
|
-
async function adoptCredential(app: App, ready: readonly ModelSpec[]): Promise<void> {
|
|
1596
|
-
const C = app.io.palette;
|
|
1597
|
-
const currentGot = app.credentialOf(app.providerConfig.spec);
|
|
1598
|
-
if (currentGot.ok) {
|
|
1599
|
-
app.session.provider = makeProvider(app, app.providerConfig, currentGot.key);
|
|
1600
|
-
app.credential = currentGot;
|
|
1601
|
-
return;
|
|
1602
|
-
}
|
|
1603
|
-
const spec = ready[0];
|
|
1604
|
-
if (spec === undefined) return;
|
|
1605
|
-
const got = app.credentialOf(spec);
|
|
1606
|
-
if (!got.ok) return;
|
|
1607
|
-
if (app.credential.ok || app.session.state.handoff !== null || app.session.state.events.some((e) => e.t === "assistant")) {
|
|
1608
|
-
await switchToModel(app, spec, undefined, "session");
|
|
1609
|
-
return;
|
|
1610
|
-
}
|
|
1611
|
-
const nextConfig: ProviderConfig = {
|
|
1612
|
-
spec,
|
|
1613
|
-
maxTokens: outputTokenBudget(spec),
|
|
1614
|
-
effort: clampThinkingLevel(spec, app.ui?.settings.modelThinkingLevels?.[modelKey(spec)] ?? app.providerConfig.effort),
|
|
1615
|
-
thinkingDisplay: app.providerConfig.thinkingDisplay,
|
|
1616
|
-
};
|
|
1617
|
-
app.session.provider = makeProvider(app, nextConfig, got.key);
|
|
1618
|
-
app.providerConfig = nextConfig;
|
|
1619
|
-
app.credential = got;
|
|
1620
|
-
const cursor = await readCursor(app.logDir);
|
|
1621
|
-
if (cursor !== null) {
|
|
1622
|
-
await writeCursor(app.logDir, {
|
|
1623
|
-
...cursor,
|
|
1624
|
-
model: spec.id,
|
|
1625
|
-
provider: spec.provider,
|
|
1626
|
-
thinking: nextConfig.effort,
|
|
1627
|
-
updated_at: app.session.clock.now(),
|
|
1628
|
-
});
|
|
1629
|
-
}
|
|
1630
|
-
app.io.say(C.green(` now ${spec.name}`) + C.dim(` ${spec.id}`));
|
|
1631
|
-
}
|
|
1632
|
-
|
|
1633
|
-
async function switchByPattern(app: App, pattern: string, origin: PreferenceOrigin = "user"): Promise<void> {
|
|
1634
|
-
const C = app.io.palette;
|
|
1635
|
-
const found = scopedModels([pattern], app.catalog, true), selected = found.entries[0];
|
|
1636
|
-
if (!selected) {
|
|
1637
|
-
const diagnostic = findModelByPattern(pattern, app.catalog);
|
|
1638
|
-
app.io.say(C.red(` ${diagnostic.ok ? found.diagnostics.join("; ") : diagnostic.reason}`));
|
|
1639
|
-
return;
|
|
1640
|
-
}
|
|
1641
|
-
await switchToModel(app, selected.model, selected.thinking, origin);
|
|
1642
|
-
}
|
|
1643
|
-
|
|
1644
|
-
export async function switchToModel(app: App, spec: ModelSpec, thinking?: Effort, origin: PreferenceOrigin = "user"): Promise<void> {
|
|
1645
|
-
const C = app.io.palette;
|
|
1646
|
-
const got = app.credentialOf(spec);
|
|
1647
|
-
if (!got.ok) {
|
|
1648
|
-
app.io.say(C.red(`\n ${got.reason}\n`));
|
|
1649
|
-
if (!got.reason.includes("/provider")) {
|
|
1650
|
-
app.io.say(C.dim(` ${describeMissingCredential(spec.provider)}\n`));
|
|
1651
|
-
}
|
|
1652
|
-
return;
|
|
1653
|
-
}
|
|
1654
|
-
if (spec.id === app.providerConfig.spec.id && spec.provider === app.providerConfig.spec.provider) {
|
|
1655
|
-
if (thinking) await applyThinking(app, clampThinkingLevel(spec, thinking), null, "session");
|
|
1656
|
-
if (origin === "user") await rememberModelSelection(app, spec, thinking);
|
|
1657
|
-
app.io.say(C.dim(` already on ${spec.id}`));
|
|
1658
|
-
return;
|
|
1659
|
-
}
|
|
1660
|
-
const nextConfig: ProviderConfig = {
|
|
1661
|
-
spec,
|
|
1662
|
-
maxTokens: outputTokenBudget(spec),
|
|
1663
|
-
effort: clampThinkingLevel(spec, thinking ?? app.ui?.settings.modelThinkingLevels?.[modelKey(spec)] ?? app.providerConfig.effort),
|
|
1664
|
-
thinkingDisplay: app.providerConfig.thinkingDisplay,
|
|
1665
|
-
};
|
|
1666
|
-
const next = makeProvider(app, nextConfig, got.key);
|
|
1667
|
-
try {
|
|
1668
|
-
if (await app.session.switchModel(next)) app.io.say(C.dim(" new segment: the next model starts from a handoff, not this model's thinking"));
|
|
1669
|
-
} catch (err) {
|
|
1670
|
-
app.io.say(C.red(` cannot switch: ${describeError(err)}`));
|
|
1671
|
-
return;
|
|
1672
|
-
}
|
|
1673
|
-
app.providerConfig = nextConfig;
|
|
1674
|
-
app.credential = got;
|
|
1675
|
-
app.io.say(C.green(` now ${spec.name}`) + C.dim(` ${spec.id}`));
|
|
1676
|
-
if (origin === "user") await rememberModelSelection(app, spec, thinking);
|
|
1677
|
-
}
|
|
1678
|
-
|
|
1679
|
-
/** N.8 must accept the checkpoint before a user choice can own the next startup. */
|
|
1680
|
-
async function rememberModelSelection(app: App, spec: ModelSpec, explicitThinking: Effort | undefined): Promise<void> {
|
|
1681
|
-
if (!app.ui) return;
|
|
1682
|
-
const effort = app.providerConfig.effort;
|
|
1683
|
-
const patch = modelPreferencePatch(spec, app.ui.settings.recentModels ?? [], effort);
|
|
1684
|
-
await commitUserSettings(app, explicitThinking === undefined ? patch : { ...patch, ...thinkingPreferencePatch(spec, effort) });
|
|
1685
|
-
}
|
|
1686
|
-
|
|
1687
|
-
function sessionDirOf(app: App): string | undefined {
|
|
1688
|
-
return sessionDirFor(app.args, app.workspace);
|
|
1689
|
-
}
|
|
1690
|
-
|
|
1691
|
-
async function openNewSession(app: App, tui?: Ui): Promise<void> {
|
|
1692
|
-
const C = app.io.palette;
|
|
1693
|
-
const created = await createSession(app.workspace, undefined, sessionDirOf(app));
|
|
1694
|
-
const switched = await adoptLogDir(app, created.path);
|
|
1695
|
-
if (!switched) {
|
|
1696
|
-
app.io.say(C.red(` cannot start a new session`));
|
|
1697
|
-
return;
|
|
1698
|
-
}
|
|
1699
|
-
if (tui) { tui.sessionName = null; await tui.syncTranscript(); }
|
|
1700
|
-
app.io.say(C.green(` new session ${created.id}`) + C.dim(` ${created.path}`));
|
|
1701
|
-
}
|
|
1702
|
-
|
|
1703
|
-
async function resumeSession(app: App, idOrPath: string, tui?: Ui): Promise<void> {
|
|
1704
|
-
const C = app.io.palette;
|
|
1705
|
-
const found = await findSessionByPrefix(app.workspace, idOrPath, sessionDirOf(app));
|
|
1706
|
-
if (!found.ok) {
|
|
1707
|
-
app.io.say(C.red(` ${found.reason}. /resume lists them.`));
|
|
1708
|
-
return;
|
|
1709
|
-
}
|
|
1710
|
-
if (found.session.path === app.logDir) {
|
|
1711
|
-
app.io.say(C.dim(" already in this session"));
|
|
1712
|
-
return;
|
|
1713
|
-
}
|
|
1714
|
-
const switched = await adoptLogDir(app, found.session.path);
|
|
1715
|
-
if (!switched) {
|
|
1716
|
-
app.io.say(C.red(` cannot resume`));
|
|
1717
|
-
return;
|
|
1718
|
-
}
|
|
1719
|
-
if (tui) { tui.sessionName = found.session.name ?? null; await tui.syncTranscript(); }
|
|
1720
|
-
app.io.say(C.green(` resumed ${found.session.name ?? found.session.id}`) + C.dim(` ${found.session.path}`));
|
|
1721
|
-
}
|
|
1722
|
-
|
|
1723
|
-
async function adoptLogDir(app: App, logDir: string): Promise<boolean> {
|
|
1724
|
-
try {
|
|
1725
|
-
await switchAppSession(app, logDir, (current, target) => openSessionFor({ ...current, logDir: target, fallback: current.providerConfig, interactive: true }));
|
|
1726
|
-
} catch (err) {
|
|
1727
|
-
app.io.say(app.io.palette.red(` ${describeError(err)}`));
|
|
1728
|
-
return false;
|
|
1729
|
-
}
|
|
1730
|
-
return true;
|
|
1731
|
-
}
|
|
1732
|
-
|
|
1733
|
-
async function transcriptEvents(app: App): Promise<LogEvent[]> {
|
|
1734
|
-
const history = await readNodeHistory(app.logDir, app.session.state.node);
|
|
1735
|
-
if (!history.ok) throw new Error(history.error);
|
|
1736
|
-
return history.nodes.flatMap((node) => node.events);
|
|
1737
|
-
}
|
|
1738
|
-
|
|
1739
|
-
async function exportSession(app: App, file?: string): Promise<void> {
|
|
1740
|
-
const C = app.io.palette;
|
|
1741
|
-
const events = await transcriptEvents(app);
|
|
1742
|
-
const defaultPath = join(app.workspace, `${basename(app.logDir)}.html`);
|
|
1743
|
-
const dest = file !== undefined && file.length > 0 ? file : defaultPath;
|
|
1744
|
-
const abs = dest.startsWith("/") ? dest : join(app.workspace, dest);
|
|
1745
|
-
const html = abs.toLowerCase().endsWith(".jsonl") === false;
|
|
1746
|
-
try {
|
|
1747
|
-
if (html) await writeFile(abs, toHtml(events, {
|
|
1748
|
-
title: basename(app.logDir),
|
|
1749
|
-
exportedAt: app.session.clock.now(),
|
|
1750
|
-
sessionId: basename(app.logDir),
|
|
1751
|
-
}), "utf8");
|
|
1752
|
-
else await exportSessionArchive(app.logDir, abs, app.blobDir);
|
|
1753
|
-
} catch (err) {
|
|
1754
|
-
app.io.say(C.red(` cannot write ${abs}: ${describeError(err)}`));
|
|
1755
|
-
return;
|
|
1756
|
-
}
|
|
1757
|
-
app.io.say(C.green(` exported ${abs}`));
|
|
1758
|
-
}
|
|
1759
|
-
|
|
1760
|
-
async function copyLastReply(app: App, tui?: Ui): Promise<void> {
|
|
1761
|
-
const C = app.io.palette;
|
|
1762
|
-
const text = lastAssistantText(await transcriptEvents(app));
|
|
1763
|
-
if (text === null) {
|
|
1764
|
-
app.io.say(C.dim(" no assistant reply to copy yet"));
|
|
1765
|
-
return;
|
|
1766
|
-
}
|
|
1767
|
-
const copied = await (tui?.clipboardWriter ?? writeClipboard)(text);
|
|
1768
|
-
if (copied) {
|
|
1769
|
-
app.io.say(C.green(" copied last assistant reply"));
|
|
1770
|
-
return;
|
|
1771
|
-
}
|
|
1772
|
-
app.io.say(C.yellow(" Clipboard unavailable. Printing the reply instead:"));
|
|
1773
|
-
app.io.say(text);
|
|
1774
|
-
}
|
|
1775
|
-
|
|
1776
|
-
async function forkSession(app: App, arg?: string, tui?: Ui): Promise<void> {
|
|
1777
|
-
const C = app.io.palette;
|
|
1778
|
-
const points = await app.session.userMessages();
|
|
1779
|
-
if (arg === undefined) {
|
|
1780
|
-
app.io.say(C.bold("\nfork from a user turn") + C.dim(" (newest last)"));
|
|
1781
|
-
if (points.length === 0) {
|
|
1782
|
-
app.io.say(C.dim(" nothing yet -- send a prompt first"));
|
|
1783
|
-
app.io.say("");
|
|
1784
|
-
return;
|
|
1785
|
-
}
|
|
1786
|
-
points.forEach((p, i) => {
|
|
1787
|
-
const n = String(i + 1).padStart(2);
|
|
1788
|
-
app.io.say(` ${C.bold(n)} ${firstLine(p.text, 60)}`);
|
|
1789
|
-
});
|
|
1790
|
-
app.io.say(C.dim("\n /fork <n> resumes before that user message and restores its draft\n"));
|
|
1791
|
-
return;
|
|
1792
|
-
}
|
|
1793
|
-
const i = Number(arg);
|
|
1794
|
-
const point = Number.isInteger(i) ? points[i - 1] : undefined;
|
|
1795
|
-
if (!point) {
|
|
1796
|
-
app.io.say(C.red(` no such turn: ${arg}. /fork lists them.`));
|
|
1797
|
-
return;
|
|
1798
|
-
}
|
|
1799
|
-
if (await spawnForkedSession(app, point.id, `forked before ${firstLine(point.text, 40)}`, "before")) {
|
|
1800
|
-
await tui?.syncTranscript();
|
|
1801
|
-
tui?.restoreDraft(point.text, point.images ?? []);
|
|
1802
|
-
tui?.redraw();
|
|
1803
|
-
}
|
|
1804
|
-
}
|
|
1805
|
-
|
|
1806
|
-
async function cloneSession(app: App): Promise<void> {
|
|
1807
|
-
await spawnForkedSession(app, undefined, "cloned");
|
|
1808
|
-
}
|
|
1809
|
-
|
|
1810
|
-
async function spawnForkedSession(app: App, untilEvent: string | undefined, label: string, position: "at" | "before" = "at"): Promise<boolean> {
|
|
1811
|
-
const C = app.io.palette;
|
|
1812
|
-
const created = await createSession(app.workspace, undefined, sessionDirOf(app));
|
|
1813
|
-
try {
|
|
1814
|
-
await app.session.fork(created.path, untilEvent, position);
|
|
1815
|
-
} catch (err) {
|
|
1816
|
-
app.io.say(C.red(` cannot fork: ${describeError(err)}`));
|
|
1817
|
-
return false;
|
|
1818
|
-
}
|
|
1819
|
-
const switched = await adoptLogDir(app, created.path);
|
|
1820
|
-
if (!switched) return false;
|
|
1821
|
-
app.io.say(C.green(` ${label} → ${created.id}`) + C.dim(` ${created.path}`));
|
|
1822
|
-
return true;
|
|
1823
|
-
}
|
|
1824
|
-
|
|
1825
|
-
async function trustProject(app: App, target: TrustChoice, tui?: Ui): Promise<void> {
|
|
1826
|
-
const C = app.io.palette;
|
|
1827
|
-
const dir = target === "parent" ? dirname(app.workspace) : app.workspace;
|
|
1828
|
-
const trusted = await applyTrustChoice(app.workspace, target);
|
|
1829
|
-
app.projectTrusted = trusted;
|
|
1830
|
-
app.io.say(
|
|
1831
|
-
C.green(` ${trusted ? "trusted" : "will not trust"} ${dir}`) +
|
|
1832
|
-
C.dim(target === "once" ? " for this session" : ` saved in ${trustPath()}`),
|
|
1833
|
-
);
|
|
1834
|
-
app.io.say(
|
|
1835
|
-
C.dim(" Project resources reloaded. Tool permissions remain separate."),
|
|
1836
|
-
);
|
|
1837
|
-
await reloadAppResources(app);
|
|
1838
|
-
await tui?.refreshResources();
|
|
1839
|
-
}
|
|
1840
|
-
|
|
1841
|
-
async function nameSession(app: App, name: string, tui?: Ui): Promise<void> {
|
|
1842
|
-
const C = app.io.palette;
|
|
1843
|
-
if (name.length === 0) {
|
|
1844
|
-
app.io.say(C.dim(" /name <name>"));
|
|
1845
|
-
return;
|
|
1846
|
-
}
|
|
1847
|
-
// Name lives on the cursor. sessions.ts has no setter -- writeCursor is
|
|
1848
|
-
// the store function that actually holds it, and it keeps a name across
|
|
1849
|
-
// the loop's own cursor rewrites.
|
|
1850
|
-
const cursor = await readCursor(app.logDir);
|
|
1851
|
-
if (cursor === null) {
|
|
1852
|
-
app.io.say(C.red(" no cursor to name yet"));
|
|
1853
|
-
return;
|
|
1854
|
-
}
|
|
1855
|
-
await writeCursor(app.logDir, { ...cursor, name });
|
|
1856
|
-
if (tui) tui.sessionName = name;
|
|
1857
|
-
app.io.say(C.green(` named ${name}`));
|
|
1858
|
-
}
|
|
1859
|
-
|
|
1860
|
-
/** Runs a prompt. Returns false if the turn failed, having already said why. */
|
|
1861
|
-
async function run(app: App, prompt: string, signal?: AbortSignal, tui?: Ui, images: readonly ImageRef[] = [], skill?: Skill, options?: RequestOptions): Promise<boolean> {
|
|
1862
|
-
if (app.extensions && !initializedExtensions.has(app.extensions)) await bindExtensions(app, tui);
|
|
1863
|
-
for (const result of await app.extensions?.emit("input", { text: prompt, images, source: "interactive" }) ?? []) {
|
|
1864
|
-
if (result["action"] === "handled") return true;
|
|
1865
|
-
if (result["action"] === "transform" && typeof result["text"] === "string") prompt = result["text"];
|
|
1866
|
-
}
|
|
1867
|
-
if (prompt.startsWith("!")) {
|
|
1868
|
-
const excluded = prompt.startsWith("!!");
|
|
1869
|
-
if (tui) tui.beginBash(prompt.slice(excluded ? 2 : 1));
|
|
1870
|
-
const result = await app.session.runUserBash(prompt.slice(excluded ? 2 : 1), excluded, signal,
|
|
1871
|
-
tui ? chunk => tui.pushBash(chunk) : undefined);
|
|
1872
|
-
tui?.observeEvent?.({ kind: "done", outcome: result.outcome });
|
|
1873
|
-
if (tui) { await tui.syncTranscript(); tui.commitStream(); }
|
|
1874
|
-
else app.io.say(`$ ${result.command}\n${result.content}`);
|
|
1875
|
-
return true;
|
|
1876
|
-
}
|
|
1877
|
-
if (!app.credential.ok) {
|
|
1878
|
-
// Must not call session.send: that records a user event before the
|
|
1879
|
-
// request, and a refused prompt is not a turn.
|
|
1880
|
-
const C = app.io.palette;
|
|
1881
|
-
app.io.say(C.yellow(" no credential yet -- this prompt was not sent."));
|
|
1882
|
-
app.io.say(C.dim(` ${app.credential.reason}`));
|
|
1883
|
-
app.io.say(C.dim(" export an API key, or /provider <provider>.\n"));
|
|
1884
|
-
tui?.restoreDraft(prompt, images);
|
|
1885
|
-
return false;
|
|
1886
|
-
}
|
|
1887
|
-
|
|
1888
|
-
const fresh = await ensureFreshCredential(
|
|
1889
|
-
app.providerConfig.spec,
|
|
1890
|
-
app.auth,
|
|
1891
|
-
app.args.apiKey.length > 0 ? app.args.apiKey : undefined,
|
|
1892
|
-
app.effects,
|
|
1893
|
-
{
|
|
1894
|
-
fetch: globalThis.fetch,
|
|
1895
|
-
now: () => Date.now(),
|
|
1896
|
-
persist: async (next) => {
|
|
1897
|
-
const entry = next[app.providerConfig.spec.provider];
|
|
1898
|
-
if (entry !== undefined) app.auth[app.providerConfig.spec.provider] = entry;
|
|
1899
|
-
await writeAuthFile(app.auth);
|
|
1900
|
-
},
|
|
1901
|
-
},
|
|
1902
|
-
);
|
|
1903
|
-
if (!fresh.ok) {
|
|
1904
|
-
const C = app.io.palette;
|
|
1905
|
-
app.io.say(C.red(` ${fresh.reason}`));
|
|
1906
|
-
app.io.say(C.dim(" this prompt was not sent.\n"));
|
|
1907
|
-
tui?.restoreDraft(prompt, images);
|
|
1908
|
-
return false;
|
|
1909
|
-
}
|
|
1910
|
-
if (!app.credential.ok || fresh.key !== app.credential.key) {
|
|
1911
|
-
app.credential = fresh;
|
|
1912
|
-
app.session.provider = makeProvider(app, app.providerConfig, fresh.key);
|
|
1913
|
-
}
|
|
1914
|
-
|
|
1915
|
-
let input: UserInput;
|
|
1916
|
-
try {
|
|
1917
|
-
const needsIndex = fileReferences(prompt).length > 0;
|
|
1918
|
-
const index: FileIndex = needsIndex
|
|
1919
|
-
? await fileIndexFor(app)
|
|
1920
|
-
: { paths: [], truncated: false };
|
|
1921
|
-
const expanded = await expandInput(prompt, {
|
|
1922
|
-
workspace: app.workspace,
|
|
1923
|
-
index,
|
|
1924
|
-
say: (text) => app.io.say(text),
|
|
1925
|
-
attachImage: path => attachImageFile(path, app.blobDir, app.ui?.settings.images?.autoResize ?? true),
|
|
1926
|
-
});
|
|
1927
|
-
if (!expanded.send && !images.length) return true;
|
|
1928
|
-
const attached = [...images, ...expanded.images ?? []];
|
|
1929
|
-
input = attached.length ? { text: expanded.message, images: attached } : expanded.message;
|
|
1930
|
-
app.session.config.imageOptions = app.ui?.settings.images ?? {};
|
|
1931
|
-
if (attached.length && (app.ui?.settings.images?.blockImages || !app.providerConfig.spec.input?.includes("image"))) {
|
|
1932
|
-
app.io.say(" Images are kept in this session; this request uses text placeholders because " +
|
|
1933
|
-
(app.ui?.settings.images?.blockImages ? "image transmission is disabled." : "the selected model does not support image input."));
|
|
1934
|
-
}
|
|
1935
|
-
} catch (error) {
|
|
1936
|
-
app.io.say(" Attachment failed: " + describeError(error));
|
|
1937
|
-
tui?.restoreDraft(prompt, images);
|
|
1938
|
-
return false;
|
|
1939
|
-
}
|
|
1940
|
-
try {
|
|
1941
|
-
await runTurn(app, input, signal, tui, skill, options);
|
|
1942
|
-
return true;
|
|
1943
|
-
} catch (error) {
|
|
1944
|
-
tui?.discardStream();
|
|
1945
|
-
// The session survives. Everything up to this turn is on disk, the next
|
|
1946
|
-
// prompt starts from it, and killing the process over one failed request
|
|
1947
|
-
// would throw away work that is still perfectly good.
|
|
1948
|
-
const C = app.io.palette;
|
|
1949
|
-
app.io.say(C.red(`\n request failed: ${describeError(error)}`));
|
|
1950
|
-
app.io.say(C.dim(" nothing was lost -- the log is intact. Try again, or /usage.\n"));
|
|
1951
|
-
return false;
|
|
1952
|
-
}
|
|
1953
|
-
}
|
|
1954
|
-
|
|
1955
|
-
export type { TurnSurface };
|
|
1956
|
-
|
|
1957
|
-
export async function runTurn(
|
|
1958
|
-
app: Pick<App, "session" | "io"> & { readonly ui?: App["ui"]; readonly args?: App["args"]; readonly extensions?: App["extensions"] },
|
|
1959
|
-
prompt: UserInput,
|
|
1960
|
-
signal?: AbortSignal,
|
|
1961
|
-
tui?: TurnSurface,
|
|
1962
|
-
skill?: Skill,
|
|
1963
|
-
options?: RequestOptions,
|
|
1964
|
-
): Promise<void> {
|
|
1965
|
-
// A pipe cannot unwrite bytes. Non-tty / -p therefore does not stream:
|
|
1966
|
-
// deltas and discard are ignored, and the final block prints once.
|
|
1967
|
-
let live = false;
|
|
1968
|
-
let skipFinal = false;
|
|
1969
|
-
const json = app.args?.mode === "json";
|
|
1970
|
-
const checkedOutput = app.session.config.validateOutput !== undefined;
|
|
1971
|
-
let finalText: string[] = [];
|
|
1972
|
-
let completed = false;
|
|
1973
|
-
let failure: string | undefined;
|
|
1974
|
-
const pull = tui?.pullQueued?.bind(tui);
|
|
1975
|
-
const registry = app.extensions;
|
|
1976
|
-
const before = await registry?.emit("before_agent_start", { prompt: userInputData(prompt).text, systemPrompt: app.session.config.frozen.system }) ?? [];
|
|
1977
|
-
for (const result of before) {
|
|
1978
|
-
if (typeof result["systemPrompt"] === "string" && result["systemPrompt"] !== app.session.config.frozen.system)
|
|
1979
|
-
await app.session.reloadResources({ ...app.session.config.frozen, system: result["systemPrompt"] });
|
|
1980
|
-
}
|
|
1981
|
-
await registry?.emit("agent_start");
|
|
1982
|
-
await registry?.emit("turn_start", { turnIndex: 0 });
|
|
1983
|
-
try { for await (const e of skill ? app.session.sendSkill(skill, prompt, signal) : sessionControl(app.session).send(prompt, signal, pull, options)) {
|
|
1984
|
-
// The log retains every response, but a script's final answer must be the
|
|
1985
|
-
// accepted last response. A rejected JSON document cannot be unwritten.
|
|
1986
|
-
if (e.kind === "turn_start" || e.kind === "discard") finalText = [];
|
|
1987
|
-
if (e.kind === "assistant_text") finalText.push(e.text ?? "");
|
|
1988
|
-
if (e.kind === "done") {
|
|
1989
|
-
completed = e.outcome?.status === "completed";
|
|
1990
|
-
if (checkedOutput && !completed) failure = e.reason ?? "output schema validation did not complete";
|
|
1991
|
-
}
|
|
1992
|
-
tui?.observeEvent?.(e);
|
|
1993
|
-
if (e.kind === "text_delta" || e.kind === "thinking_delta") await registry?.emit("message_update", { assistantMessageEvent: { type: e.kind, delta: e.text } });
|
|
1994
|
-
if (e.kind === "assistant_text") await registry?.emit("message_end", { message: { role: "assistant", content: [{ type: "text", text: e.text }] } });
|
|
1995
|
-
if (e.kind === "done") await registry?.emit("turn_end", { reason: e.reason });
|
|
1996
|
-
if (json) {
|
|
1997
|
-
const payload = toJsonEvent(e, app.session.clock.now());
|
|
1998
|
-
if (payload !== null) process.stdout.write(`${JSON.stringify(payload)}\n`);
|
|
1999
|
-
continue;
|
|
2000
|
-
}
|
|
2001
|
-
if (checkedOutput && !tui && e.kind === "assistant_text") continue;
|
|
2002
|
-
if (e.kind === "progress") { if (e.progress) tui?.pushProgress?.(e.progress); continue; }
|
|
2003
|
-
if (tui?.syncTranscript) {
|
|
2004
|
-
if (e.kind === "usage" && e.ok === false && e.prefix && app.ui?.settings.showCacheMissNotices === true)
|
|
2005
|
-
app.io.say(app.io.palette.yellow(` Cache: ${e.prefix}`));
|
|
2006
|
-
if (e.kind === "text_delta" || e.kind === "thinking_delta") {
|
|
2007
|
-
tui.pushDelta(e.kind === "text_delta" ? "text" : "thinking", e.text ?? "");
|
|
2008
|
-
} else {
|
|
2009
|
-
if (e.kind === "discard") tui.discardStream();
|
|
2010
|
-
await tui.syncTranscript();
|
|
2011
|
-
if (["assistant_text", "thinking", "tool_call", "tool_result", "done", "cut"].includes(e.kind)) tui.commitStream();
|
|
2012
|
-
if (e.kind === "stop_and_ask") {
|
|
2013
|
-
for (const line of formatEvent(e, app.io.palette, Math.max(20, (process.stdout.columns ?? 80) - 1), {}, app.ui?.style)) app.io.say(line);
|
|
2014
|
-
}
|
|
2015
|
-
}
|
|
2016
|
-
continue;
|
|
2017
|
-
}
|
|
2018
|
-
if (e.kind === "text_delta" || e.kind === "thinking_delta") {
|
|
2019
|
-
if (tui !== undefined) {
|
|
2020
|
-
live = true;
|
|
2021
|
-
tui.pushDelta(e.kind === "thinking_delta" ? "thinking" : "text", e.text ?? "");
|
|
2022
|
-
}
|
|
2023
|
-
continue;
|
|
2024
|
-
}
|
|
2025
|
-
if (e.kind === "discard") {
|
|
2026
|
-
live = false;
|
|
2027
|
-
skipFinal = false;
|
|
2028
|
-
tui?.discardStream();
|
|
2029
|
-
continue;
|
|
2030
|
-
}
|
|
2031
|
-
// Per-round, not per-turn: a tool-using turn streams again after each
|
|
2032
|
-
// tool_result. A latch that stays set would skip commitStream on later
|
|
2033
|
-
// rounds and dropLive would then erase the only copy.
|
|
2034
|
-
if (live && tui !== undefined) {
|
|
2035
|
-
tui.commitStream();
|
|
2036
|
-
live = false;
|
|
2037
|
-
skipFinal = true;
|
|
2038
|
-
}
|
|
2039
|
-
if (skipFinal && (e.kind === "assistant_text" || e.kind === "thinking")) continue;
|
|
2040
|
-
if (skipFinal && e.kind !== "usage") skipFinal = false;
|
|
2041
|
-
if (e.kind === "assistant_text" && app.io.answer !== undefined) {
|
|
2042
|
-
app.io.answer(e.text ?? "");
|
|
2043
|
-
continue;
|
|
2044
|
-
}
|
|
2045
|
-
for (const line of formatEvent(
|
|
2046
|
-
e,
|
|
2047
|
-
app.io.palette,
|
|
2048
|
-
Math.max(20, (process.stdout.columns ?? 80) - 1),
|
|
2049
|
-
{},
|
|
2050
|
-
app.ui?.style,
|
|
2051
|
-
)) {
|
|
2052
|
-
app.io.say(line);
|
|
2053
|
-
}
|
|
2054
|
-
} } finally { await registry?.emit("agent_end", { messages: structuredClone(app.session.state.events) }); }
|
|
2055
|
-
// feat-external-effects-summary: one line, in every mode (print and json send it to stderr).
|
|
2056
|
-
const host = hostExternalOptions();
|
|
2057
|
-
const outside = formatExternalEffects(skill ? requestExternalEffects(app.session, host) : sessionControl(app.session).lastExternalEffects, host);
|
|
2058
|
-
if (outside) { if (tui?.notice) tui.notice(outside, "warning"); else app.io.say(app.io.palette.yellow(` ${outside}`)); }
|
|
2059
|
-
if (failure) throw Error(failure);
|
|
2060
|
-
if (completed) {
|
|
2061
|
-
const text = finalText.join("\n");
|
|
2062
|
-
if (checkedOutput && !tui && !json) {
|
|
2063
|
-
if (app.io.answer) app.io.answer(text); else app.io.say(text);
|
|
2064
|
-
}
|
|
2065
|
-
if (app.args?.outputLastMessage) await writeFile(resolve(app.args.outputLastMessage), text.endsWith("\n") ? text : text + "\n", "utf8");
|
|
2066
|
-
}
|
|
2067
|
-
}
|
|
2068
|
-
|
|
2069
|
-
// ---------------------------------------------------------------------------
|
|
2070
|
-
// TUI
|
|
2071
|
-
// ---------------------------------------------------------------------------
|