opencode-dux 1.0.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/LICENSE +21 -0
- package/README.md +452 -0
- package/dist/agents/descriptions.d.ts +6 -0
- package/dist/agents/designer.d.ts +2 -0
- package/dist/agents/explorer.d.ts +2 -0
- package/dist/agents/fixer.d.ts +2 -0
- package/dist/agents/index.d.ts +22 -0
- package/dist/agents/interpreter.d.ts +2 -0
- package/dist/agents/librarian.d.ts +2 -0
- package/dist/agents/oracle.d.ts +2 -0
- package/dist/agents/orchestrator.d.ts +27 -0
- package/dist/agents/overrides.d.ts +18 -0
- package/dist/agents/prompt-blocks.d.ts +97 -0
- package/dist/agents/steward.d.ts +3 -0
- package/dist/cli/config-io.d.ts +24 -0
- package/dist/cli/config-manager.d.ts +4 -0
- package/dist/cli/index.d.ts +2 -0
- package/dist/cli/index.js +1006 -0
- package/dist/cli/install.d.ts +2 -0
- package/dist/cli/mcps.d.ts +13 -0
- package/dist/cli/model-key-normalization.d.ts +1 -0
- package/dist/cli/paths.d.ts +35 -0
- package/dist/cli/providers.d.ts +137 -0
- package/dist/cli/skills.d.ts +22 -0
- package/dist/cli/system.d.ts +5 -0
- package/dist/cli/types.d.ts +38 -0
- package/dist/config/constants.d.ts +12 -0
- package/dist/config/index.d.ts +4 -0
- package/dist/config/loader.d.ts +40 -0
- package/dist/config/runtime-preset.d.ts +12 -0
- package/dist/config/schema.d.ts +281 -0
- package/dist/config/utils.d.ts +10 -0
- package/dist/discovery/local/types.d.ts +79 -0
- package/dist/discovery/local.d.ts +73 -0
- package/dist/discovery/mcp-servers.d.ts +88 -0
- package/dist/discovery/skills.d.ts +94 -0
- package/dist/hooks/apply-patch/codec.d.ts +7 -0
- package/dist/hooks/apply-patch/errors.d.ts +25 -0
- package/dist/hooks/apply-patch/execution-context.d.ts +27 -0
- package/dist/hooks/apply-patch/index.d.ts +15 -0
- package/dist/hooks/apply-patch/matching.d.ts +26 -0
- package/dist/hooks/apply-patch/operations.d.ts +3 -0
- package/dist/hooks/apply-patch/patch.d.ts +2 -0
- package/dist/hooks/apply-patch/prepared-changes.d.ts +17 -0
- package/dist/hooks/apply-patch/resolution.d.ts +19 -0
- package/dist/hooks/apply-patch/rewrite.d.ts +7 -0
- package/dist/hooks/apply-patch/test-helpers.d.ts +6 -0
- package/dist/hooks/apply-patch/types.d.ts +80 -0
- package/dist/hooks/auto-update-checker/cache.d.ts +11 -0
- package/dist/hooks/auto-update-checker/checker.d.ts +32 -0
- package/dist/hooks/auto-update-checker/constants.d.ts +11 -0
- package/dist/hooks/auto-update-checker/index.d.ts +18 -0
- package/dist/hooks/auto-update-checker/types.d.ts +22 -0
- package/dist/hooks/chat-headers.d.ts +16 -0
- package/dist/hooks/context-pressure-reminder/index.d.ts +33 -0
- package/dist/hooks/delegate-task-retry/guidance.d.ts +2 -0
- package/dist/hooks/delegate-task-retry/hook.d.ts +8 -0
- package/dist/hooks/delegate-task-retry/index.d.ts +4 -0
- package/dist/hooks/delegate-task-retry/patterns.d.ts +11 -0
- package/dist/hooks/filter-available-skills/index.d.ts +32 -0
- package/dist/hooks/foreground-fallback/index.d.ts +72 -0
- package/dist/hooks/image-hook.d.ts +5 -0
- package/dist/hooks/index.d.ts +14 -0
- package/dist/hooks/json-error-recovery/hook.d.ts +18 -0
- package/dist/hooks/json-error-recovery/index.d.ts +1 -0
- package/dist/hooks/phase-reminder/index.d.ts +26 -0
- package/dist/hooks/post-file-tool-nudge/index.d.ts +19 -0
- package/dist/hooks/task-session-manager/index.d.ts +52 -0
- package/dist/hooks/todo-continuation/index.d.ts +53 -0
- package/dist/hooks/todo-continuation/todo-hygiene.d.ts +35 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +31782 -0
- package/dist/mcp/context7.d.ts +6 -0
- package/dist/mcp/grep-app.d.ts +6 -0
- package/dist/mcp/index.d.ts +13 -0
- package/dist/mcp/types.d.ts +12 -0
- package/dist/mcp/websearch.d.ts +9 -0
- package/dist/skills/registry.d.ts +29 -0
- package/dist/subscriptions/accounts-store.d.ts +57 -0
- package/dist/subscriptions/index.d.ts +13 -0
- package/dist/subscriptions/neuralwatt-scraper.d.ts +14 -0
- package/dist/subscriptions/opencode-go-scraper.d.ts +27 -0
- package/dist/subscriptions/types.d.ts +115 -0
- package/dist/subscriptions/usage-service.d.ts +74 -0
- package/dist/tools/ast-grep/cli.d.ts +15 -0
- package/dist/tools/ast-grep/constants.d.ts +25 -0
- package/dist/tools/ast-grep/downloader.d.ts +5 -0
- package/dist/tools/ast-grep/index.d.ts +10 -0
- package/dist/tools/ast-grep/tools.d.ts +3 -0
- package/dist/tools/ast-grep/types.d.ts +30 -0
- package/dist/tools/ast-grep/utils.d.ts +4 -0
- package/dist/tools/delegate.d.ts +14 -0
- package/dist/tools/index.d.ts +5 -0
- package/dist/tools/preset-manager.d.ts +27 -0
- package/dist/tools/smartfetch/binary.d.ts +3 -0
- package/dist/tools/smartfetch/cache.d.ts +6 -0
- package/dist/tools/smartfetch/constants.d.ts +12 -0
- package/dist/tools/smartfetch/index.d.ts +3 -0
- package/dist/tools/smartfetch/network.d.ts +38 -0
- package/dist/tools/smartfetch/secondary-model.d.ts +28 -0
- package/dist/tools/smartfetch/tool.d.ts +3 -0
- package/dist/tools/smartfetch/types.d.ts +122 -0
- package/dist/tools/smartfetch/utils.d.ts +18 -0
- package/dist/tui-state.d.ts +168 -0
- package/dist/tui.d.ts +37 -0
- package/dist/tui.js +1896 -0
- package/dist/utils/agent-variant.d.ts +63 -0
- package/dist/utils/compat.d.ts +30 -0
- package/dist/utils/env.d.ts +1 -0
- package/dist/utils/index.d.ts +9 -0
- package/dist/utils/internal-initiator.d.ts +6 -0
- package/dist/utils/logger.d.ts +8 -0
- package/dist/utils/polling.d.ts +21 -0
- package/dist/utils/session-manager.d.ts +55 -0
- package/dist/utils/session.d.ts +90 -0
- package/dist/utils/subagent-depth.d.ts +35 -0
- package/dist/utils/system-collapse.d.ts +6 -0
- package/dist/utils/task.d.ts +4 -0
- package/dist/utils/zip-extractor.d.ts +1 -0
- package/index.ts +1 -0
- package/opencode-dux.schema.json +634 -0
- package/package.json +103 -0
- package/src/agents/descriptions.ts +55 -0
- package/src/agents/designer.test.ts +86 -0
- package/src/agents/designer.ts +154 -0
- package/src/agents/display-name.test.ts +186 -0
- package/src/agents/explorer.test.ts +79 -0
- package/src/agents/explorer.ts +144 -0
- package/src/agents/fixer.test.ts +79 -0
- package/src/agents/fixer.ts +145 -0
- package/src/agents/index.test.ts +472 -0
- package/src/agents/index.ts +248 -0
- package/src/agents/interpreter.ts +136 -0
- package/src/agents/librarian.test.ts +80 -0
- package/src/agents/librarian.ts +145 -0
- package/src/agents/oracle.test.ts +89 -0
- package/src/agents/oracle.ts +184 -0
- package/src/agents/orchestrator.test.ts +116 -0
- package/src/agents/orchestrator.ts +574 -0
- package/src/agents/overrides.ts +95 -0
- package/src/agents/prompt-blocks.test.ts +114 -0
- package/src/agents/prompt-blocks.ts +640 -0
- package/src/agents/steward.ts +146 -0
- package/src/cli/config-io.test.ts +536 -0
- package/src/cli/config-io.ts +473 -0
- package/src/cli/config-manager.test.ts +141 -0
- package/src/cli/config-manager.ts +4 -0
- package/src/cli/index.ts +88 -0
- package/src/cli/install.ts +282 -0
- package/src/cli/mcps.test.ts +62 -0
- package/src/cli/mcps.ts +39 -0
- package/src/cli/model-key-normalization.test.ts +21 -0
- package/src/cli/model-key-normalization.ts +60 -0
- package/src/cli/paths.test.ts +167 -0
- package/src/cli/paths.ts +144 -0
- package/src/cli/providers.test.ts +118 -0
- package/src/cli/providers.ts +141 -0
- package/src/cli/skills.test.ts +111 -0
- package/src/cli/skills.ts +103 -0
- package/src/cli/system.test.ts +91 -0
- package/src/cli/system.ts +180 -0
- package/src/cli/types.ts +43 -0
- package/src/config/constants.ts +58 -0
- package/src/config/index.ts +4 -0
- package/src/config/loader.test.ts +1194 -0
- package/src/config/loader.ts +269 -0
- package/src/config/model-resolution.test.ts +176 -0
- package/src/config/runtime-preset.test.ts +61 -0
- package/src/config/runtime-preset.ts +37 -0
- package/src/config/schema.ts +248 -0
- package/src/config/utils.test.ts +41 -0
- package/src/config/utils.ts +23 -0
- package/src/discovery/local/types.ts +85 -0
- package/src/discovery/local.ts +322 -0
- package/src/discovery/mcp-servers.ts +804 -0
- package/src/discovery/skills.ts +959 -0
- package/src/hooks/apply-patch/codec.test.ts +184 -0
- package/src/hooks/apply-patch/codec.ts +352 -0
- package/src/hooks/apply-patch/errors.ts +117 -0
- package/src/hooks/apply-patch/execution-context.ts +432 -0
- package/src/hooks/apply-patch/hook.test.ts +768 -0
- package/src/hooks/apply-patch/index.ts +126 -0
- package/src/hooks/apply-patch/matching.test.ts +215 -0
- package/src/hooks/apply-patch/matching.ts +586 -0
- package/src/hooks/apply-patch/operations.test.ts +1535 -0
- package/src/hooks/apply-patch/operations.ts +3 -0
- package/src/hooks/apply-patch/patch.ts +9 -0
- package/src/hooks/apply-patch/prepared-changes.ts +400 -0
- package/src/hooks/apply-patch/resolution.test.ts +420 -0
- package/src/hooks/apply-patch/resolution.ts +437 -0
- package/src/hooks/apply-patch/rewrite.ts +496 -0
- package/src/hooks/apply-patch/test-helpers.ts +52 -0
- package/src/hooks/apply-patch/types.ts +111 -0
- package/src/hooks/auto-update-checker/cache.test.ts +179 -0
- package/src/hooks/auto-update-checker/cache.ts +188 -0
- package/src/hooks/auto-update-checker/checker.test.ts +159 -0
- package/src/hooks/auto-update-checker/checker.ts +308 -0
- package/src/hooks/auto-update-checker/constants.ts +33 -0
- package/src/hooks/auto-update-checker/index.test.ts +282 -0
- package/src/hooks/auto-update-checker/index.ts +225 -0
- package/src/hooks/auto-update-checker/types.ts +26 -0
- package/src/hooks/chat-headers.test.ts +236 -0
- package/src/hooks/chat-headers.ts +97 -0
- package/src/hooks/context-pressure-reminder/index.test.ts +179 -0
- package/src/hooks/context-pressure-reminder/index.ts +137 -0
- package/src/hooks/delegate-task-retry/guidance.ts +41 -0
- package/src/hooks/delegate-task-retry/hook.ts +23 -0
- package/src/hooks/delegate-task-retry/index.test.ts +38 -0
- package/src/hooks/delegate-task-retry/index.ts +7 -0
- package/src/hooks/delegate-task-retry/patterns.ts +79 -0
- package/src/hooks/filter-available-skills/index.test.ts +297 -0
- package/src/hooks/filter-available-skills/index.ts +160 -0
- package/src/hooks/foreground-fallback/index.test.ts +624 -0
- package/src/hooks/foreground-fallback/index.ts +374 -0
- package/src/hooks/image-hook.ts +6 -0
- package/src/hooks/index.ts +17 -0
- package/src/hooks/json-error-recovery/hook.ts +73 -0
- package/src/hooks/json-error-recovery/index.test.ts +111 -0
- package/src/hooks/json-error-recovery/index.ts +6 -0
- package/src/hooks/phase-reminder/index.test.ts +74 -0
- package/src/hooks/phase-reminder/index.ts +85 -0
- package/src/hooks/post-file-tool-nudge/index.test.ts +94 -0
- package/src/hooks/post-file-tool-nudge/index.ts +63 -0
- package/src/hooks/task-session-manager/index.test.ts +833 -0
- package/src/hooks/task-session-manager/index.ts +434 -0
- package/src/hooks/todo-continuation/index.test.ts +3026 -0
- package/src/hooks/todo-continuation/index.ts +878 -0
- package/src/hooks/todo-continuation/todo-hygiene.test.ts +204 -0
- package/src/hooks/todo-continuation/todo-hygiene.ts +207 -0
- package/src/index.ts +1672 -0
- package/src/mcp/context7.ts +14 -0
- package/src/mcp/grep-app.ts +11 -0
- package/src/mcp/index.test.ts +96 -0
- package/src/mcp/index.ts +66 -0
- package/src/mcp/types.ts +16 -0
- package/src/mcp/websearch.ts +47 -0
- package/src/skills/codemap/README.md +60 -0
- package/src/skills/codemap/SKILL.md +174 -0
- package/src/skills/codemap/scripts/codemap.mjs +483 -0
- package/src/skills/codemap/scripts/codemap.test.ts +129 -0
- package/src/skills/registry.ts +218 -0
- package/src/skills/simplify/README.md +19 -0
- package/src/skills/simplify/SKILL.md +138 -0
- package/src/subscriptions/accounts-store.test.ts +236 -0
- package/src/subscriptions/accounts-store.ts +184 -0
- package/src/subscriptions/index.ts +30 -0
- package/src/subscriptions/neuralwatt-scraper.ts +108 -0
- package/src/subscriptions/opencode-go-scraper.ts +301 -0
- package/src/subscriptions/types.ts +145 -0
- package/src/subscriptions/usage-service.test.ts +202 -0
- package/src/subscriptions/usage-service.ts +651 -0
- package/src/tools/ast-grep/cli.ts +257 -0
- package/src/tools/ast-grep/constants.ts +214 -0
- package/src/tools/ast-grep/downloader.ts +131 -0
- package/src/tools/ast-grep/index.ts +24 -0
- package/src/tools/ast-grep/tools.ts +117 -0
- package/src/tools/ast-grep/types.ts +51 -0
- package/src/tools/ast-grep/utils.ts +126 -0
- package/src/tools/delegate-handoff.test.ts +18 -0
- package/src/tools/delegate.ts +508 -0
- package/src/tools/index.ts +8 -0
- package/src/tools/preset-manager.test.ts +795 -0
- package/src/tools/preset-manager.ts +332 -0
- package/src/tools/smartfetch/binary.ts +58 -0
- package/src/tools/smartfetch/cache.test.ts +34 -0
- package/src/tools/smartfetch/cache.ts +112 -0
- package/src/tools/smartfetch/constants.ts +29 -0
- package/src/tools/smartfetch/index.ts +8 -0
- package/src/tools/smartfetch/network.test.ts +178 -0
- package/src/tools/smartfetch/network.ts +614 -0
- package/src/tools/smartfetch/secondary-model.test.ts +85 -0
- package/src/tools/smartfetch/secondary-model.ts +276 -0
- package/src/tools/smartfetch/tool.test.ts +60 -0
- package/src/tools/smartfetch/tool.ts +832 -0
- package/src/tools/smartfetch/types.ts +135 -0
- package/src/tools/smartfetch/utils.test.ts +24 -0
- package/src/tools/smartfetch/utils.ts +456 -0
- package/src/tui-state.test.ts +867 -0
- package/src/tui-state.ts +1255 -0
- package/src/tui.test.ts +336 -0
- package/src/tui.ts +1539 -0
- package/src/utils/agent-variant.test.ts +244 -0
- package/src/utils/agent-variant.ts +187 -0
- package/src/utils/compat.ts +91 -0
- package/src/utils/env.ts +12 -0
- package/src/utils/index.ts +9 -0
- package/src/utils/internal-initiator.ts +28 -0
- package/src/utils/logger.test.ts +220 -0
- package/src/utils/logger.ts +136 -0
- package/src/utils/polling.test.ts +191 -0
- package/src/utils/polling.ts +67 -0
- package/src/utils/session-manager.test.ts +173 -0
- package/src/utils/session-manager.ts +356 -0
- package/src/utils/session.test.ts +110 -0
- package/src/utils/session.ts +389 -0
- package/src/utils/subagent-depth.test.ts +170 -0
- package/src/utils/subagent-depth.ts +75 -0
- package/src/utils/system-collapse.test.ts +86 -0
- package/src/utils/system-collapse.ts +24 -0
- package/src/utils/task.test.ts +24 -0
- package/src/utils/task.ts +20 -0
- package/src/utils/zip-extractor.ts +102 -0
package/src/index.ts
ADDED
|
@@ -0,0 +1,1672 @@
|
|
|
1
|
+
import { existsSync, readdirSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import type { Plugin } from '@opencode-ai/plugin';
|
|
4
|
+
import { createAgents, getAgentConfigs } from './agents';
|
|
5
|
+
import { buildOrchestratorPrompt } from './agents/orchestrator';
|
|
6
|
+
import {
|
|
7
|
+
type AgentOverrideConfig,
|
|
8
|
+
ALL_AGENT_NAMES,
|
|
9
|
+
DEFAULT_MODELS,
|
|
10
|
+
deepMerge,
|
|
11
|
+
loadPluginConfig,
|
|
12
|
+
} from './config';
|
|
13
|
+
import { AGENT_ALIASES } from './config/constants';
|
|
14
|
+
import {
|
|
15
|
+
getActiveRuntimePreset,
|
|
16
|
+
getPreviousRuntimePreset,
|
|
17
|
+
setActiveRuntimePreset,
|
|
18
|
+
} from './config/runtime-preset';
|
|
19
|
+
import { getLocalDiscovery } from './discovery/local';
|
|
20
|
+
import { createDiscoverMcpServersTool } from './discovery/mcp-servers';
|
|
21
|
+
import { createDiscoverSkillsTool } from './discovery/skills';
|
|
22
|
+
import {
|
|
23
|
+
createApplyPatchHook,
|
|
24
|
+
createAutoUpdateCheckerHook,
|
|
25
|
+
createChatHeadersHook,
|
|
26
|
+
createContextPressureReminderHook,
|
|
27
|
+
createDelegateTaskRetryHook,
|
|
28
|
+
createFilterAvailableSkillsHook,
|
|
29
|
+
createJsonErrorRecoveryHook,
|
|
30
|
+
createPhaseReminderHook,
|
|
31
|
+
createPostFileToolNudgeHook,
|
|
32
|
+
createTaskSessionManagerHook,
|
|
33
|
+
createTodoContinuationHook,
|
|
34
|
+
ForegroundFallbackManager,
|
|
35
|
+
} from './hooks';
|
|
36
|
+
import { processImageAttachments } from './hooks/image-hook';
|
|
37
|
+
import { createBuiltinMcps } from './mcp';
|
|
38
|
+
import { discoverSkills } from './skills/registry';
|
|
39
|
+
import type { UsageService } from './subscriptions';
|
|
40
|
+
import { createUsageService } from './subscriptions';
|
|
41
|
+
import {
|
|
42
|
+
ast_grep_replace,
|
|
43
|
+
ast_grep_search,
|
|
44
|
+
createDelegateTools,
|
|
45
|
+
createPresetManager,
|
|
46
|
+
createWebfetchTool,
|
|
47
|
+
} from './tools';
|
|
48
|
+
import {
|
|
49
|
+
deleteSessionEntries,
|
|
50
|
+
expandMissingSessionCascade,
|
|
51
|
+
mergedSessionModels,
|
|
52
|
+
mergedSessionTree,
|
|
53
|
+
normalizeProjectDirectory,
|
|
54
|
+
patchSessionTreeStatusFromOpenCode,
|
|
55
|
+
pruneStaleTuiSessionBundles,
|
|
56
|
+
type RecordSessionUsageInput,
|
|
57
|
+
readTuiSnapshot,
|
|
58
|
+
recordChildSessionSnapshot,
|
|
59
|
+
recordSessionDone,
|
|
60
|
+
recordSessionEnd,
|
|
61
|
+
recordSessionModel,
|
|
62
|
+
recordSessionNode,
|
|
63
|
+
recordSessionProject,
|
|
64
|
+
recordSessionTitle,
|
|
65
|
+
recordSessionUsage,
|
|
66
|
+
recordSessionUsagesBatch,
|
|
67
|
+
recordSessionVariant,
|
|
68
|
+
sessionTreeStore,
|
|
69
|
+
syncOpenCodeStatusesIntoSessionTree,
|
|
70
|
+
updateSnapshot,
|
|
71
|
+
} from './tui-state';
|
|
72
|
+
import {
|
|
73
|
+
createDisplayNameMentionRewriter,
|
|
74
|
+
resolveRuntimeAgentName,
|
|
75
|
+
} from './utils';
|
|
76
|
+
import { initLogger, log } from './utils/logger';
|
|
77
|
+
import { SubagentDepthTracker } from './utils/subagent-depth';
|
|
78
|
+
import { collapseSystemInPlace } from './utils/system-collapse';
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Best-effort log to opencode's app logger.
|
|
82
|
+
* Wrapped in try/catch to avoid deadlocking on opencode v1.4.8-v1.4.9
|
|
83
|
+
* where client.app.log() during init triggers a middleware cycle.
|
|
84
|
+
*/
|
|
85
|
+
async function appLog(
|
|
86
|
+
ctx: Parameters<Plugin>[0],
|
|
87
|
+
level: 'error' | 'warn' | 'info',
|
|
88
|
+
message: string,
|
|
89
|
+
): Promise<void> {
|
|
90
|
+
try {
|
|
91
|
+
await ctx.client.app.log({
|
|
92
|
+
body: { service: 'opencode-dux', level, message },
|
|
93
|
+
});
|
|
94
|
+
} catch {
|
|
95
|
+
// client.app.log may deadlock or be unavailable; stderr is the
|
|
96
|
+
// fallback
|
|
97
|
+
const prefix =
|
|
98
|
+
level === 'error' ? 'ERROR' : level === 'warn' ? 'WARN' : 'INFO';
|
|
99
|
+
console.error(`[opencode-dux] ${prefix}: ${message}`);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Minimum expected registrations for a healthy plugin load. */
|
|
104
|
+
const HEALTH_CHECK = {
|
|
105
|
+
minAgents: 5,
|
|
106
|
+
minTools: 5,
|
|
107
|
+
minMcps: 1,
|
|
108
|
+
} as const;
|
|
109
|
+
|
|
110
|
+
function asNumber(value: unknown): number | null {
|
|
111
|
+
return typeof value === 'number' && Number.isFinite(value) ? value : null;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function readTokenTelemetry(message: unknown): {
|
|
115
|
+
input: number;
|
|
116
|
+
output: number;
|
|
117
|
+
reasoning: number;
|
|
118
|
+
cacheRead: number;
|
|
119
|
+
cacheWrite: number;
|
|
120
|
+
contextLimit: number;
|
|
121
|
+
} | null {
|
|
122
|
+
const msg = message as {
|
|
123
|
+
info?: {
|
|
124
|
+
role?: string;
|
|
125
|
+
tokens?: {
|
|
126
|
+
input?: unknown;
|
|
127
|
+
output?: unknown;
|
|
128
|
+
reasoning?: unknown;
|
|
129
|
+
cache?: { read?: unknown; write?: unknown };
|
|
130
|
+
};
|
|
131
|
+
model?: {
|
|
132
|
+
limit?: { context?: unknown; input?: unknown };
|
|
133
|
+
};
|
|
134
|
+
};
|
|
135
|
+
};
|
|
136
|
+
if (msg.info?.role !== 'assistant') return null;
|
|
137
|
+
|
|
138
|
+
const input = asNumber(msg.info?.tokens?.input) ?? 0;
|
|
139
|
+
const output = asNumber(msg.info?.tokens?.output) ?? 0;
|
|
140
|
+
const reasoning = asNumber(msg.info?.tokens?.reasoning) ?? 0;
|
|
141
|
+
const cacheRead = asNumber(msg.info?.tokens?.cache?.read) ?? 0;
|
|
142
|
+
const cacheWrite = asNumber(msg.info?.tokens?.cache?.write) ?? 0;
|
|
143
|
+
|
|
144
|
+
const contextLimit =
|
|
145
|
+
asNumber(msg.info?.model?.limit?.context) ??
|
|
146
|
+
asNumber(msg.info?.model?.limit?.input) ??
|
|
147
|
+
0;
|
|
148
|
+
|
|
149
|
+
if (
|
|
150
|
+
input <= 0 &&
|
|
151
|
+
output <= 0 &&
|
|
152
|
+
reasoning <= 0 &&
|
|
153
|
+
cacheRead <= 0 &&
|
|
154
|
+
cacheWrite <= 0
|
|
155
|
+
) {
|
|
156
|
+
return null;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
return {
|
|
160
|
+
input,
|
|
161
|
+
output,
|
|
162
|
+
reasoning,
|
|
163
|
+
cacheRead,
|
|
164
|
+
cacheWrite,
|
|
165
|
+
contextLimit,
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Model context limit cache: key = "providerID/modelID", value = context
|
|
170
|
+
// limit. Populated lazily via ensureModelContextLimits().
|
|
171
|
+
const _modelContextLimitCache = new Map<string, number>();
|
|
172
|
+
let _modelLimitFetchPromise: Promise<void> | null = null;
|
|
173
|
+
|
|
174
|
+
async function ensureModelContextLimits(client: {
|
|
175
|
+
provider: {
|
|
176
|
+
list: () => Promise<{
|
|
177
|
+
data?: { all?: Array<Record<string, unknown>> };
|
|
178
|
+
}>;
|
|
179
|
+
};
|
|
180
|
+
}): Promise<void> {
|
|
181
|
+
if (_modelContextLimitCache.size > 0 || _modelLimitFetchPromise) {
|
|
182
|
+
await _modelLimitFetchPromise;
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
_modelLimitFetchPromise = (async () => {
|
|
187
|
+
try {
|
|
188
|
+
const result = await client.provider.list();
|
|
189
|
+
const providers =
|
|
190
|
+
(result.data?.all as
|
|
191
|
+
| Array<{
|
|
192
|
+
id?: string;
|
|
193
|
+
models?: Record<
|
|
194
|
+
string,
|
|
195
|
+
{ id?: string; limit?: { context?: number } }
|
|
196
|
+
>;
|
|
197
|
+
}>
|
|
198
|
+
| undefined) ?? [];
|
|
199
|
+
for (const provider of providers) {
|
|
200
|
+
if (!provider.models) continue;
|
|
201
|
+
for (const model of Object.values(provider.models)) {
|
|
202
|
+
if (
|
|
203
|
+
typeof model?.limit?.context === 'number' &&
|
|
204
|
+
model.limit.context > 0 &&
|
|
205
|
+
provider.id &&
|
|
206
|
+
model.id
|
|
207
|
+
) {
|
|
208
|
+
_modelContextLimitCache.set(
|
|
209
|
+
`${provider.id}/${model.id}`,
|
|
210
|
+
model.limit.context,
|
|
211
|
+
);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
} catch {
|
|
216
|
+
// Non-critical - cache stays empty, percentage shows 0
|
|
217
|
+
}
|
|
218
|
+
})();
|
|
219
|
+
|
|
220
|
+
return _modelLimitFetchPromise;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Compute usage telemetry for one session (messages fetch). Used by
|
|
225
|
+
* reconciliation; persists via {@link recordSessionUsagesBatch} so we do not
|
|
226
|
+
* N-compete on tui-state.json.
|
|
227
|
+
*/
|
|
228
|
+
async function computeSessionUsageForReconcile(
|
|
229
|
+
ctx: Parameters<Plugin>[0],
|
|
230
|
+
sessionID: string,
|
|
231
|
+
): Promise<RecordSessionUsageInput | null> {
|
|
232
|
+
try {
|
|
233
|
+
const messagesResult = await ctx.client.session.messages({
|
|
234
|
+
path: { id: sessionID },
|
|
235
|
+
});
|
|
236
|
+
const allMessages = Array.isArray(messagesResult.data)
|
|
237
|
+
? messagesResult.data
|
|
238
|
+
: [];
|
|
239
|
+
const assistantMsgs = allMessages.filter(
|
|
240
|
+
(m) => (m as { info?: { role?: string } }).info?.role === 'assistant',
|
|
241
|
+
);
|
|
242
|
+
|
|
243
|
+
// Extract tokens from last assistant message only (SDK supplies cumulative values)
|
|
244
|
+
let totalInput = 0;
|
|
245
|
+
let totalOutput = 0;
|
|
246
|
+
let totalReasoning = 0;
|
|
247
|
+
let totalCacheRead = 0;
|
|
248
|
+
let totalCacheWrite = 0;
|
|
249
|
+
let contextLimit = 0;
|
|
250
|
+
let contextUsed = 0;
|
|
251
|
+
let contextPct = 0;
|
|
252
|
+
|
|
253
|
+
// Ensure context limit cache is populated before recording usage
|
|
254
|
+
await ensureModelContextLimits(ctx.client).catch(() => {});
|
|
255
|
+
|
|
256
|
+
const lastTokenMsg = [...assistantMsgs]
|
|
257
|
+
.reverse()
|
|
258
|
+
.find((m) => readTokenTelemetry(m));
|
|
259
|
+
if (lastTokenMsg) {
|
|
260
|
+
const telemetry = readTokenTelemetry(lastTokenMsg);
|
|
261
|
+
if (telemetry) {
|
|
262
|
+
totalInput = telemetry.input;
|
|
263
|
+
totalOutput = telemetry.output;
|
|
264
|
+
totalReasoning = telemetry.reasoning;
|
|
265
|
+
totalCacheRead = telemetry.cacheRead;
|
|
266
|
+
totalCacheWrite = telemetry.cacheWrite;
|
|
267
|
+
contextLimit = telemetry.contextLimit;
|
|
268
|
+
// Sidebar expects CTX used to match Input + Output tokens.
|
|
269
|
+
// Input row = input + cacheRead
|
|
270
|
+
// Output row = output + reasoning
|
|
271
|
+
contextUsed =
|
|
272
|
+
telemetry.input +
|
|
273
|
+
telemetry.cacheRead +
|
|
274
|
+
telemetry.output +
|
|
275
|
+
telemetry.reasoning;
|
|
276
|
+
contextPct = contextLimit > 0 ? (contextUsed / contextLimit) * 100 : 0;
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// Fallback: if message didn't provide context limit, look up from cache
|
|
281
|
+
if (contextLimit === 0 && contextUsed > 0) {
|
|
282
|
+
const model = mergedSessionModels(readTuiSnapshot())[sessionID];
|
|
283
|
+
const cachedLimit = model
|
|
284
|
+
? _modelContextLimitCache.get(model)
|
|
285
|
+
: undefined;
|
|
286
|
+
if (cachedLimit && cachedLimit > 0) {
|
|
287
|
+
contextLimit = cachedLimit;
|
|
288
|
+
contextPct = (contextUsed / contextLimit) * 100;
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
if (contextUsed > 0 || totalInput > 0 || totalOutput > 0) {
|
|
293
|
+
return {
|
|
294
|
+
sessionID,
|
|
295
|
+
contextUsed,
|
|
296
|
+
contextLimit,
|
|
297
|
+
contextPct,
|
|
298
|
+
input: totalInput,
|
|
299
|
+
output: totalOutput,
|
|
300
|
+
reasoning: totalReasoning,
|
|
301
|
+
cacheRead: totalCacheRead,
|
|
302
|
+
cacheWrite: totalCacheWrite,
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
return null;
|
|
306
|
+
} catch {
|
|
307
|
+
return null;
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* Probe jsdom at init time so the first webfetch call doesn't fail
|
|
313
|
+
* silently. Logs a warning if jsdom can't be imported or instantiated,
|
|
314
|
+
* but does not throw; the plugin works without webfetch.
|
|
315
|
+
*/
|
|
316
|
+
async function probeJSDOM(): Promise<string | null> {
|
|
317
|
+
try {
|
|
318
|
+
const { JSDOM } = await import('jsdom');
|
|
319
|
+
new JSDOM('<!DOCTYPE html><html><body>test</body></html>');
|
|
320
|
+
return null;
|
|
321
|
+
} catch (err) {
|
|
322
|
+
return String(err);
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// Module-level runtime preset tracking. Survives plugin re-inits triggered
|
|
327
|
+
// by client.config.update() → Instance.dispose(). When the plugin function
|
|
328
|
+
// re-runs, it checks this variable and applies the runtime preset instead
|
|
329
|
+
// of the config file's preset. State lives in config/runtime-preset.ts.
|
|
330
|
+
|
|
331
|
+
// Guards to ensure startup logs fire only once across plugin re-inits
|
|
332
|
+
let didLogVerboseInit = false;
|
|
333
|
+
let didLogStartupSummary = false;
|
|
334
|
+
|
|
335
|
+
const OhMyOpenCodeLite: Plugin = async (ctx) => {
|
|
336
|
+
const sessionId = new Date().toISOString().replace(/[-:]/g, '').slice(0, 15);
|
|
337
|
+
initLogger(sessionId);
|
|
338
|
+
|
|
339
|
+
// Declare variables that must survive the try/catch for the return
|
|
340
|
+
// closure. These are set inside the try block.
|
|
341
|
+
let config: ReturnType<typeof loadPluginConfig>;
|
|
342
|
+
let agentDefs: Awaited<ReturnType<typeof createAgents>>;
|
|
343
|
+
let agents: Awaited<ReturnType<typeof getAgentConfigs>>;
|
|
344
|
+
let builtinMcps: ReturnType<typeof createBuiltinMcps>;
|
|
345
|
+
let modelArrayMap: Record<string, Array<{ id: string; variant?: string }>>;
|
|
346
|
+
let runtimeChains: Record<string, string[]>;
|
|
347
|
+
let depthTracker: SubagentDepthTracker;
|
|
348
|
+
let autoUpdateChecker: ReturnType<typeof createAutoUpdateCheckerHook>;
|
|
349
|
+
let phaseReminderHook: ReturnType<typeof createPhaseReminderHook>;
|
|
350
|
+
let filterAvailableSkillsHook: ReturnType<
|
|
351
|
+
typeof createFilterAvailableSkillsHook
|
|
352
|
+
>;
|
|
353
|
+
let sessionAgentMap: Map<string, string>;
|
|
354
|
+
let deletingSessions: Set<string>;
|
|
355
|
+
let reconcileSessions!: () => Promise<void>;
|
|
356
|
+
let postFileToolNudgeHook: ReturnType<typeof createPostFileToolNudgeHook>;
|
|
357
|
+
let chatHeadersHook: ReturnType<typeof createChatHeadersHook>;
|
|
358
|
+
let delegateTaskRetryHook: ReturnType<typeof createDelegateTaskRetryHook>;
|
|
359
|
+
let applyPatchHook: ReturnType<typeof createApplyPatchHook>;
|
|
360
|
+
let jsonErrorRecoveryHook: ReturnType<typeof createJsonErrorRecoveryHook>;
|
|
361
|
+
let foregroundFallback: ForegroundFallbackManager;
|
|
362
|
+
let todoContinuationHook: ReturnType<typeof createTodoContinuationHook>;
|
|
363
|
+
let taskSessionManagerHook: ReturnType<typeof createTaskSessionManagerHook>;
|
|
364
|
+
let contextPressureReminderHook: ReturnType<
|
|
365
|
+
typeof createContextPressureReminderHook
|
|
366
|
+
>;
|
|
367
|
+
let presetManager: ReturnType<typeof createPresetManager>;
|
|
368
|
+
let usageService: UsageService | null;
|
|
369
|
+
let webfetch: ReturnType<typeof createWebfetchTool>;
|
|
370
|
+
let delegateTools: Record<string, unknown>;
|
|
371
|
+
let discoverMcpTool:
|
|
372
|
+
| ReturnType<typeof createDiscoverMcpServersTool>
|
|
373
|
+
| undefined;
|
|
374
|
+
let discoverSkillTool:
|
|
375
|
+
| ReturnType<typeof createDiscoverSkillsTool>
|
|
376
|
+
| undefined;
|
|
377
|
+
let rewriteDisplayNameMentions: ReturnType<
|
|
378
|
+
typeof createDisplayNameMentionRewriter
|
|
379
|
+
>;
|
|
380
|
+
|
|
381
|
+
// Counters for post-init health check (set inside try, checked outside)
|
|
382
|
+
let toolCount = 0;
|
|
383
|
+
|
|
384
|
+
try {
|
|
385
|
+
const isFirstInit = !didLogVerboseInit;
|
|
386
|
+
|
|
387
|
+
if (isFirstInit) console.log('\u{2699}\u{FE0F} Initializing opencode-dux...');
|
|
388
|
+
|
|
389
|
+
config = loadPluginConfig(ctx.directory);
|
|
390
|
+
|
|
391
|
+
// Safety net: if a runtime preset was set via /preset command and
|
|
392
|
+
// OpenCode ever fully re-runs the plugin function (not just the
|
|
393
|
+
// config() hook), override config.preset so agents are created with
|
|
394
|
+
// the correct models. Currently only the config() hook re-runs after
|
|
395
|
+
// Instance.dispose(), so this is a defensive guard.
|
|
396
|
+
const runtimePreset = getActiveRuntimePreset();
|
|
397
|
+
if (runtimePreset && config.presets?.[runtimePreset]) {
|
|
398
|
+
config.preset = runtimePreset;
|
|
399
|
+
// Re-merge runtime preset into config.agents (loadPluginConfig
|
|
400
|
+
// already merged the config-file preset, not the runtime one).
|
|
401
|
+
// Runtime preset is override so it wins over config-file preset.
|
|
402
|
+
const presetAgents = config.presets[runtimePreset];
|
|
403
|
+
config.agents = deepMerge(config.agents, presetAgents);
|
|
404
|
+
} else if (runtimePreset) {
|
|
405
|
+
// Preset was deleted from config since last switch - clear stale state
|
|
406
|
+
setActiveRuntimePreset(null);
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
// Validate all agents have models configured
|
|
410
|
+
for (const agentName of ALL_AGENT_NAMES) {
|
|
411
|
+
const override = config?.agents?.[agentName]?.model;
|
|
412
|
+
const defaultModel =
|
|
413
|
+
DEFAULT_MODELS[agentName as keyof typeof DEFAULT_MODELS];
|
|
414
|
+
const effectiveModel = override ?? defaultModel;
|
|
415
|
+
if (!effectiveModel) {
|
|
416
|
+
ctx.client.tui
|
|
417
|
+
.showToast({
|
|
418
|
+
body: {
|
|
419
|
+
title: `Agent "${agentName}" has no model configured`,
|
|
420
|
+
message:
|
|
421
|
+
`Set "agents.${agentName}.model" in your config or ` +
|
|
422
|
+
'a default will be used.',
|
|
423
|
+
variant: 'info',
|
|
424
|
+
duration: 5000,
|
|
425
|
+
},
|
|
426
|
+
})
|
|
427
|
+
.catch(() => {});
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
if (isFirstInit) console.log(' \u{1F4C1} plugin config: loaded');
|
|
432
|
+
|
|
433
|
+
rewriteDisplayNameMentions = createDisplayNameMentionRewriter(config);
|
|
434
|
+
agentDefs = await createAgents(config);
|
|
435
|
+
agents = await getAgentConfigs(config);
|
|
436
|
+
|
|
437
|
+
if (isFirstInit) console.log(` \u{1F916} agents: ${Object.keys(agents).join(', ')}`);
|
|
438
|
+
|
|
439
|
+
// Build a map of agent name → priority model array for runtime
|
|
440
|
+
// fallback. Populated when the user configures model as an array in
|
|
441
|
+
// their plugin config.
|
|
442
|
+
modelArrayMap = {} as Record<
|
|
443
|
+
string,
|
|
444
|
+
Array<{ id: string; variant?: string }>
|
|
445
|
+
>;
|
|
446
|
+
for (const agentDef of agentDefs) {
|
|
447
|
+
if (agentDef._modelArray && agentDef._modelArray.length > 0) {
|
|
448
|
+
modelArrayMap[agentDef.name] = agentDef._modelArray;
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
// Build runtime fallback chains for all foreground agents. Each chain
|
|
452
|
+
// is an ordered list of model strings to try when the current model is
|
|
453
|
+
// rate-limited. Seeds from _modelArray entries (when the user
|
|
454
|
+
// configures model as an array), then appends fallback.chains entries.
|
|
455
|
+
runtimeChains = {} as Record<string, string[]>;
|
|
456
|
+
for (const agentDef of agentDefs) {
|
|
457
|
+
if (agentDef._modelArray?.length) {
|
|
458
|
+
runtimeChains[agentDef.name] = agentDef._modelArray.map((m) => m.id);
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
if (config.fallback?.enabled !== false) {
|
|
462
|
+
const chains =
|
|
463
|
+
(config.fallback?.chains as Record<string, string[] | undefined>) ?? {};
|
|
464
|
+
for (const [agentName, chainModels] of Object.entries(chains)) {
|
|
465
|
+
if (!chainModels?.length) continue;
|
|
466
|
+
const existing = runtimeChains[agentName] ?? [];
|
|
467
|
+
const seen = new Set(existing);
|
|
468
|
+
for (const m of chainModels) {
|
|
469
|
+
if (!seen.has(m)) {
|
|
470
|
+
seen.add(m);
|
|
471
|
+
existing.push(m);
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
runtimeChains[agentName] = existing;
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
depthTracker = new SubagentDepthTracker();
|
|
479
|
+
|
|
480
|
+
// Initialize delegate tools for orchestrator variant-based subagent spawning
|
|
481
|
+
delegateTools = createDelegateTools(
|
|
482
|
+
ctx,
|
|
483
|
+
config,
|
|
484
|
+
depthTracker,
|
|
485
|
+
);
|
|
486
|
+
|
|
487
|
+
builtinMcps = createBuiltinMcps(undefined, config.websearch);
|
|
488
|
+
|
|
489
|
+
if (isFirstInit) console.log(` \u{1F50C} MCPs: ${Object.keys(builtinMcps).join(', ')}`);
|
|
490
|
+
|
|
491
|
+
// Warm the local discovery cache asynchronously (non-blocking init).
|
|
492
|
+
// Subsequent hooks/tools will read from cache on first use.
|
|
493
|
+
getLocalDiscovery(ctx).catch(() => {});
|
|
494
|
+
|
|
495
|
+
webfetch = createWebfetchTool(ctx);
|
|
496
|
+
|
|
497
|
+
// Initialize online discovery tools (graceful degradation on failure)
|
|
498
|
+
const toolsOnline: string[] = [];
|
|
499
|
+
try {
|
|
500
|
+
discoverMcpTool = createDiscoverMcpServersTool(ctx);
|
|
501
|
+
toolsOnline.push('discover_mcp_servers');
|
|
502
|
+
} catch (err) {
|
|
503
|
+
log('[plugin] failed to create discover_mcp_servers tool', String(err));
|
|
504
|
+
discoverMcpTool = undefined;
|
|
505
|
+
}
|
|
506
|
+
try {
|
|
507
|
+
discoverSkillTool = createDiscoverSkillsTool(ctx);
|
|
508
|
+
toolsOnline.push('discover_skills_online');
|
|
509
|
+
} catch (err) {
|
|
510
|
+
log('[plugin] failed to create discover_skills_online tool', String(err));
|
|
511
|
+
discoverSkillTool = undefined;
|
|
512
|
+
}
|
|
513
|
+
if (isFirstInit) console.log(` \u{1F527} tools: webfetch, ast_grep_search, ast_grep_replace${toolsOnline.length ? `, ${toolsOnline.join(', ')}` : ''}`);
|
|
514
|
+
|
|
515
|
+
// Initialize auto-update checker hook
|
|
516
|
+
autoUpdateChecker = createAutoUpdateCheckerHook(ctx, {
|
|
517
|
+
autoUpdate: config.autoUpdate ?? true,
|
|
518
|
+
});
|
|
519
|
+
|
|
520
|
+
// Initialize phase reminder hook for workflow compliance
|
|
521
|
+
phaseReminderHook = createPhaseReminderHook();
|
|
522
|
+
|
|
523
|
+
// Initialize available skills filter hook
|
|
524
|
+
filterAvailableSkillsHook = createFilterAvailableSkillsHook(ctx, config);
|
|
525
|
+
|
|
526
|
+
// Track session → agent mapping for serve-mode system prompt injection
|
|
527
|
+
sessionAgentMap = new Map<string, string>();
|
|
528
|
+
deletingSessions = new Set<string>();
|
|
529
|
+
|
|
530
|
+
// Sync tui-state with OpenCode's session snapshot. Prefer running after the
|
|
531
|
+
// user submits (`experimental.chat.messages.transform`) rather than plugin
|
|
532
|
+
// init so session.status tends to enumerate active sessions reliably.
|
|
533
|
+
reconcileSessions = async (): Promise<void> => {
|
|
534
|
+
try {
|
|
535
|
+
const result = await ctx.client.session.status({});
|
|
536
|
+
const statuses = result.data as
|
|
537
|
+
| Record<string, { type: string }>
|
|
538
|
+
| undefined;
|
|
539
|
+
if (!statuses) return;
|
|
540
|
+
// An empty status map cannot distinguish "nothing running" from
|
|
541
|
+
// transient/incomplete enumeration. Treating {} as authoritative
|
|
542
|
+
// cleared every snapshot bundle for this cwd and tore down the
|
|
543
|
+
// entire sessionTreeStore (see instanceSeeds below).
|
|
544
|
+
if (Object.keys(statuses).length === 0) return;
|
|
545
|
+
|
|
546
|
+
const opencodeIds = new Set(Object.keys(statuses));
|
|
547
|
+
const currentProjectDir = normalizeProjectDirectory(ctx.directory);
|
|
548
|
+
|
|
549
|
+
// Sync polled OpenCode statuses into tree nodes. Bundles are removed
|
|
550
|
+
// when every session id in the tree is absent from OpenCode (same
|
|
551
|
+
// project only), by 7d idle TTL, or soft-pruned for partial gaps.
|
|
552
|
+
updateSnapshot((s) => {
|
|
553
|
+
syncOpenCodeStatusesIntoSessionTree(
|
|
554
|
+
s,
|
|
555
|
+
statuses as Record<string, { type: string }>,
|
|
556
|
+
);
|
|
557
|
+
pruneStaleTuiSessionBundles(s, {
|
|
558
|
+
opencodeIds,
|
|
559
|
+
currentProjectDir,
|
|
560
|
+
now: Date.now(),
|
|
561
|
+
});
|
|
562
|
+
});
|
|
563
|
+
|
|
564
|
+
const snap = readTuiSnapshot();
|
|
565
|
+
const mergedForMemory = {
|
|
566
|
+
...mergedSessionTree(snap),
|
|
567
|
+
...sessionTreeStore,
|
|
568
|
+
};
|
|
569
|
+
|
|
570
|
+
const instanceSeeds = Object.keys(sessionTreeStore).filter(
|
|
571
|
+
(sid) => !opencodeIds.has(sid),
|
|
572
|
+
);
|
|
573
|
+
const instanceExpanded = expandMissingSessionCascade(
|
|
574
|
+
mergedForMemory,
|
|
575
|
+
instanceSeeds,
|
|
576
|
+
);
|
|
577
|
+
|
|
578
|
+
for (const sid of instanceExpanded) {
|
|
579
|
+
sessionAgentMap.delete(sid);
|
|
580
|
+
delete sessionTreeStore[sid];
|
|
581
|
+
if (depthTracker) depthTracker.cleanup(sid);
|
|
582
|
+
deletingSessions.delete(sid);
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
const activeIds = Object.keys(statuses);
|
|
586
|
+
const usageResults = await Promise.allSettled(
|
|
587
|
+
activeIds.map((sid) => computeSessionUsageForReconcile(ctx, sid)),
|
|
588
|
+
);
|
|
589
|
+
const usageBatch: RecordSessionUsageInput[] = [];
|
|
590
|
+
for (const r of usageResults) {
|
|
591
|
+
if (r.status === 'fulfilled' && r.value) {
|
|
592
|
+
usageBatch.push(r.value);
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
recordSessionUsagesBatch(usageBatch);
|
|
596
|
+
|
|
597
|
+
usageService?.refresh(false).catch(() => {});
|
|
598
|
+
} catch {
|
|
599
|
+
// best-effort - silent
|
|
600
|
+
}
|
|
601
|
+
};
|
|
602
|
+
|
|
603
|
+
// Initialize post-file-tool nudge hook
|
|
604
|
+
postFileToolNudgeHook = createPostFileToolNudgeHook({
|
|
605
|
+
shouldInject: (sessionID) =>
|
|
606
|
+
sessionAgentMap.get(sessionID) === 'orchestrator',
|
|
607
|
+
});
|
|
608
|
+
|
|
609
|
+
chatHeadersHook = createChatHeadersHook(ctx);
|
|
610
|
+
|
|
611
|
+
// Initialize delegate-task retry guidance hook
|
|
612
|
+
delegateTaskRetryHook = createDelegateTaskRetryHook(ctx);
|
|
613
|
+
|
|
614
|
+
applyPatchHook = createApplyPatchHook(ctx);
|
|
615
|
+
// Initialize JSON parse error recovery hook
|
|
616
|
+
jsonErrorRecoveryHook = createJsonErrorRecoveryHook(ctx);
|
|
617
|
+
|
|
618
|
+
// Initialize foreground fallback manager for runtime model switching
|
|
619
|
+
foregroundFallback = new ForegroundFallbackManager(
|
|
620
|
+
ctx.client,
|
|
621
|
+
runtimeChains,
|
|
622
|
+
config.fallback?.enabled !== false &&
|
|
623
|
+
Object.keys(runtimeChains).length > 0,
|
|
624
|
+
);
|
|
625
|
+
|
|
626
|
+
// Initialize todo-continuation hook (opt-in auto-continue for
|
|
627
|
+
// incomplete todos)
|
|
628
|
+
todoContinuationHook = createTodoContinuationHook(ctx, {
|
|
629
|
+
maxContinuations: config.todoContinuation?.maxContinuations ?? 5,
|
|
630
|
+
cooldownMs: config.todoContinuation?.cooldownMs ?? 3000,
|
|
631
|
+
autoEnable: config.todoContinuation?.autoEnable ?? false,
|
|
632
|
+
autoEnableThreshold: config.todoContinuation?.autoEnableThreshold ?? 4,
|
|
633
|
+
});
|
|
634
|
+
taskSessionManagerHook = createTaskSessionManagerHook(ctx, {
|
|
635
|
+
maxSessionsPerAgent: config.sessionManager?.maxSessionsPerAgent ?? 2,
|
|
636
|
+
readContextMinLines: config.sessionManager?.readContextMinLines ?? 10,
|
|
637
|
+
readContextMaxFiles: config.sessionManager?.readContextMaxFiles ?? 8,
|
|
638
|
+
shouldManageSession: (sessionID) =>
|
|
639
|
+
sessionAgentMap.get(sessionID) === 'orchestrator',
|
|
640
|
+
});
|
|
641
|
+
contextPressureReminderHook = createContextPressureReminderHook({
|
|
642
|
+
enabled: config.contextPressure?.enabled ?? true,
|
|
643
|
+
warnThresholdPct: config.contextPressure?.warnThresholdPct ?? 75,
|
|
644
|
+
});
|
|
645
|
+
presetManager = createPresetManager(ctx, config);
|
|
646
|
+
usageService = createUsageService(ctx.client);
|
|
647
|
+
usageService.syncActiveAccounts();
|
|
648
|
+
|
|
649
|
+
if (isFirstInit) console.log(' \u{1F517} hooks: auto-update, phase-reminder, skills-filter, apply-patch, json-recovery, fallback, todo-continuation, session-manager, pressure-reminder');
|
|
650
|
+
|
|
651
|
+
toolCount =
|
|
652
|
+
Object.keys(delegateTools).length +
|
|
653
|
+
Object.keys(todoContinuationHook.tool).length +
|
|
654
|
+
1 + // webfetch
|
|
655
|
+
2 + // ast_grep_search, ast_grep_replace
|
|
656
|
+
(discoverMcpTool ? 1 : 0) + // discover_mcp_servers
|
|
657
|
+
(discoverSkillTool ? 1 : 0); // discover_skills_online
|
|
658
|
+
|
|
659
|
+
if (isFirstInit) {
|
|
660
|
+
console.log(`\u{2705} opencode-dux initialized (${Object.keys(agents).length} agents, ${toolCount} tools, ${Object.keys(builtinMcps).length} MCPs)`);
|
|
661
|
+
didLogVerboseInit = true;
|
|
662
|
+
}
|
|
663
|
+
} catch (err) {
|
|
664
|
+
// Plugin init failed: log visibly before re-throwing so the user
|
|
665
|
+
// sees something actionable instead of a silent "loaded but empty".
|
|
666
|
+
log('[plugin] FATAL: init failed', String(err));
|
|
667
|
+
await appLog(
|
|
668
|
+
ctx,
|
|
669
|
+
'error',
|
|
670
|
+
`INIT FAILED: ${String(err)}. Report at github.com/bakhtiar-personal-work/opencode-dux/issues`,
|
|
671
|
+
);
|
|
672
|
+
throw err;
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
// ── Health check: validate registrations ────────────────────────────
|
|
676
|
+
const agentCount = Object.keys(agents).length;
|
|
677
|
+
const mcpCount = Object.keys(builtinMcps).length;
|
|
678
|
+
const mcpThreshold = HEALTH_CHECK.minMcps;
|
|
679
|
+
|
|
680
|
+
if (
|
|
681
|
+
agentCount < HEALTH_CHECK.minAgents ||
|
|
682
|
+
toolCount < HEALTH_CHECK.minTools ||
|
|
683
|
+
mcpCount < mcpThreshold
|
|
684
|
+
) {
|
|
685
|
+
const msg = [
|
|
686
|
+
'Health check: registrations suspiciously low.',
|
|
687
|
+
` agents: ${agentCount} (expected >=${HEALTH_CHECK.minAgents})`,
|
|
688
|
+
` tools: ${toolCount} (expected >=${HEALTH_CHECK.minTools})`,
|
|
689
|
+
` mcps: ${mcpCount} (expected >=${mcpThreshold})`,
|
|
690
|
+
'This usually means a dependency failed to resolve (jsdom, etc).',
|
|
691
|
+
].join('\n');
|
|
692
|
+
log(`[plugin] WARN: ${msg}`);
|
|
693
|
+
await appLog(ctx, 'warn', msg);
|
|
694
|
+
} else {
|
|
695
|
+
log('[plugin] health check passed', {
|
|
696
|
+
agents: agentCount,
|
|
697
|
+
tools: toolCount,
|
|
698
|
+
mcps: mcpCount,
|
|
699
|
+
});
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
// ── Probe jsdom (async, non-blocking) ───────────────────────────────
|
|
703
|
+
// Don't await this; we don't want to block init. The warning will
|
|
704
|
+
// appear shortly after startup if jsdom is broken.
|
|
705
|
+
probeJSDOM().then((err) => {
|
|
706
|
+
if (err) {
|
|
707
|
+
const msg = `jsdom probe failed; webfetch tool will not work: ${err}`;
|
|
708
|
+
log(`[plugin] WARN: ${msg}`);
|
|
709
|
+
appLog(ctx, 'warn', msg).catch(() => {});
|
|
710
|
+
}
|
|
711
|
+
});
|
|
712
|
+
|
|
713
|
+
return {
|
|
714
|
+
name: 'opencode-dux',
|
|
715
|
+
|
|
716
|
+
agent: agents,
|
|
717
|
+
|
|
718
|
+
tool: {
|
|
719
|
+
...delegateTools,
|
|
720
|
+
webfetch,
|
|
721
|
+
...todoContinuationHook.tool,
|
|
722
|
+
ast_grep_search,
|
|
723
|
+
ast_grep_replace,
|
|
724
|
+
...(discoverMcpTool ? { discover_mcp_servers: discoverMcpTool } : {}),
|
|
725
|
+
...(discoverSkillTool
|
|
726
|
+
? { discover_skills_online: discoverSkillTool }
|
|
727
|
+
: {}),
|
|
728
|
+
},
|
|
729
|
+
|
|
730
|
+
mcp: builtinMcps,
|
|
731
|
+
|
|
732
|
+
config: async (opencodeConfig: Record<string, unknown>) => {
|
|
733
|
+
// Only set default_agent if not already configured by the user
|
|
734
|
+
// and the plugin config doesn't explicitly disable this behavior
|
|
735
|
+
if (
|
|
736
|
+
config.setDefaultAgent !== false &&
|
|
737
|
+
!(opencodeConfig as { default_agent?: string }).default_agent
|
|
738
|
+
) {
|
|
739
|
+
(opencodeConfig as { default_agent?: string }).default_agent =
|
|
740
|
+
'orchestrator';
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
// Merge Agent configs - per-agent shallow merge to preserve
|
|
744
|
+
// user-supplied fields (e.g. tools, permission) from opencode.json
|
|
745
|
+
if (!opencodeConfig.agent) {
|
|
746
|
+
opencodeConfig.agent = { ...agents };
|
|
747
|
+
} else {
|
|
748
|
+
for (const [name, pluginAgent] of Object.entries(agents)) {
|
|
749
|
+
const existing = (opencodeConfig.agent as Record<string, unknown>)[
|
|
750
|
+
name
|
|
751
|
+
] as Record<string, unknown> | undefined;
|
|
752
|
+
if (existing) {
|
|
753
|
+
// Shallow merge: plugin defaults first, user overrides win
|
|
754
|
+
(opencodeConfig.agent as Record<string, unknown>)[name] = {
|
|
755
|
+
...pluginAgent,
|
|
756
|
+
...existing,
|
|
757
|
+
};
|
|
758
|
+
} else {
|
|
759
|
+
(opencodeConfig.agent as Record<string, unknown>)[name] = {
|
|
760
|
+
...pluginAgent,
|
|
761
|
+
};
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
const configAgent = opencodeConfig.agent as Record<string, unknown>;
|
|
766
|
+
|
|
767
|
+
// Model resolution for foreground agents: combine _modelArray
|
|
768
|
+
// entries with fallback.chains config, then pick the first model in
|
|
769
|
+
// the effective array for startup-time selection.
|
|
770
|
+
//
|
|
771
|
+
// Runtime failover on API errors (e.g. rate limits
|
|
772
|
+
// mid-conversation) is handled separately by
|
|
773
|
+
// ForegroundFallbackManager via the event hook.
|
|
774
|
+
const fallbackChainsEnabled = config.fallback?.enabled !== false;
|
|
775
|
+
const fallbackChains = fallbackChainsEnabled
|
|
776
|
+
? ((config.fallback?.chains as Record<string, string[] | undefined>) ??
|
|
777
|
+
{})
|
|
778
|
+
: {};
|
|
779
|
+
|
|
780
|
+
// Build effective model arrays: seed from _modelArray, then append
|
|
781
|
+
// fallback.chains entries so the resolver considers the full chain
|
|
782
|
+
// when picking the best available provider at startup.
|
|
783
|
+
const effectiveArrays: Record<
|
|
784
|
+
string,
|
|
785
|
+
Array<{ id: string; variant?: string }>
|
|
786
|
+
> = {};
|
|
787
|
+
|
|
788
|
+
for (const [agentName, models] of Object.entries(modelArrayMap)) {
|
|
789
|
+
effectiveArrays[agentName] = [...models];
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
for (const [agentName, chainModels] of Object.entries(fallbackChains)) {
|
|
793
|
+
if (!chainModels || chainModels.length === 0) continue;
|
|
794
|
+
|
|
795
|
+
if (!effectiveArrays[agentName]) {
|
|
796
|
+
// Agent has no _modelArray - seed from its current string model
|
|
797
|
+
// so the fallback chain appends after it rather than replacing
|
|
798
|
+
// it.
|
|
799
|
+
const entry = configAgent[agentName] as
|
|
800
|
+
| Record<string, unknown>
|
|
801
|
+
| undefined;
|
|
802
|
+
const currentModel =
|
|
803
|
+
typeof entry?.model === 'string' ? entry.model : undefined;
|
|
804
|
+
effectiveArrays[agentName] = currentModel
|
|
805
|
+
? [{ id: currentModel }]
|
|
806
|
+
: [];
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
const seen = new Set(effectiveArrays[agentName].map((m) => m.id));
|
|
810
|
+
for (const chainModel of chainModels) {
|
|
811
|
+
if (!seen.has(chainModel)) {
|
|
812
|
+
seen.add(chainModel);
|
|
813
|
+
effectiveArrays[agentName].push({ id: chainModel });
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
if (Object.keys(effectiveArrays).length > 0) {
|
|
819
|
+
for (const [agentName, modelArray] of Object.entries(effectiveArrays)) {
|
|
820
|
+
if (modelArray.length === 0) continue;
|
|
821
|
+
|
|
822
|
+
// Use the first model in the effective array. Not all providers
|
|
823
|
+
// require entries in opencodeConfig.provider - some are loaded
|
|
824
|
+
// automatically by opencode (e.g. github-copilot, openrouter).
|
|
825
|
+
// We cannot distinguish these from truly unconfigured providers
|
|
826
|
+
// at config-hook time, so we cannot gate on the provider config
|
|
827
|
+
// keys. Runtime failover is handled separately by
|
|
828
|
+
// ForegroundFallbackManager.
|
|
829
|
+
const chosen = modelArray[0];
|
|
830
|
+
const entry = configAgent[agentName] as
|
|
831
|
+
| Record<string, unknown>
|
|
832
|
+
| undefined;
|
|
833
|
+
if (entry) {
|
|
834
|
+
entry.model = chosen.id;
|
|
835
|
+
if (chosen.variant) {
|
|
836
|
+
entry.variant = chosen.variant;
|
|
837
|
+
}
|
|
838
|
+
} else {
|
|
839
|
+
// Agent exists in slim but not in opencodeConfig.agent -
|
|
840
|
+
// create entry
|
|
841
|
+
(configAgent as Record<string, unknown>)[agentName] = {
|
|
842
|
+
model: chosen.id,
|
|
843
|
+
...(chosen.variant ? { variant: chosen.variant } : {}),
|
|
844
|
+
};
|
|
845
|
+
}
|
|
846
|
+
log('[plugin] resolved model from array', {
|
|
847
|
+
agent: agentName,
|
|
848
|
+
model: chosen.id,
|
|
849
|
+
variant: chosen.variant,
|
|
850
|
+
});
|
|
851
|
+
}
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
// Runtime preset override: if /preset switched to a runtime preset,
|
|
855
|
+
// override the model/variant/temperature from the preset's agent
|
|
856
|
+
// config. This runs after the normal model resolution because the
|
|
857
|
+
// config() hook re-runs with stale modelArrayMap after dispose(),
|
|
858
|
+
// but the runtime preset data is in the captured `config` closure.
|
|
859
|
+
const runtimePresetName = getActiveRuntimePreset();
|
|
860
|
+
if (runtimePresetName && config.presets?.[runtimePresetName]) {
|
|
861
|
+
const runtimePreset = config.presets[runtimePresetName];
|
|
862
|
+
for (const [agentName, override] of Object.entries(runtimePreset)) {
|
|
863
|
+
// Resolve legacy alias keys (e.g. "explore" → "explorer")
|
|
864
|
+
// so presets using aliases work in this path.
|
|
865
|
+
const resolvedName = AGENT_ALIASES[agentName] ?? agentName;
|
|
866
|
+
const entry = configAgent[resolvedName] as
|
|
867
|
+
| Record<string, unknown>
|
|
868
|
+
| undefined;
|
|
869
|
+
if (!entry) continue;
|
|
870
|
+
|
|
871
|
+
if (typeof override.model === 'string') {
|
|
872
|
+
entry.model = override.model;
|
|
873
|
+
} else if (
|
|
874
|
+
Array.isArray(override.model) &&
|
|
875
|
+
override.model.length > 0
|
|
876
|
+
) {
|
|
877
|
+
const first = override.model[0];
|
|
878
|
+
entry.model = typeof first === 'string' ? first : first.id;
|
|
879
|
+
// Extract inline variant from array-form model entry
|
|
880
|
+
if (typeof first !== 'string' && first.variant) {
|
|
881
|
+
entry.variant = first.variant;
|
|
882
|
+
}
|
|
883
|
+
}
|
|
884
|
+
// Explicitly set or clear scalar fields so switching from
|
|
885
|
+
// Preset A (which sets a field) to Preset B (which doesn't)
|
|
886
|
+
// doesn't leave stale values behind.
|
|
887
|
+
if (typeof override.variant === 'string') {
|
|
888
|
+
entry.variant = override.variant;
|
|
889
|
+
} else if ('variant' in override) {
|
|
890
|
+
delete entry.variant;
|
|
891
|
+
}
|
|
892
|
+
if (typeof override.temperature === 'number') {
|
|
893
|
+
entry.temperature = override.temperature;
|
|
894
|
+
} else if ('temperature' in override) {
|
|
895
|
+
delete entry.temperature;
|
|
896
|
+
}
|
|
897
|
+
if (
|
|
898
|
+
override.options &&
|
|
899
|
+
typeof override.options === 'object' &&
|
|
900
|
+
!Array.isArray(override.options)
|
|
901
|
+
) {
|
|
902
|
+
entry.options = override.options;
|
|
903
|
+
} else if ('options' in override) {
|
|
904
|
+
delete entry.options;
|
|
905
|
+
}
|
|
906
|
+
log('[plugin] runtime preset override', {
|
|
907
|
+
preset: runtimePresetName,
|
|
908
|
+
agent: agentName,
|
|
909
|
+
model: entry.model as string,
|
|
910
|
+
});
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
// Reset agents from the previous preset that aren't in the new one.
|
|
914
|
+
// The stale model resolution above overwrites the reset values sent
|
|
915
|
+
// by preset-manager, so we re-apply them here from config-file
|
|
916
|
+
// baseline.
|
|
917
|
+
const prevPresetName = getPreviousRuntimePreset();
|
|
918
|
+
if (prevPresetName && config.presets?.[prevPresetName]) {
|
|
919
|
+
const prevPreset = config.presets[prevPresetName];
|
|
920
|
+
// Build resolved key set from new preset for correct comparison
|
|
921
|
+
// (handles alias keys like "explore" → "explorer")
|
|
922
|
+
const newPresetResolved = new Set(
|
|
923
|
+
Object.keys(runtimePreset).map((k) => AGENT_ALIASES[k] ?? k),
|
|
924
|
+
);
|
|
925
|
+
for (const agentName of Object.keys(prevPreset)) {
|
|
926
|
+
const resolvedName = AGENT_ALIASES[agentName] ?? agentName;
|
|
927
|
+
if (newPresetResolved.has(resolvedName)) continue; // new preset handles it
|
|
928
|
+
const entry = configAgent[resolvedName] as
|
|
929
|
+
| Record<string, unknown>
|
|
930
|
+
| undefined;
|
|
931
|
+
if (!entry) continue;
|
|
932
|
+
// Reset to config-file baseline. Use the previous preset's
|
|
933
|
+
// override to identify which fields to clear even when the
|
|
934
|
+
// baseline doesn't define them.
|
|
935
|
+
const baseline = config.agents?.[resolvedName];
|
|
936
|
+
const prevOverride = prevPreset[agentName] as
|
|
937
|
+
| AgentOverrideConfig
|
|
938
|
+
| undefined;
|
|
939
|
+
if (typeof baseline?.model === 'string') {
|
|
940
|
+
entry.model = baseline.model;
|
|
941
|
+
}
|
|
942
|
+
if (typeof baseline?.variant === 'string') {
|
|
943
|
+
entry.variant = baseline.variant;
|
|
944
|
+
} else if (prevOverride && 'variant' in prevOverride) {
|
|
945
|
+
delete entry.variant;
|
|
946
|
+
}
|
|
947
|
+
if (typeof baseline?.temperature === 'number') {
|
|
948
|
+
entry.temperature = baseline.temperature;
|
|
949
|
+
} else if (prevOverride && 'temperature' in prevOverride) {
|
|
950
|
+
delete entry.temperature;
|
|
951
|
+
}
|
|
952
|
+
if (
|
|
953
|
+
baseline?.options &&
|
|
954
|
+
typeof baseline.options === 'object' &&
|
|
955
|
+
!Array.isArray(baseline.options)
|
|
956
|
+
) {
|
|
957
|
+
entry.options = baseline.options;
|
|
958
|
+
} else if (prevOverride && 'options' in prevOverride) {
|
|
959
|
+
delete entry.options;
|
|
960
|
+
}
|
|
961
|
+
log('[plugin] runtime preset reset from previous', {
|
|
962
|
+
previousPreset: prevPresetName,
|
|
963
|
+
agent: resolvedName,
|
|
964
|
+
model: entry.model as string,
|
|
965
|
+
});
|
|
966
|
+
}
|
|
967
|
+
}
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
// Merge MCP configs
|
|
971
|
+
const configMcp = opencodeConfig.mcp as
|
|
972
|
+
| Record<string, unknown>
|
|
973
|
+
| undefined;
|
|
974
|
+
if (!configMcp) {
|
|
975
|
+
opencodeConfig.mcp = { ...builtinMcps };
|
|
976
|
+
} else {
|
|
977
|
+
Object.assign(configMcp, builtinMcps);
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
// Register /auto-continue command so OpenCode recognizes it.
|
|
981
|
+
// Actual handling is done by command.execute.before hook below
|
|
982
|
+
// (no LLM round-trip - injected directly into output.parts).
|
|
983
|
+
const configCommand = opencodeConfig.command as
|
|
984
|
+
| Record<string, unknown>
|
|
985
|
+
| undefined;
|
|
986
|
+
if (!configCommand?.['auto-continue']) {
|
|
987
|
+
if (!opencodeConfig.command) {
|
|
988
|
+
opencodeConfig.command = {};
|
|
989
|
+
}
|
|
990
|
+
(opencodeConfig.command as Record<string, unknown>)['auto-continue'] = {
|
|
991
|
+
template: 'Call the auto_continue tool with enabled=true',
|
|
992
|
+
description:
|
|
993
|
+
'Enable auto-continuation - orchestrator keeps working through incomplete todos',
|
|
994
|
+
};
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
presetManager.registerCommand(opencodeConfig);
|
|
998
|
+
usageService?.registerCommand(opencodeConfig);
|
|
999
|
+
|
|
1000
|
+
// One-time startup summary: log bundled skills, installed skills, and MCPs
|
|
1001
|
+
if (!didLogStartupSummary) {
|
|
1002
|
+
didLogStartupSummary = true;
|
|
1003
|
+
|
|
1004
|
+
const bundledSkillsDir = join(ctx.directory, 'src', 'skills');
|
|
1005
|
+
if (existsSync(bundledSkillsDir)) {
|
|
1006
|
+
try {
|
|
1007
|
+
const skills = readdirSync(bundledSkillsDir, { withFileTypes: true })
|
|
1008
|
+
.filter((d) => d.isDirectory())
|
|
1009
|
+
.map((d) => d.name);
|
|
1010
|
+
if (skills.length > 0) {
|
|
1011
|
+
console.log(`\u{1F4E6} Bundled skills available: ${skills.join(', ')}`);
|
|
1012
|
+
}
|
|
1013
|
+
} catch {
|
|
1014
|
+
// Silently ignore scan failures
|
|
1015
|
+
}
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
const installedSkills = await discoverSkills(ctx.directory);
|
|
1019
|
+
if (installedSkills.length > 0) {
|
|
1020
|
+
console.log(
|
|
1021
|
+
`\u{1F4A1} Auto-discovered ${installedSkills.length} skill(s): ${installedSkills.map((s) => s.name).join(', ')}`,
|
|
1022
|
+
);
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
const mcpKeys = Object.keys(opencodeConfig.mcp as Record<string, unknown> ?? builtinMcps);
|
|
1026
|
+
if (mcpKeys.length > 0) {
|
|
1027
|
+
console.log(`\u{1F50C} MCP servers: ${mcpKeys.join(', ')}`);
|
|
1028
|
+
}
|
|
1029
|
+
}
|
|
1030
|
+
},
|
|
1031
|
+
|
|
1032
|
+
event: async (input) => {
|
|
1033
|
+
const event = input.event as {
|
|
1034
|
+
type: string;
|
|
1035
|
+
properties?: {
|
|
1036
|
+
info?: {
|
|
1037
|
+
id?: string;
|
|
1038
|
+
parentID?: string;
|
|
1039
|
+
title?: string;
|
|
1040
|
+
agent?: string;
|
|
1041
|
+
providerID?: string;
|
|
1042
|
+
modelID?: string;
|
|
1043
|
+
variant?: string;
|
|
1044
|
+
sessionID?: string;
|
|
1045
|
+
directory?: string;
|
|
1046
|
+
};
|
|
1047
|
+
sessionID?: string;
|
|
1048
|
+
error?: { name?: string };
|
|
1049
|
+
status?: { type: string };
|
|
1050
|
+
part?: {
|
|
1051
|
+
type?: string;
|
|
1052
|
+
sessionID?: string;
|
|
1053
|
+
tokens?: {
|
|
1054
|
+
input?: number;
|
|
1055
|
+
output?: number;
|
|
1056
|
+
reasoning?: number;
|
|
1057
|
+
cache?: { read?: number; write?: number };
|
|
1058
|
+
};
|
|
1059
|
+
};
|
|
1060
|
+
providerID?: string;
|
|
1061
|
+
modelID?: string;
|
|
1062
|
+
};
|
|
1063
|
+
};
|
|
1064
|
+
|
|
1065
|
+
// Handle streaming token updates from step-finish parts
|
|
1066
|
+
if (event.type === 'message.part.updated') {
|
|
1067
|
+
const part = event.properties?.part as
|
|
1068
|
+
| {
|
|
1069
|
+
type?: string;
|
|
1070
|
+
sessionID?: string;
|
|
1071
|
+
tokens?: {
|
|
1072
|
+
input?: number;
|
|
1073
|
+
output?: number;
|
|
1074
|
+
reasoning?: number;
|
|
1075
|
+
cache?: { read?: number; write?: number };
|
|
1076
|
+
};
|
|
1077
|
+
}
|
|
1078
|
+
| undefined;
|
|
1079
|
+
|
|
1080
|
+
if (part?.type === 'step-finish' && part?.sessionID && part?.tokens) {
|
|
1081
|
+
const input = part.tokens.input ?? 0;
|
|
1082
|
+
const output = part.tokens.output ?? 0;
|
|
1083
|
+
const reasoning = part.tokens.reasoning ?? 0;
|
|
1084
|
+
const cacheRead = part.tokens.cache?.read ?? 0;
|
|
1085
|
+
// Don't record cache tokens during streaming - they're cumulative
|
|
1086
|
+
// per message and will be correctly summed by the message.updated
|
|
1087
|
+
// handler
|
|
1088
|
+
|
|
1089
|
+
if (input > 0 || output > 0 || reasoning > 0 || cacheRead > 0) {
|
|
1090
|
+
// Calculate contextUsed from the same components as
|
|
1091
|
+
// the sidebar Input + Output rows.
|
|
1092
|
+
const streamContextUsed = input + cacheRead + output + reasoning;
|
|
1093
|
+
|
|
1094
|
+
// Look up contextLimit from cache using session's model
|
|
1095
|
+
let streamContextLimit = 0;
|
|
1096
|
+
const sessionModel = mergedSessionModels(readTuiSnapshot())[
|
|
1097
|
+
part.sessionID
|
|
1098
|
+
];
|
|
1099
|
+
if (sessionModel) {
|
|
1100
|
+
streamContextLimit =
|
|
1101
|
+
_modelContextLimitCache.get(sessionModel) ?? 0;
|
|
1102
|
+
}
|
|
1103
|
+
|
|
1104
|
+
const streamContextPct =
|
|
1105
|
+
streamContextLimit > 0
|
|
1106
|
+
? (streamContextUsed / streamContextLimit) * 100
|
|
1107
|
+
: 0;
|
|
1108
|
+
|
|
1109
|
+
recordSessionUsage({
|
|
1110
|
+
sessionID: part.sessionID,
|
|
1111
|
+
contextUsed: streamContextUsed,
|
|
1112
|
+
contextLimit: streamContextLimit,
|
|
1113
|
+
contextPct: streamContextPct,
|
|
1114
|
+
input,
|
|
1115
|
+
output,
|
|
1116
|
+
reasoning,
|
|
1117
|
+
cacheRead,
|
|
1118
|
+
cacheWrite: part.tokens.cache?.write ?? 0,
|
|
1119
|
+
});
|
|
1120
|
+
}
|
|
1121
|
+
}
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
if (event.type === 'message.updated') {
|
|
1125
|
+
const info = event.properties?.info;
|
|
1126
|
+
const sessionIDForTitle =
|
|
1127
|
+
(info && typeof info.sessionID === 'string' && info.sessionID) ||
|
|
1128
|
+
(typeof event.properties?.sessionID === 'string'
|
|
1129
|
+
? event.properties.sessionID
|
|
1130
|
+
: undefined);
|
|
1131
|
+
if (
|
|
1132
|
+
sessionIDForTitle &&
|
|
1133
|
+
info &&
|
|
1134
|
+
typeof info.title === 'string' &&
|
|
1135
|
+
info.title.trim().length > 0
|
|
1136
|
+
) {
|
|
1137
|
+
recordSessionTitle({
|
|
1138
|
+
sessionID: sessionIDForTitle,
|
|
1139
|
+
title: info.title,
|
|
1140
|
+
});
|
|
1141
|
+
}
|
|
1142
|
+
if (info) {
|
|
1143
|
+
const sessionID = info.sessionID ?? event.properties?.sessionID;
|
|
1144
|
+
if (sessionID) {
|
|
1145
|
+
if (
|
|
1146
|
+
typeof info.providerID === 'string' &&
|
|
1147
|
+
typeof info.modelID === 'string'
|
|
1148
|
+
) {
|
|
1149
|
+
recordSessionModel({
|
|
1150
|
+
sessionID,
|
|
1151
|
+
model: `${info.providerID}/${info.modelID}`,
|
|
1152
|
+
});
|
|
1153
|
+
}
|
|
1154
|
+
if (typeof info.variant === 'string' && info.variant.trim()) {
|
|
1155
|
+
recordSessionVariant({
|
|
1156
|
+
sessionID,
|
|
1157
|
+
variant: info.variant.trim(),
|
|
1158
|
+
});
|
|
1159
|
+
}
|
|
1160
|
+
}
|
|
1161
|
+
}
|
|
1162
|
+
|
|
1163
|
+
const sessionID = info?.sessionID ?? event.properties?.sessionID;
|
|
1164
|
+
if (sessionID) {
|
|
1165
|
+
try {
|
|
1166
|
+
// Fetch messages and extract tokens from last assistant message only
|
|
1167
|
+
const messagesResult = await ctx.client.session.messages({
|
|
1168
|
+
path: { id: sessionID },
|
|
1169
|
+
});
|
|
1170
|
+
const allMessages = Array.isArray(messagesResult.data)
|
|
1171
|
+
? messagesResult.data
|
|
1172
|
+
: [];
|
|
1173
|
+
const assistantMsgs = allMessages.filter(
|
|
1174
|
+
(m) =>
|
|
1175
|
+
(m as { info?: { role?: string } }).info?.role === 'assistant',
|
|
1176
|
+
);
|
|
1177
|
+
|
|
1178
|
+
// Extract tokens from last assistant message only (SDK supplies cumulative values)
|
|
1179
|
+
let totalInput = 0;
|
|
1180
|
+
let totalOutput = 0;
|
|
1181
|
+
let totalReasoning = 0;
|
|
1182
|
+
let totalCacheRead = 0;
|
|
1183
|
+
let totalCacheWrite = 0;
|
|
1184
|
+
let contextLimit = 0;
|
|
1185
|
+
let contextUsed = 0;
|
|
1186
|
+
let contextPct = 0;
|
|
1187
|
+
|
|
1188
|
+
// Ensure context limit cache is populated before recording usage
|
|
1189
|
+
await ensureModelContextLimits(ctx.client).catch(() => {});
|
|
1190
|
+
|
|
1191
|
+
const lastTokenMsg = [...assistantMsgs]
|
|
1192
|
+
.reverse()
|
|
1193
|
+
.find((m) => readTokenTelemetry(m));
|
|
1194
|
+
if (lastTokenMsg) {
|
|
1195
|
+
const telemetry = readTokenTelemetry(lastTokenMsg);
|
|
1196
|
+
if (telemetry) {
|
|
1197
|
+
totalInput = telemetry.input;
|
|
1198
|
+
totalOutput = telemetry.output;
|
|
1199
|
+
totalReasoning = telemetry.reasoning;
|
|
1200
|
+
totalCacheRead = telemetry.cacheRead;
|
|
1201
|
+
totalCacheWrite = telemetry.cacheWrite;
|
|
1202
|
+
contextLimit = telemetry.contextLimit;
|
|
1203
|
+
// Sidebar expects CTX used to match Input + Output tokens.
|
|
1204
|
+
// Input row = input + cacheRead
|
|
1205
|
+
// Output row = output + reasoning
|
|
1206
|
+
contextUsed =
|
|
1207
|
+
telemetry.input +
|
|
1208
|
+
telemetry.cacheRead +
|
|
1209
|
+
telemetry.output +
|
|
1210
|
+
telemetry.reasoning;
|
|
1211
|
+
contextPct =
|
|
1212
|
+
contextLimit > 0 ? (contextUsed / contextLimit) * 100 : 0;
|
|
1213
|
+
}
|
|
1214
|
+
}
|
|
1215
|
+
|
|
1216
|
+
// Fallback: if message didn't provide context limit, look up from
|
|
1217
|
+
// cache using the model associated with this session.
|
|
1218
|
+
if (contextLimit === 0) {
|
|
1219
|
+
const model = mergedSessionModels(readTuiSnapshot())[sessionID];
|
|
1220
|
+
const cachedLimit = model
|
|
1221
|
+
? _modelContextLimitCache.get(model)
|
|
1222
|
+
: undefined;
|
|
1223
|
+
if (cachedLimit && cachedLimit > 0) {
|
|
1224
|
+
contextLimit = cachedLimit;
|
|
1225
|
+
contextPct = (contextUsed / contextLimit) * 100;
|
|
1226
|
+
}
|
|
1227
|
+
}
|
|
1228
|
+
|
|
1229
|
+
if (contextUsed > 0 || totalInput > 0 || totalOutput > 0) {
|
|
1230
|
+
recordSessionUsage({
|
|
1231
|
+
sessionID,
|
|
1232
|
+
contextUsed,
|
|
1233
|
+
contextLimit,
|
|
1234
|
+
contextPct,
|
|
1235
|
+
input: totalInput,
|
|
1236
|
+
output: totalOutput,
|
|
1237
|
+
reasoning: totalReasoning,
|
|
1238
|
+
cacheRead: totalCacheRead,
|
|
1239
|
+
cacheWrite: totalCacheWrite,
|
|
1240
|
+
});
|
|
1241
|
+
}
|
|
1242
|
+
} catch {
|
|
1243
|
+
// Usage telemetry is best-effort for sidebar display.
|
|
1244
|
+
}
|
|
1245
|
+
}
|
|
1246
|
+
}
|
|
1247
|
+
|
|
1248
|
+
if (event.type === 'session.created') {
|
|
1249
|
+
const childSessionId = event.properties?.info?.id;
|
|
1250
|
+
const parentSessionId = event.properties?.info?.parentID;
|
|
1251
|
+
const title = event.properties?.info?.title;
|
|
1252
|
+
const directory = event.properties?.info?.directory ?? ctx.directory;
|
|
1253
|
+
if (depthTracker && childSessionId && parentSessionId) {
|
|
1254
|
+
depthTracker.registerChild(parentSessionId, childSessionId);
|
|
1255
|
+
}
|
|
1256
|
+
if (childSessionId) {
|
|
1257
|
+
recordChildSessionSnapshot({
|
|
1258
|
+
sessionID: childSessionId,
|
|
1259
|
+
title: title ?? '',
|
|
1260
|
+
parentSessionId:
|
|
1261
|
+
typeof parentSessionId === 'string' ? parentSessionId : undefined,
|
|
1262
|
+
projectPath: directory ? directory : undefined,
|
|
1263
|
+
});
|
|
1264
|
+
}
|
|
1265
|
+
}
|
|
1266
|
+
|
|
1267
|
+
if (event.type === 'session.updated') {
|
|
1268
|
+
const info = event.properties?.info;
|
|
1269
|
+
const sid =
|
|
1270
|
+
(typeof info?.id === 'string' && info.id) ||
|
|
1271
|
+
(typeof info?.sessionID === 'string' && info.sessionID) ||
|
|
1272
|
+
(typeof event.properties?.sessionID === 'string'
|
|
1273
|
+
? event.properties.sessionID
|
|
1274
|
+
: undefined);
|
|
1275
|
+
if (
|
|
1276
|
+
sid &&
|
|
1277
|
+
info &&
|
|
1278
|
+
typeof info.title === 'string' &&
|
|
1279
|
+
info.title.trim().length > 0
|
|
1280
|
+
) {
|
|
1281
|
+
recordSessionTitle({ sessionID: sid, title: info.title });
|
|
1282
|
+
}
|
|
1283
|
+
if (
|
|
1284
|
+
sid &&
|
|
1285
|
+
info &&
|
|
1286
|
+
typeof info.variant === 'string' &&
|
|
1287
|
+
info.variant.trim().length > 0
|
|
1288
|
+
) {
|
|
1289
|
+
recordSessionVariant({
|
|
1290
|
+
sessionID: sid,
|
|
1291
|
+
variant: info.variant.trim(),
|
|
1292
|
+
});
|
|
1293
|
+
}
|
|
1294
|
+
}
|
|
1295
|
+
|
|
1296
|
+
// Runtime model fallback for foreground agents (rate-limit detection)
|
|
1297
|
+
await foregroundFallback.handleEvent(input.event);
|
|
1298
|
+
|
|
1299
|
+
// Todo-continuation: auto-continue orchestrator on incomplete todos
|
|
1300
|
+
await todoContinuationHook.handleEvent(input);
|
|
1301
|
+
|
|
1302
|
+
// Handle auto-update checking
|
|
1303
|
+
await autoUpdateChecker.event(input);
|
|
1304
|
+
|
|
1305
|
+
// Track session.status to update sidebar status display and
|
|
1306
|
+
// active session counts. Non-orchestrator idle means done.
|
|
1307
|
+
if (event.type === 'session.status') {
|
|
1308
|
+
const statusType = event.properties?.status?.type;
|
|
1309
|
+
const sessionID = event.properties?.sessionID;
|
|
1310
|
+
if (sessionID && statusType) {
|
|
1311
|
+
patchSessionTreeStatusFromOpenCode(sessionID, statusType);
|
|
1312
|
+
}
|
|
1313
|
+
if (sessionID && statusType === 'idle') {
|
|
1314
|
+
if (sessionAgentMap.get(sessionID) === 'orchestrator') {
|
|
1315
|
+
// Cascade abort: stop any still-running blocking children
|
|
1316
|
+
const snapshot = readTuiSnapshot();
|
|
1317
|
+
for (const [childId, child] of Object.entries(
|
|
1318
|
+
mergedSessionTree(snapshot),
|
|
1319
|
+
)) {
|
|
1320
|
+
if (
|
|
1321
|
+
child.parentId === sessionID &&
|
|
1322
|
+
child.status === 'busy' &&
|
|
1323
|
+
child.mode !== 'fire_forget'
|
|
1324
|
+
) {
|
|
1325
|
+
ctx.client.session
|
|
1326
|
+
.abort({ path: { id: childId } })
|
|
1327
|
+
.catch(() => {});
|
|
1328
|
+
}
|
|
1329
|
+
}
|
|
1330
|
+
recordSessionNode({
|
|
1331
|
+
sessionID,
|
|
1332
|
+
agent: 'orchestrator',
|
|
1333
|
+
status: 'idle',
|
|
1334
|
+
});
|
|
1335
|
+
// Trigger OpenCode Go usage data refresh
|
|
1336
|
+
usageService?.onOrchestratorIdle();
|
|
1337
|
+
// Set finishedAt with a 3-second buffer so the orchestrator's
|
|
1338
|
+
// flash timer starts AFTER children have cleared from the tree.
|
|
1339
|
+
// Children were just marked idle (recordSessionDone) and need
|
|
1340
|
+
// FLASH_DURATION_MS+1s to flash out. The orchestrator shows a
|
|
1341
|
+
// spinner while children are visible, then flashes after they clear.
|
|
1342
|
+
updateSnapshot((s) => {
|
|
1343
|
+
for (const bundle of Object.values(s.sessions)) {
|
|
1344
|
+
const node = bundle.tree[sessionID];
|
|
1345
|
+
if (node) {
|
|
1346
|
+
node.finishedAt = Date.now() + 3000;
|
|
1347
|
+
bundle.lastActivityAt = Date.now();
|
|
1348
|
+
}
|
|
1349
|
+
}
|
|
1350
|
+
});
|
|
1351
|
+
const storeNode = sessionTreeStore[sessionID];
|
|
1352
|
+
if (storeNode) storeNode.finishedAt = Date.now() + 3000;
|
|
1353
|
+
} else {
|
|
1354
|
+
recordSessionEnd(sessionID);
|
|
1355
|
+
recordSessionDone(sessionID);
|
|
1356
|
+
}
|
|
1357
|
+
}
|
|
1358
|
+
}
|
|
1359
|
+
|
|
1360
|
+
await taskSessionManagerHook.event(
|
|
1361
|
+
input as {
|
|
1362
|
+
event: {
|
|
1363
|
+
type: string;
|
|
1364
|
+
properties?: { info?: { id?: string }; sessionID?: string };
|
|
1365
|
+
};
|
|
1366
|
+
},
|
|
1367
|
+
);
|
|
1368
|
+
|
|
1369
|
+
if (event.type === 'session.deleted') {
|
|
1370
|
+
const sessionID =
|
|
1371
|
+
event.properties?.info?.id ?? event.properties?.sessionID;
|
|
1372
|
+
if (sessionID) {
|
|
1373
|
+
recordSessionEnd(sessionID);
|
|
1374
|
+
recordSessionDone(sessionID);
|
|
1375
|
+
deleteSessionEntries(sessionID);
|
|
1376
|
+
if (depthTracker) depthTracker.cleanup(sessionID);
|
|
1377
|
+
deletingSessions.delete(sessionID);
|
|
1378
|
+
}
|
|
1379
|
+
}
|
|
1380
|
+
},
|
|
1381
|
+
|
|
1382
|
+
// Best-effort rescue only for stale apply_patch input before native
|
|
1383
|
+
// execution
|
|
1384
|
+
'tool.execute.before': async (input, output) => {
|
|
1385
|
+
await applyPatchHook['tool.execute.before'](
|
|
1386
|
+
input as {
|
|
1387
|
+
tool: string;
|
|
1388
|
+
directory?: string;
|
|
1389
|
+
},
|
|
1390
|
+
output as {
|
|
1391
|
+
args?: { patchText?: unknown; [key: string]: unknown };
|
|
1392
|
+
},
|
|
1393
|
+
);
|
|
1394
|
+
|
|
1395
|
+
await taskSessionManagerHook['tool.execute.before'](
|
|
1396
|
+
input as {
|
|
1397
|
+
tool: string;
|
|
1398
|
+
sessionID?: string;
|
|
1399
|
+
callID?: string;
|
|
1400
|
+
},
|
|
1401
|
+
output as { args?: unknown },
|
|
1402
|
+
);
|
|
1403
|
+
},
|
|
1404
|
+
|
|
1405
|
+
// Direct interception of /auto-continue command - bypasses LLM
|
|
1406
|
+
// round-trip
|
|
1407
|
+
'command.execute.before': async (input, output) => {
|
|
1408
|
+
await todoContinuationHook.handleCommandExecuteBefore(
|
|
1409
|
+
input as {
|
|
1410
|
+
command: string;
|
|
1411
|
+
sessionID: string;
|
|
1412
|
+
arguments: string;
|
|
1413
|
+
},
|
|
1414
|
+
output as { parts: Array<{ type: string; text?: string }> },
|
|
1415
|
+
);
|
|
1416
|
+
|
|
1417
|
+
await presetManager.handleCommandExecuteBefore(
|
|
1418
|
+
input as {
|
|
1419
|
+
command: string;
|
|
1420
|
+
sessionID: string;
|
|
1421
|
+
arguments: string;
|
|
1422
|
+
},
|
|
1423
|
+
output as { parts: Array<{ type: string; text?: string }> },
|
|
1424
|
+
);
|
|
1425
|
+
|
|
1426
|
+
await usageService?.handleCommandExecuteBefore(
|
|
1427
|
+
input as {
|
|
1428
|
+
command: string;
|
|
1429
|
+
sessionID: string;
|
|
1430
|
+
arguments: string;
|
|
1431
|
+
},
|
|
1432
|
+
output as { parts: Array<{ type: string; text?: string }> },
|
|
1433
|
+
);
|
|
1434
|
+
},
|
|
1435
|
+
|
|
1436
|
+
'chat.headers': chatHeadersHook['chat.headers'],
|
|
1437
|
+
|
|
1438
|
+
// Track which agent each session uses (needed for serve-mode prompt
|
|
1439
|
+
// injection)
|
|
1440
|
+
'chat.message': async (
|
|
1441
|
+
input: {
|
|
1442
|
+
sessionID: string;
|
|
1443
|
+
agent?: string;
|
|
1444
|
+
model?: { providerID: string; modelID: string };
|
|
1445
|
+
variant?: string;
|
|
1446
|
+
},
|
|
1447
|
+
output?: { message?: { agent?: string } },
|
|
1448
|
+
) => {
|
|
1449
|
+
const rawAgent = input.agent ?? output?.message?.agent;
|
|
1450
|
+
const agent = rawAgent
|
|
1451
|
+
? resolveRuntimeAgentName(config, rawAgent)
|
|
1452
|
+
: undefined;
|
|
1453
|
+
|
|
1454
|
+
if (
|
|
1455
|
+
agent &&
|
|
1456
|
+
output?.message &&
|
|
1457
|
+
typeof output.message.agent === 'string'
|
|
1458
|
+
) {
|
|
1459
|
+
output.message.agent = agent;
|
|
1460
|
+
}
|
|
1461
|
+
|
|
1462
|
+
if (agent) {
|
|
1463
|
+
sessionAgentMap.set(input.sessionID, agent);
|
|
1464
|
+
recordSessionProject({
|
|
1465
|
+
sessionID: input.sessionID,
|
|
1466
|
+
projectPath: ctx.directory,
|
|
1467
|
+
});
|
|
1468
|
+
if (input.model) {
|
|
1469
|
+
recordSessionModel({
|
|
1470
|
+
sessionID: input.sessionID,
|
|
1471
|
+
model: `${input.model.providerID}/${input.model.modelID}`,
|
|
1472
|
+
});
|
|
1473
|
+
}
|
|
1474
|
+
if (typeof input.variant === 'string') {
|
|
1475
|
+
recordSessionVariant({
|
|
1476
|
+
sessionID: input.sessionID,
|
|
1477
|
+
variant: input.variant,
|
|
1478
|
+
});
|
|
1479
|
+
}
|
|
1480
|
+
if (agent) {
|
|
1481
|
+
recordSessionNode({
|
|
1482
|
+
sessionID: input.sessionID,
|
|
1483
|
+
agent,
|
|
1484
|
+
model: input.model
|
|
1485
|
+
? `${input.model.providerID}/${input.model.modelID}`
|
|
1486
|
+
: undefined,
|
|
1487
|
+
variant: input.variant,
|
|
1488
|
+
status: 'busy',
|
|
1489
|
+
});
|
|
1490
|
+
}
|
|
1491
|
+
}
|
|
1492
|
+
todoContinuationHook.handleChatMessage({
|
|
1493
|
+
sessionID: input.sessionID,
|
|
1494
|
+
agent,
|
|
1495
|
+
});
|
|
1496
|
+
},
|
|
1497
|
+
|
|
1498
|
+
// Inject orchestrator system prompt for serve-mode sessions. In serve
|
|
1499
|
+
// mode, the agent's prompt field may be absent from the agents
|
|
1500
|
+
// registry (built before plugin config hooks run). This hook injects
|
|
1501
|
+
// it at LLM call time. Uses the already-resolved prompt from
|
|
1502
|
+
// agentDefs (which has custom replacement or append prompts applied)
|
|
1503
|
+
// instead of rebuilding the default.
|
|
1504
|
+
'experimental.chat.system.transform': async (
|
|
1505
|
+
input: { sessionID?: string },
|
|
1506
|
+
output: { system: string[] },
|
|
1507
|
+
): Promise<void> => {
|
|
1508
|
+
const agentName = input.sessionID
|
|
1509
|
+
? sessionAgentMap.get(input.sessionID)
|
|
1510
|
+
: undefined;
|
|
1511
|
+
if (agentName === 'orchestrator') {
|
|
1512
|
+
const alreadyInjected = output.system.some(
|
|
1513
|
+
(s) =>
|
|
1514
|
+
typeof s === 'string' &&
|
|
1515
|
+
s.includes('<Role>') &&
|
|
1516
|
+
s.includes('orchestrator'),
|
|
1517
|
+
);
|
|
1518
|
+
if (!alreadyInjected) {
|
|
1519
|
+
// Prepend the orchestrator prompt to the system array. Use the
|
|
1520
|
+
// resolved prompt from the orchestrator agent definition (which
|
|
1521
|
+
// includes any custom replacement or append from orchestrator.md
|
|
1522
|
+
// / orchestrator_append.md) Fall back to
|
|
1523
|
+
// buildOrchestratorPrompt only if the resolved prompt is
|
|
1524
|
+
// missing.
|
|
1525
|
+
const orchestratorDef = agentDefs.find(
|
|
1526
|
+
(a) => a.name === 'orchestrator',
|
|
1527
|
+
);
|
|
1528
|
+
const orchestratorPrompt =
|
|
1529
|
+
typeof orchestratorDef?.config?.prompt === 'string'
|
|
1530
|
+
? orchestratorDef.config.prompt
|
|
1531
|
+
: buildOrchestratorPrompt();
|
|
1532
|
+
output.system[0] =
|
|
1533
|
+
orchestratorPrompt +
|
|
1534
|
+
(output.system[0] ? `\n\n${output.system[0]}` : '');
|
|
1535
|
+
}
|
|
1536
|
+
}
|
|
1537
|
+
|
|
1538
|
+
// Collapse to single system message for provider compatibility.
|
|
1539
|
+
// Some providers (e.g. Qwen via VLLM/DashScope) reject multiple
|
|
1540
|
+
// system messages. Sub-hooks above may push additional entries; join
|
|
1541
|
+
// them back into one element so OpenCode emits a single system
|
|
1542
|
+
// message.
|
|
1543
|
+
collapseSystemInPlace(output.system);
|
|
1544
|
+
},
|
|
1545
|
+
|
|
1546
|
+
// Inject phase reminder and filter available skills before sending to
|
|
1547
|
+
// API (doesn't show in UI)
|
|
1548
|
+
'experimental.chat.messages.transform': async (
|
|
1549
|
+
input: Record<string, never>,
|
|
1550
|
+
output: { messages: unknown[] },
|
|
1551
|
+
): Promise<void> => {
|
|
1552
|
+
// Type assertion since we know the structure matches
|
|
1553
|
+
// MessageWithParts[]
|
|
1554
|
+
const typedOutput = output as {
|
|
1555
|
+
messages: Array<{
|
|
1556
|
+
info: { role: string; agent?: string; sessionID?: string };
|
|
1557
|
+
parts: Array<{
|
|
1558
|
+
type: string;
|
|
1559
|
+
text?: string;
|
|
1560
|
+
[key: string]: unknown;
|
|
1561
|
+
}>;
|
|
1562
|
+
}>;
|
|
1563
|
+
};
|
|
1564
|
+
|
|
1565
|
+
const hasUserTurn = typedOutput.messages.some(
|
|
1566
|
+
(message) => message.info.role === 'user',
|
|
1567
|
+
);
|
|
1568
|
+
if (hasUserTurn) {
|
|
1569
|
+
// After the user submits, session.status reliably reflects OpenCode -
|
|
1570
|
+
// better than reconciling once at startup (empty/partial snapshots).
|
|
1571
|
+
// Await so context telemetry is fresh for hooks (e.g. /compact reminder).
|
|
1572
|
+
await reconcileSessions();
|
|
1573
|
+
}
|
|
1574
|
+
|
|
1575
|
+
for (const message of typedOutput.messages) {
|
|
1576
|
+
if (message.info.role !== 'user') {
|
|
1577
|
+
continue;
|
|
1578
|
+
}
|
|
1579
|
+
for (const part of message.parts) {
|
|
1580
|
+
if (part.type !== 'text' || typeof part.text !== 'string') {
|
|
1581
|
+
continue;
|
|
1582
|
+
}
|
|
1583
|
+
part.text = rewriteDisplayNameMentions(part.text);
|
|
1584
|
+
}
|
|
1585
|
+
}
|
|
1586
|
+
|
|
1587
|
+
processImageAttachments();
|
|
1588
|
+
|
|
1589
|
+
await todoContinuationHook.handleMessagesTransform({
|
|
1590
|
+
messages: typedOutput.messages,
|
|
1591
|
+
});
|
|
1592
|
+
await taskSessionManagerHook['experimental.chat.messages.transform'](
|
|
1593
|
+
input,
|
|
1594
|
+
typedOutput,
|
|
1595
|
+
);
|
|
1596
|
+
await contextPressureReminderHook['experimental.chat.messages.transform'](
|
|
1597
|
+
input,
|
|
1598
|
+
typedOutput,
|
|
1599
|
+
);
|
|
1600
|
+
await phaseReminderHook['experimental.chat.messages.transform'](
|
|
1601
|
+
input,
|
|
1602
|
+
typedOutput,
|
|
1603
|
+
);
|
|
1604
|
+
await filterAvailableSkillsHook['experimental.chat.messages.transform'](
|
|
1605
|
+
input,
|
|
1606
|
+
typedOutput,
|
|
1607
|
+
);
|
|
1608
|
+
},
|
|
1609
|
+
|
|
1610
|
+
// Post-tool hooks: retry guidance for delegation errors + file-tool
|
|
1611
|
+
// nudge
|
|
1612
|
+
'tool.execute.after': async (input, output) => {
|
|
1613
|
+
await delegateTaskRetryHook['tool.execute.after'](
|
|
1614
|
+
input as { tool: string },
|
|
1615
|
+
output as { output: unknown },
|
|
1616
|
+
);
|
|
1617
|
+
|
|
1618
|
+
await jsonErrorRecoveryHook['tool.execute.after'](
|
|
1619
|
+
input as {
|
|
1620
|
+
tool: string;
|
|
1621
|
+
sessionID: string;
|
|
1622
|
+
callID: string;
|
|
1623
|
+
},
|
|
1624
|
+
output as {
|
|
1625
|
+
title: string;
|
|
1626
|
+
output: unknown;
|
|
1627
|
+
metadata: unknown;
|
|
1628
|
+
},
|
|
1629
|
+
);
|
|
1630
|
+
|
|
1631
|
+
await todoContinuationHook.handleToolExecuteAfter(
|
|
1632
|
+
input as {
|
|
1633
|
+
tool: string;
|
|
1634
|
+
sessionID?: string;
|
|
1635
|
+
},
|
|
1636
|
+
output as { output?: unknown },
|
|
1637
|
+
);
|
|
1638
|
+
|
|
1639
|
+
await postFileToolNudgeHook['tool.execute.after'](
|
|
1640
|
+
input as {
|
|
1641
|
+
tool: string;
|
|
1642
|
+
sessionID?: string;
|
|
1643
|
+
callID?: string;
|
|
1644
|
+
},
|
|
1645
|
+
output as {
|
|
1646
|
+
title: string;
|
|
1647
|
+
output: string;
|
|
1648
|
+
metadata: Record<string, unknown>;
|
|
1649
|
+
},
|
|
1650
|
+
);
|
|
1651
|
+
|
|
1652
|
+
await taskSessionManagerHook['tool.execute.after'](
|
|
1653
|
+
input as {
|
|
1654
|
+
tool: string;
|
|
1655
|
+
sessionID?: string;
|
|
1656
|
+
callID?: string;
|
|
1657
|
+
},
|
|
1658
|
+
output as { output: unknown },
|
|
1659
|
+
);
|
|
1660
|
+
},
|
|
1661
|
+
};
|
|
1662
|
+
};
|
|
1663
|
+
|
|
1664
|
+
export default OhMyOpenCodeLite;
|
|
1665
|
+
|
|
1666
|
+
export type {
|
|
1667
|
+
AgentName,
|
|
1668
|
+
AgentOverrideConfig,
|
|
1669
|
+
McpName,
|
|
1670
|
+
PluginConfig,
|
|
1671
|
+
} from './config';
|
|
1672
|
+
export type { RemoteMcpConfig } from './mcp';
|