one-code-extension 0.1.4 → 0.3.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 +20 -0
- package/README.md +200 -172
- package/agents/explore.md +9 -6
- package/agents/general-purpose.md +1 -1
- package/agents/plan.md +6 -4
- package/docs/guide/README.md +52 -0
- package/docs/guide/bring-your-claude-code-setup.md +126 -0
- package/docs/guide/configuration.md +171 -0
- package/docs/guide/differences-from-claude-code.md +150 -0
- package/docs/guide/doctor.md +86 -0
- package/docs/guide/hooks.md +109 -0
- package/docs/guide/installation.md +124 -0
- package/docs/guide/permissions-modes-and-auto-mode.md +450 -0
- package/docs/guide/providers-and-models.md +194 -0
- package/docs/guide/reference.md +224 -0
- package/docs/guide/sessions-and-context.md +190 -0
- package/docs/guide/skills-plugins-and-mcp.md +176 -0
- package/docs/guide/subagents-and-workflows.md +193 -0
- package/docs/guide/tasks-and-background-work.md +111 -0
- package/docs/guide/terminal-interface.md +155 -0
- package/docs/guide/tools.md +175 -0
- package/docs/guide/troubleshooting.md +175 -0
- package/extensions/ask-user/index.ts +65 -71
- package/extensions/ask-user/questions.ts +15 -28
- package/extensions/ask-user/widget.ts +533 -0
- package/extensions/auto-mode/actions.ts +9 -26
- package/extensions/auto-mode/classifier-prompt.ts +127 -0
- package/extensions/auto-mode/classifier.ts +305 -227
- package/extensions/auto-mode/config.ts +279 -77
- package/extensions/auto-mode/defaults.ts +17 -60
- package/extensions/auto-mode/instructions.ts +2 -1
- package/extensions/auto-mode/model-picker.ts +53 -31
- package/extensions/auto-mode/model-select.ts +153 -82
- package/extensions/auto-mode/paths.ts +13 -1
- package/extensions/auto-mode/pause.ts +8 -0
- package/extensions/auto-mode/prompt.ts +205 -238
- package/extensions/auto-mode/recoverability.ts +229 -0
- package/extensions/auto-mode/rules.ts +92 -75
- package/extensions/auto-mode/safety-floor.ts +43 -12
- package/extensions/auto-mode/sensitive.ts +11 -0
- package/extensions/auto-mode/setup-run.ts +245 -0
- package/extensions/auto-mode/setup.ts +235 -0
- package/extensions/auto-mode/shell-analysis.ts +332 -26
- package/extensions/auto-mode/transcript.ts +102 -0
- package/extensions/background/index.ts +256 -43
- package/extensions/background/monitor-batch.ts +58 -0
- package/extensions/background/registry.ts +9 -0
- package/extensions/background/wakeup.ts +45 -0
- package/extensions/bash/background.ts +47 -22
- package/extensions/bash/guards.ts +229 -0
- package/extensions/bash/index.ts +81 -19
- package/extensions/branding/index.ts +44 -18
- package/extensions/branding/startup.ts +23 -8
- package/extensions/claude-compat/index.ts +17 -2
- package/extensions/claude-context/index.ts +41 -12
- package/extensions/clear/index.ts +27 -0
- package/extensions/compaction/fit.ts +121 -0
- package/extensions/compaction/index.ts +176 -71
- package/extensions/compaction/prompt.ts +21 -1
- package/extensions/context-budget/budget.ts +61 -0
- package/extensions/context-budget/index.ts +66 -0
- package/extensions/context-management/index.ts +40 -16
- package/extensions/doctor/build.ts +226 -0
- package/extensions/doctor/cli.ts +99 -0
- package/extensions/doctor/compat.ts +532 -0
- package/extensions/doctor/dependencies.ts +186 -0
- package/extensions/doctor/fix-prompt.ts +125 -0
- package/extensions/doctor/index.ts +308 -0
- package/extensions/doctor/models.ts +231 -0
- package/extensions/doctor/presets.ts +208 -0
- package/extensions/doctor/report.ts +189 -0
- package/extensions/doctor/update-lookup.ts +46 -0
- package/extensions/doctor/viewer.ts +124 -0
- package/extensions/effort/index.ts +41 -4
- package/extensions/effort/slider.ts +33 -2
- package/extensions/file-tracker/index.ts +143 -20
- package/extensions/file-tracker/replay.ts +46 -0
- package/extensions/file-tracker/tracker.ts +51 -4
- package/extensions/footer/footer-line.ts +207 -0
- package/extensions/footer/index.ts +152 -0
- package/extensions/footer/pr.ts +53 -0
- package/extensions/hooks/executor.ts +57 -20
- package/extensions/hooks/formatter-notice.ts +76 -0
- package/extensions/hooks/index.ts +314 -73
- package/extensions/hooks/matcher.ts +35 -1
- package/extensions/hooks/plugin-hooks.ts +3 -3
- package/extensions/hooks/protocol.ts +58 -0
- package/extensions/hooks/subagent-bridge.ts +77 -0
- package/extensions/hooks/trust.ts +15 -4
- package/extensions/init/index.ts +26 -0
- package/extensions/init/prompt.ts +40 -0
- package/extensions/interrupted/index.ts +35 -0
- package/extensions/interrupted/line.ts +9 -0
- package/extensions/lib/abort.ts +15 -0
- package/extensions/lib/agent-loader.ts +183 -0
- package/extensions/lib/anthropic-payload.ts +15 -0
- package/extensions/lib/atomic-write.ts +56 -0
- package/extensions/lib/bridge-watch.ts +20 -0
- package/extensions/lib/cache-retention.ts +60 -0
- package/extensions/lib/capability-index.ts +466 -0
- package/extensions/lib/claude-context.ts +318 -27
- package/extensions/lib/claude-settings.ts +64 -0
- package/extensions/lib/consent-preview.ts +30 -0
- package/extensions/lib/deferred.ts +144 -13
- package/extensions/lib/favorites.ts +43 -0
- package/extensions/lib/fetch-timeout.ts +15 -0
- package/extensions/lib/frontmatter.ts +31 -0
- package/extensions/lib/git.ts +44 -2
- package/extensions/lib/hook-gate.ts +90 -0
- package/extensions/lib/interrupt.ts +94 -0
- package/extensions/lib/mcp-overrides.ts +61 -0
- package/extensions/lib/mcp-share.ts +25 -0
- package/extensions/lib/mcp-status.ts +34 -0
- package/extensions/lib/memory.ts +41 -6
- package/extensions/lib/model-facts.generated.json +2480 -0
- package/extensions/lib/model-facts.ts +162 -0
- package/extensions/lib/model-policy.ts +145 -189
- package/extensions/lib/model-tier.ts +480 -64
- package/extensions/lib/model-unusable.ts +37 -0
- package/extensions/lib/notifications.ts +320 -1
- package/extensions/lib/one-code-settings.ts +63 -0
- package/extensions/lib/original-command.ts +71 -0
- package/extensions/lib/package-version.ts +31 -0
- package/extensions/lib/paths.ts +39 -4
- package/extensions/lib/permission-gate.ts +192 -0
- package/extensions/lib/persisted-output.ts +18 -0
- package/extensions/lib/pi-ai-estimate.ts +160 -0
- package/extensions/lib/pi-version.ts +1 -1
- package/extensions/lib/plan-mode-channels.ts +11 -0
- package/extensions/lib/plugin-overrides.ts +24 -0
- package/extensions/lib/plugin-root.ts +44 -0
- package/extensions/lib/plugins.ts +233 -54
- package/extensions/lib/prefix-warm-gate.ts +157 -0
- package/extensions/lib/process-tree.ts +56 -0
- package/extensions/lib/reminders.ts +321 -68
- package/extensions/lib/scratchpad.ts +11 -8
- package/extensions/lib/session-lifecycle.ts +28 -0
- package/extensions/lib/settings-channels.ts +14 -0
- package/extensions/lib/shell-tasks.ts +54 -0
- package/extensions/lib/skill-overrides.ts +86 -0
- package/extensions/lib/skill-scan.ts +110 -0
- package/extensions/lib/text-width.ts +267 -0
- package/extensions/lib/tui-render.ts +281 -54
- package/extensions/lib/usage-bus.ts +75 -0
- package/extensions/lib/usage-tracker.ts +67 -0
- package/extensions/lib/worktree-isolation.ts +170 -0
- package/extensions/lsp/client.ts +69 -12
- package/extensions/lsp/format.ts +12 -2
- package/extensions/lsp/index.ts +273 -84
- package/extensions/lsp/install-hints.ts +43 -0
- package/extensions/lsp/keep-alive.ts +19 -0
- package/extensions/lsp/plugin-servers.ts +331 -0
- package/extensions/lsp/watcher.ts +157 -0
- package/extensions/mcp/announce.ts +99 -0
- package/extensions/mcp/client.ts +181 -22
- package/extensions/mcp/config.ts +55 -24
- package/extensions/mcp/index.ts +586 -86
- package/extensions/mcp/oauth/browser.ts +26 -0
- package/extensions/mcp/oauth/callback.ts +104 -0
- package/extensions/mcp/oauth/flow.ts +68 -0
- package/extensions/mcp/oauth/provider.ts +147 -0
- package/extensions/mcp/oauth/store.ts +86 -0
- package/extensions/mcp/panel/keys.ts +34 -0
- package/extensions/mcp/panel/model.ts +62 -0
- package/extensions/mcp/panel/render.ts +141 -0
- package/extensions/mcp/panel/state.ts +108 -0
- package/extensions/mcp/schema.ts +83 -6
- package/extensions/mcp/trust.ts +340 -0
- package/extensions/memory/entries.ts +163 -0
- package/extensions/memory/index.ts +119 -2
- package/extensions/memory/open-external.ts +97 -0
- package/extensions/memory/panel.ts +124 -0
- package/extensions/model-default/index.ts +74 -0
- package/extensions/notebook/index.ts +36 -18
- package/extensions/notebook/notebook.ts +110 -17
- package/extensions/permissions/index.ts +1012 -247
- package/extensions/permissions/matcher.ts +488 -57
- package/extensions/permissions/modes.ts +55 -7
- package/extensions/permissions/project-trust.ts +81 -0
- package/extensions/permissions/protected-paths.ts +16 -3
- package/extensions/permissions/rule-prose.ts +79 -0
- package/extensions/permissions/session-grant.ts +91 -0
- package/extensions/permissions/settings.ts +112 -33
- package/extensions/permissions/subagent-gate.ts +57 -0
- package/extensions/plan-mode/index.ts +143 -33
- package/extensions/plan-mode/reminder.ts +16 -19
- package/extensions/plan-mode/viewer.ts +40 -31
- package/extensions/plugins/counts.ts +68 -0
- package/extensions/plugins/index.ts +496 -32
- package/extensions/plugins/install/install.ts +167 -0
- package/extensions/plugins/install/paths.ts +23 -0
- package/extensions/plugins/install/registry.ts +72 -0
- package/extensions/plugins/marketplace/git.ts +103 -0
- package/extensions/plugins/marketplace/official.ts +36 -0
- package/extensions/plugins/marketplace/parse.ts +94 -0
- package/extensions/plugins/marketplace/registry.ts +60 -0
- package/extensions/plugins/marketplace/sync.ts +146 -0
- package/extensions/plugins/marketplace/types.ts +128 -0
- package/extensions/plugins/panel/keys.ts +52 -0
- package/extensions/plugins/panel/render.ts +315 -0
- package/extensions/plugins/panel/rows.ts +230 -0
- package/extensions/plugins/panel/state.ts +333 -0
- package/extensions/recap/index.ts +215 -0
- package/extensions/recap/prompt.ts +44 -0
- package/extensions/recap/scheduler.ts +87 -0
- package/extensions/search-tools/index.ts +0 -0
- package/extensions/search-tools/tools.ts +7 -5
- package/extensions/skill/index.ts +360 -34
- package/extensions/skill/invoke.ts +185 -0
- package/extensions/skill/listing.ts +21 -0
- package/extensions/skill/panel/keys.ts +41 -0
- package/extensions/skill/panel/render.ts +91 -0
- package/extensions/skill/panel/state.ts +115 -0
- package/extensions/spinner/index.ts +110 -0
- package/extensions/spinner/line.ts +51 -0
- package/extensions/spinner/verbs.ts +201 -0
- package/extensions/subagents/agents.ts +88 -44
- package/extensions/subagents/default-model.ts +62 -32
- package/extensions/subagents/delegation-steer.ts +23 -0
- package/extensions/subagents/hand-back-review.ts +67 -0
- package/extensions/subagents/index.ts +1545 -346
- package/extensions/subagents/live-runs.ts +293 -0
- package/extensions/subagents/model-select.ts +252 -82
- package/extensions/subagents/outcome.ts +90 -0
- package/extensions/subagents/panel-keys.ts +41 -0
- package/extensions/subagents/panel-render.ts +357 -0
- package/extensions/subagents/panel-widget.ts +330 -0
- package/extensions/subagents/pending-claim.ts +45 -0
- package/extensions/subagents/prose.ts +105 -0
- package/extensions/subagents/runner.ts +674 -0
- package/extensions/subagents/runs.ts +68 -1
- package/extensions/subagents/send-to-main-tool.ts +55 -0
- package/extensions/subagents/session-turns.ts +138 -0
- package/extensions/subagents/shell-panel.ts +198 -0
- package/extensions/subagents/usage.ts +16 -4
- package/extensions/subagents/worktree.ts +13 -0
- package/extensions/system-prompt/environment.ts +5 -3
- package/extensions/system-prompt/git-status.ts +93 -0
- package/extensions/system-prompt/index.ts +23 -1
- package/extensions/system-prompt/template.ts +34 -4
- package/extensions/system-prompt/tiers/common.ts +31 -6
- package/extensions/system-prompt/tiers/low.ts +13 -3
- package/extensions/system-prompt/tiers/mid.ts +21 -8
- package/extensions/system-reminder/index.ts +53 -9
- package/extensions/tasks/index.ts +65 -16
- package/extensions/tasks/store.ts +76 -3
- package/extensions/tool-search/announce.ts +42 -0
- package/extensions/tool-search/index.ts +136 -13
- package/extensions/turn-duration/index.ts +60 -0
- package/extensions/turn-duration/line.ts +22 -0
- package/extensions/turn-duration/span.ts +41 -0
- package/extensions/turn-duration/verbs.ts +24 -0
- package/extensions/web/backends.ts +421 -0
- package/extensions/web/index.ts +178 -21
- package/extensions/web-fetch/index.ts +74 -18
- package/extensions/web-fetch/summarize.ts +1 -1
- package/extensions/workflow/agent-session.ts +182 -112
- package/extensions/workflow/globals.ts +129 -106
- package/extensions/workflow/index.ts +343 -28
- package/extensions/workflow/records.ts +118 -0
- package/extensions/workflow/run-manager.ts +80 -31
- package/extensions/workflow/saved-workflows.ts +2 -1
- package/extensions/workflow/script-worker.mjs +110 -0
- package/extensions/workflow/types.ts +74 -8
- package/extensions/workflow/viewer.ts +562 -0
- package/extensions/workflow/vm-runtime.ts +118 -22
- package/extensions/workflow/widget.ts +126 -18
- package/extensions/worktree/guards.ts +240 -0
- package/extensions/worktree/index.ts +30 -5
- package/extensions/worktree/rewrite.ts +32 -16
- package/package.json +20 -10
- package/skills/code-review/SKILL.md +165 -0
- package/skills/fewer-permission-prompts/SKILL.md +61 -0
- package/skills/security-review/SKILL.md +178 -0
- package/skills/simplify/SKILL.md +70 -0
- package/themes/{one-code-light.json → onecode-light.json} +6 -1
- package/themes/{one-code.json → onecode.json} +6 -1
- package/types/app.d.ts +8 -1
- package/types/community-packages.d.ts +45 -2
- package/docs/decisions/auto-mode.md +0 -526
- package/docs/decisions/branding.md +0 -127
- package/docs/decisions/code-review-remediation.md +0 -106
- package/docs/decisions/compaction.md +0 -140
- package/docs/decisions/distribution.md +0 -168
- package/docs/decisions/hooks.md +0 -64
- package/docs/decisions/lsp.md +0 -58
- package/docs/decisions/mcp.md +0 -104
- package/docs/decisions/memory-state.md +0 -170
- package/docs/decisions/model-policy.md +0 -126
- package/docs/decisions/modes.md +0 -178
- package/docs/decisions/skills-plugins.md +0 -56
- package/docs/decisions/subagents-workflows.md +0 -225
- package/docs/decisions/system-prompt.md +0 -98
- package/docs/decisions/tools.md +0 -407
- package/docs/decisions/tui.md +0 -217
- package/docs/decisions.md +0 -148
- package/extensions/subagents/child.ts +0 -414
- package/extensions/subagents/rpc-turns.ts +0 -123
- package/extensions/todo/index.ts +0 -133
- package/extensions/workflow/permission-gate.ts +0 -71
|
@@ -1,36 +1,69 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Claude Code plugin discovery (pure).
|
|
3
3
|
*
|
|
4
|
-
*
|
|
4
|
+
* Two roots are merged:
|
|
5
5
|
*
|
|
6
|
-
* ~/.claude/plugins/
|
|
7
|
-
*
|
|
6
|
+
* ~/.claude/plugins/ Claude Code's own installs — READ-ONLY.
|
|
7
|
+
* <agentDir>/plugins/ One Code's installs (lib/plugin-root.ts) —
|
|
8
|
+
* the only root One Code ever writes.
|
|
9
|
+
*
|
|
10
|
+
* Both hold the same on-disk format:
|
|
11
|
+
*
|
|
12
|
+
* <root>/installed_plugins.json
|
|
13
|
+
* { "version": 2, "plugins": { "<name>@<marketplace>": [{ scope, installPath, version, ... }] } }
|
|
8
14
|
*
|
|
9
15
|
* <installPath>/.claude-plugin/plugin.json { name, description, author }
|
|
10
16
|
* <installPath>/agents/*.md agent definitions
|
|
11
17
|
* <installPath>/commands/*.md slash commands
|
|
12
18
|
* <installPath>/skills/<name>/SKILL.md skills
|
|
13
19
|
* <installPath>/.mcp.json MCP servers
|
|
20
|
+
* <installPath>/.lsp.json LSP servers (extensions/lsp)
|
|
21
|
+
*
|
|
22
|
+
* Enabled state differs by origin: Claude Code keeps it in settings.json's
|
|
23
|
+
* `enabledPlugins` map (its installed_plugins.json entries have NO enabled
|
|
24
|
+
* field), overridable from One Code via <one-code root>/overrides.json;
|
|
25
|
+
* One Code's own entries carry `enabled` directly (we own that schema copy).
|
|
14
26
|
*
|
|
15
27
|
* Claude Code namespaces everything a plugin provides as `<plugin>:<name>`, so
|
|
16
28
|
* two plugins can both ship a `commit` command.
|
|
17
29
|
*/
|
|
18
30
|
|
|
19
31
|
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
|
20
|
-
import
|
|
32
|
+
import os from "node:os";
|
|
33
|
+
import { basename, join, resolve, sep } from "node:path";
|
|
34
|
+
import { readJsonFile } from "./atomic-write.ts";
|
|
35
|
+
import { readEnabledPlugins } from "./claude-settings.ts";
|
|
36
|
+
import { readOverrides } from "./plugin-overrides.ts";
|
|
37
|
+
import { pluginRoot } from "./plugin-root.ts";
|
|
38
|
+
import { isSkillEnabled, readSkillStates, skillOverrideKey } from "./skill-overrides.ts";
|
|
39
|
+
import { claudeUserDir } from "./paths.ts";
|
|
21
40
|
|
|
22
41
|
export interface PluginManifest {
|
|
23
42
|
name?: string;
|
|
24
43
|
description?: string;
|
|
25
44
|
}
|
|
26
45
|
|
|
46
|
+
export type PluginOrigin = "claude" | "one-code";
|
|
47
|
+
|
|
27
48
|
export interface Plugin {
|
|
28
|
-
/**
|
|
49
|
+
/** Raw registry key (`<name>@<marketplace>`) — what enabledPlugins/overrides key on. */
|
|
50
|
+
id: string;
|
|
51
|
+
/** Plugin name, without the @marketplace suffix (manifest name when present). */
|
|
29
52
|
name: string;
|
|
30
53
|
marketplace?: string;
|
|
31
54
|
path: string;
|
|
32
55
|
version?: string;
|
|
33
56
|
description?: string;
|
|
57
|
+
/** Which root the plugin was discovered under. */
|
|
58
|
+
originRoot: PluginOrigin;
|
|
59
|
+
/** Effective enabled state after settings/override merging. */
|
|
60
|
+
enabled: boolean;
|
|
61
|
+
/** True when a claude-origin plugin's state comes from our overrides.json. */
|
|
62
|
+
overridden?: boolean;
|
|
63
|
+
/** Base dir for the plugin's `${CLAUDE_PLUGIN_DATA}` (per-origin). */
|
|
64
|
+
dataRoot: string;
|
|
65
|
+
/** Raw `enabled` from the installed_plugins.json entry (meaningful only for one-code origin). */
|
|
66
|
+
rawEnabled?: boolean;
|
|
34
67
|
}
|
|
35
68
|
|
|
36
69
|
export interface PluginResources {
|
|
@@ -40,15 +73,35 @@ export interface PluginResources {
|
|
|
40
73
|
mcpConfig?: string;
|
|
41
74
|
/** <plugin>/hooks/hooks.json when present — consumed by extensions/hooks. */
|
|
42
75
|
hooksConfig?: string;
|
|
76
|
+
/** <plugin>/.lsp.json when present — consumed by extensions/lsp. */
|
|
77
|
+
lspConfig?: string;
|
|
43
78
|
}
|
|
44
79
|
|
|
45
80
|
interface InstalledEntry {
|
|
46
81
|
scope?: string;
|
|
82
|
+
/** For scope "project"/"local": the project the plugin was installed for. */
|
|
83
|
+
projectPath?: string;
|
|
47
84
|
installPath?: string;
|
|
48
85
|
version?: string;
|
|
49
86
|
enabled?: boolean;
|
|
50
87
|
}
|
|
51
88
|
|
|
89
|
+
/**
|
|
90
|
+
* Whether an installed-plugin entry applies to the session cwd. A project- or
|
|
91
|
+
* local-scoped entry belongs to one project (its `projectPath`), so it should
|
|
92
|
+
* be live only in that project or a descendant — not in every project under One
|
|
93
|
+
* Code (review L6). A user-scoped entry (or one with no `projectPath`, or when
|
|
94
|
+
* cwd is unknown) applies everywhere.
|
|
95
|
+
*/
|
|
96
|
+
export function entryAppliesToCwd(entry: InstalledEntry, cwd: string | undefined): boolean {
|
|
97
|
+
if (!cwd) return true;
|
|
98
|
+
if (entry.scope !== "project" && entry.scope !== "local") return true;
|
|
99
|
+
if (!entry.projectPath) return true;
|
|
100
|
+
const base = resolve(entry.projectPath);
|
|
101
|
+
const here = resolve(cwd);
|
|
102
|
+
return here === base || here.startsWith(base + sep);
|
|
103
|
+
}
|
|
104
|
+
|
|
52
105
|
export function splitPluginKey(key: string): { name: string; marketplace?: string } {
|
|
53
106
|
const at = key.lastIndexOf("@");
|
|
54
107
|
if (at <= 0) return { name: key };
|
|
@@ -64,56 +117,80 @@ export function tryReadFile(path: string): string | undefined {
|
|
|
64
117
|
}
|
|
65
118
|
}
|
|
66
119
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
120
|
+
/** One root's installed plugins, before enabled-policy is applied. */
|
|
121
|
+
export interface InstalledPlugin {
|
|
122
|
+
id: string;
|
|
123
|
+
name: string;
|
|
124
|
+
marketplace?: string;
|
|
125
|
+
path: string;
|
|
126
|
+
version?: string;
|
|
127
|
+
description?: string;
|
|
128
|
+
rawEnabled?: boolean;
|
|
76
129
|
}
|
|
77
130
|
|
|
78
|
-
/**
|
|
79
|
-
|
|
131
|
+
/**
|
|
132
|
+
* Reads a root's installed-plugin registry (`<pluginsDir>/installed_plugins.json`).
|
|
133
|
+
* Format reader only — no enabled policy here: Claude Code's entries carry no
|
|
134
|
+
* enabled field (that state lives in settings.json), so the first entry with an
|
|
135
|
+
* existing installPath wins; a raw `enabled` value is passed through for the
|
|
136
|
+
* One Code root, where the entry schema is ours.
|
|
137
|
+
*/
|
|
138
|
+
export function loadInstalledPlugins(pluginsDir: string, cwd?: string): InstalledPlugin[] {
|
|
80
139
|
const registry = readJsonFile<{ plugins?: Record<string, InstalledEntry[]> }>(
|
|
81
|
-
join(
|
|
140
|
+
join(pluginsDir, "installed_plugins.json"),
|
|
82
141
|
);
|
|
83
142
|
if (!registry?.plugins) return [];
|
|
84
143
|
|
|
85
|
-
const plugins:
|
|
144
|
+
const plugins: InstalledPlugin[] = [];
|
|
86
145
|
for (const [key, entries] of Object.entries(registry.plugins)) {
|
|
87
146
|
if (!Array.isArray(entries) || entries.length === 0) continue;
|
|
88
|
-
const entry = entries.find((e) => e.
|
|
89
|
-
if (!entry?.installPath
|
|
147
|
+
const entry = entries.find((e) => e.installPath && existsSync(e.installPath) && entryAppliesToCwd(e, cwd));
|
|
148
|
+
if (!entry?.installPath) continue;
|
|
90
149
|
|
|
91
150
|
const { name, marketplace } = splitPluginKey(key);
|
|
92
151
|
const manifest = readJsonFile<PluginManifest>(join(entry.installPath, ".claude-plugin", "plugin.json"));
|
|
93
152
|
plugins.push({
|
|
94
|
-
|
|
153
|
+
id: key,
|
|
154
|
+
// A third-party manifest is copied in verbatim: a non-string `name`
|
|
155
|
+
// (or one carrying path separators) would later crash `localeCompare`
|
|
156
|
+
// or poison the namespace it becomes. Fall back to the registry key's
|
|
157
|
+
// name for anything not a clean string (review H2).
|
|
158
|
+
name: validPluginName(manifest?.name) ?? name,
|
|
95
159
|
marketplace,
|
|
96
160
|
path: entry.installPath,
|
|
97
161
|
version: entry.version,
|
|
98
|
-
description: manifest?.description,
|
|
162
|
+
description: typeof manifest?.description === "string" ? manifest.description : undefined,
|
|
163
|
+
rawEnabled: typeof entry.enabled === "boolean" ? entry.enabled : undefined,
|
|
99
164
|
});
|
|
100
165
|
}
|
|
101
166
|
return plugins.sort((a, b) => a.name.localeCompare(b.name));
|
|
102
167
|
}
|
|
103
168
|
|
|
104
|
-
|
|
169
|
+
/** A manifest `name` usable as a namespace: a non-empty string free of path separators, colons and whitespace. */
|
|
170
|
+
export function validPluginName(value: unknown): string | undefined {
|
|
171
|
+
if (typeof value !== "string") return undefined;
|
|
172
|
+
const trimmed = value.trim();
|
|
173
|
+
if (trimmed.length === 0) return undefined;
|
|
174
|
+
if (/[/\\:\s]/.test(trimmed)) return undefined;
|
|
175
|
+
return trimmed;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export function pluginResources(plugin: Pick<Plugin, "path">): PluginResources {
|
|
105
179
|
const dirIfExists = (name: string) => {
|
|
106
180
|
const path = join(plugin.path, name);
|
|
107
181
|
return existsSync(path) && statSync(path).isDirectory() ? path : undefined;
|
|
108
182
|
};
|
|
109
|
-
const
|
|
110
|
-
|
|
183
|
+
const fileIfExists = (...segments: string[]) => {
|
|
184
|
+
const path = join(plugin.path, ...segments);
|
|
185
|
+
return existsSync(path) ? path : undefined;
|
|
186
|
+
};
|
|
111
187
|
return {
|
|
112
188
|
agentsDir: dirIfExists("agents"),
|
|
113
189
|
commandsDir: dirIfExists("commands"),
|
|
114
190
|
skillsDir: dirIfExists("skills"),
|
|
115
|
-
mcpConfig:
|
|
116
|
-
hooksConfig:
|
|
191
|
+
mcpConfig: fileIfExists(".mcp.json"),
|
|
192
|
+
hooksConfig: fileIfExists("hooks", "hooks.json"),
|
|
193
|
+
lspConfig: fileIfExists(".lsp.json"),
|
|
117
194
|
};
|
|
118
195
|
}
|
|
119
196
|
|
|
@@ -125,7 +202,7 @@ export interface PluginSkill {
|
|
|
125
202
|
}
|
|
126
203
|
|
|
127
204
|
/** Each immediate subdirectory holding a SKILL.md is one skill. */
|
|
128
|
-
export function findPluginSkills(plugin: Plugin, skillsDir: string): PluginSkill[] {
|
|
205
|
+
export function findPluginSkills(plugin: Pick<Plugin, "name">, skillsDir: string): PluginSkill[] {
|
|
129
206
|
const skills: PluginSkill[] = [];
|
|
130
207
|
let entries: string[];
|
|
131
208
|
try {
|
|
@@ -149,7 +226,7 @@ export interface PluginCommand {
|
|
|
149
226
|
path: string;
|
|
150
227
|
}
|
|
151
228
|
|
|
152
|
-
export function findPluginCommands(plugin: Plugin, commandsDir: string): PluginCommand[] {
|
|
229
|
+
export function findPluginCommands(plugin: Pick<Plugin, "name">, commandsDir: string): PluginCommand[] {
|
|
153
230
|
let entries: string[];
|
|
154
231
|
try {
|
|
155
232
|
entries = readdirSync(commandsDir);
|
|
@@ -165,14 +242,51 @@ export function findPluginCommands(plugin: Plugin, commandsDir: string): PluginC
|
|
|
165
242
|
}));
|
|
166
243
|
}
|
|
167
244
|
|
|
245
|
+
/** The two roots + settings locations discovery reads from. */
|
|
246
|
+
export interface DiscoverRoots {
|
|
247
|
+
/** Claude Code's plugins dir (`~/.claude/plugins`) — read-only. */
|
|
248
|
+
claudePluginsDir: string;
|
|
249
|
+
/** One Code's plugin root (`<agentDir>/plugins`) — ours to write. */
|
|
250
|
+
oneCodeRoot: string;
|
|
251
|
+
cwd: string;
|
|
252
|
+
home: string;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/** The standard roots; wiring passes `getAgentDir()` (pure modules can't). */
|
|
256
|
+
export function defaultDiscoverRoots(agentDir: string, cwd: string = process.cwd(), home: string = os.homedir()): DiscoverRoots {
|
|
257
|
+
return {
|
|
258
|
+
claudePluginsDir: join(claudeUserDir(home), "plugins"),
|
|
259
|
+
oneCodeRoot: pluginRoot(agentDir),
|
|
260
|
+
cwd,
|
|
261
|
+
home,
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* The enabled-state precedence for a Claude Code-installed plugin: a One Code
|
|
267
|
+
* override wins, then Claude Code's own settings, then default-enabled.
|
|
268
|
+
*/
|
|
269
|
+
export function claudePluginEnabled(
|
|
270
|
+
id: string,
|
|
271
|
+
ccEnabled: Record<string, boolean>,
|
|
272
|
+
overrides: Record<string, boolean>,
|
|
273
|
+
): boolean {
|
|
274
|
+
return overrides[id] ?? ccEnabled[id] ?? true;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/** One Code-installed plugins carry enabled on their own registry entry. */
|
|
278
|
+
export function oneCodePluginEnabled(rawEnabled: boolean | undefined): boolean {
|
|
279
|
+
return rawEnabled ?? true;
|
|
280
|
+
}
|
|
281
|
+
|
|
168
282
|
/**
|
|
169
|
-
* Everything
|
|
283
|
+
* Everything the installed plugins contribute, resolved in one pass.
|
|
170
284
|
*
|
|
171
285
|
* IMPORTANT: this is a function each consuming extension calls for itself, not a
|
|
172
286
|
* shared singleton. Under jiti every extension file gets its own module
|
|
173
287
|
* instance, so module-level state written by one extension is invisible to
|
|
174
|
-
* another — a shared registry silently read as empty. Re-deriving is cheap (
|
|
175
|
-
* small JSON plus
|
|
288
|
+
* another — a shared registry silently read as empty. Re-deriving is cheap (a
|
|
289
|
+
* few small JSON files plus directory checks) and has no load-order constraints.
|
|
176
290
|
*/
|
|
177
291
|
export interface NamespacedDir {
|
|
178
292
|
dir: string;
|
|
@@ -180,48 +294,113 @@ export interface NamespacedDir {
|
|
|
180
294
|
}
|
|
181
295
|
|
|
182
296
|
export interface DiscoveredPlugins {
|
|
297
|
+
/** Every installed plugin, enabled or not (the /plugins panel needs both). */
|
|
183
298
|
plugins: Plugin[];
|
|
299
|
+
/** Enabled plugins only — the set whose resources are wired in below. */
|
|
300
|
+
enabledPlugins: Plugin[];
|
|
184
301
|
agentDirs: NamespacedDir[];
|
|
185
302
|
skills: PluginSkill[];
|
|
186
303
|
commands: PluginCommand[];
|
|
187
304
|
mcpConfigs: string[];
|
|
188
|
-
/**
|
|
189
|
-
|
|
305
|
+
/** The plugin each `mcpConfigs` path belongs to, so its servers can be namespaced `plugin:<name>:<server>`. */
|
|
306
|
+
mcpConfigPlugins: Map<string, string>;
|
|
307
|
+
/** Per-plugin summary keyed by plugin id, for status output. */
|
|
308
|
+
byPlugin: Map<string, { agents: boolean; skills: number; commands: number; mcp: boolean; lsp: boolean }>;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* ONE discovery cache for the whole process. Seven extensions import this
|
|
313
|
+
* module (branding, hooks, lsp, mcp, plugins, skill, subagents) and jiti gives
|
|
314
|
+
* each its own module instance, so a module-level variable meant seven full
|
|
315
|
+
* disk scans at startup and an invalidation that cleared only the caller's
|
|
316
|
+
* copy. The cache therefore hangs off `globalThis` under a symbol: a pure
|
|
317
|
+
* memo of filesystem state (not steering state — the module-isolation rule in
|
|
318
|
+
* CLAUDE.md is about *that*), read-through, cleared everywhere at once.
|
|
319
|
+
*/
|
|
320
|
+
const CACHE_SLOT = Symbol.for("one-code:plugins-discovery-cache");
|
|
321
|
+
type CacheBox = { current?: { key: string; result: DiscoveredPlugins } };
|
|
322
|
+
const box: CacheBox = ((globalThis as Record<symbol, unknown>)[CACHE_SLOT] ??= {}) as CacheBox;
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* Drops the process-wide discovery cache; the /plugins panel calls this after
|
|
326
|
+
* installs/toggles so every extension's next discoverPlugins() reflects them.
|
|
327
|
+
*/
|
|
328
|
+
export function invalidatePluginsCache(): void {
|
|
329
|
+
box.current = undefined;
|
|
190
330
|
}
|
|
191
331
|
|
|
192
|
-
|
|
332
|
+
export function discoverPlugins(roots: DiscoverRoots): DiscoveredPlugins {
|
|
333
|
+
const key = [roots.claudePluginsDir, roots.oneCodeRoot, roots.cwd, roots.home].join("\n");
|
|
334
|
+
if (box.current?.key === key) return box.current.result;
|
|
335
|
+
|
|
336
|
+
const ccEnabled = readEnabledPlugins(roots.cwd, roots.home);
|
|
337
|
+
const overrides = readOverrides(roots.oneCodeRoot);
|
|
338
|
+
const skillOverrides = readSkillStates(roots.oneCodeRoot);
|
|
193
339
|
|
|
194
|
-
|
|
195
|
-
|
|
340
|
+
const claudePlugins: Plugin[] = loadInstalledPlugins(roots.claudePluginsDir, roots.cwd).map((p) => ({
|
|
341
|
+
...p,
|
|
342
|
+
originRoot: "claude" as const,
|
|
343
|
+
enabled: claudePluginEnabled(p.id, ccEnabled, overrides),
|
|
344
|
+
overridden: p.id in overrides,
|
|
345
|
+
dataRoot: join(roots.claudePluginsDir, "data"),
|
|
346
|
+
}));
|
|
347
|
+
|
|
348
|
+
const oneCodePlugins: Plugin[] = loadInstalledPlugins(roots.oneCodeRoot, roots.cwd).map((p) => ({
|
|
349
|
+
...p,
|
|
350
|
+
originRoot: "one-code" as const,
|
|
351
|
+
enabled: oneCodePluginEnabled(p.rawEnabled),
|
|
352
|
+
dataRoot: join(roots.oneCodeRoot, "data"),
|
|
353
|
+
}));
|
|
354
|
+
|
|
355
|
+
// Same plugin id installed under both roots: the One Code copy wins — it's
|
|
356
|
+
// the one this harness actively manages.
|
|
357
|
+
const byId = new Map<string, Plugin>();
|
|
358
|
+
for (const plugin of [...claudePlugins, ...oneCodePlugins]) byId.set(plugin.id, plugin);
|
|
359
|
+
const plugins = [...byId.values()].sort((a, b) => a.name.localeCompare(b.name));
|
|
360
|
+
const enabledPlugins = plugins.filter((p) => p.enabled);
|
|
196
361
|
|
|
197
|
-
const plugins = loadInstalledPlugins(claudeDir);
|
|
198
362
|
const result: DiscoveredPlugins = {
|
|
199
363
|
plugins,
|
|
364
|
+
enabledPlugins,
|
|
200
365
|
agentDirs: [],
|
|
201
366
|
skills: [],
|
|
202
367
|
commands: [],
|
|
203
368
|
mcpConfigs: [],
|
|
369
|
+
mcpConfigPlugins: new Map(),
|
|
204
370
|
byPlugin: new Map(),
|
|
205
371
|
};
|
|
206
372
|
|
|
207
373
|
for (const plugin of plugins) {
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
374
|
+
// One plugin's fault (a broken symlink in its resource dirs, a statSync
|
|
375
|
+
// permission error) must skip only that plugin, not blank out every
|
|
376
|
+
// other plugin's skills/commands/agents for the session. safeDiscover's
|
|
377
|
+
// outer catch is the last resort; this keeps the blast radius to one entry.
|
|
378
|
+
try {
|
|
379
|
+
const resources = pluginResources(plugin);
|
|
380
|
+
const skills = resources.skillsDir ? findPluginSkills(plugin, resources.skillsDir) : [];
|
|
381
|
+
const commands = resources.commandsDir ? findPluginCommands(plugin, resources.commandsDir) : [];
|
|
382
|
+
|
|
383
|
+
result.byPlugin.set(plugin.id, {
|
|
384
|
+
agents: !!resources.agentsDir,
|
|
385
|
+
skills: skills.length,
|
|
386
|
+
commands: commands.length,
|
|
387
|
+
mcp: !!resources.mcpConfig,
|
|
388
|
+
lsp: !!resources.lspConfig,
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
if (!plugin.enabled) continue;
|
|
392
|
+
if (resources.agentsDir) result.agentDirs.push({ dir: resources.agentsDir, namespace: plugin.name });
|
|
393
|
+
result.skills.push(...skills.filter((s) => isSkillEnabled(skillOverrides, skillOverrideKey("plugin", s.name))));
|
|
394
|
+
result.commands.push(...commands);
|
|
395
|
+
if (resources.mcpConfig) {
|
|
396
|
+
result.mcpConfigs.push(resources.mcpConfig);
|
|
397
|
+
result.mcpConfigPlugins.set(resources.mcpConfig, plugin.name);
|
|
398
|
+
}
|
|
399
|
+
} catch (error) {
|
|
400
|
+
console.error(`One Code: skipping plugin "${plugin.id}" — its resources could not be read: ${(error as Error).message}`);
|
|
401
|
+
}
|
|
223
402
|
}
|
|
224
403
|
|
|
225
|
-
|
|
404
|
+
box.current = { key, result };
|
|
226
405
|
return result;
|
|
227
406
|
}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* First-token gate for parallel agent fan-out.
|
|
3
|
+
*
|
|
4
|
+
* A provider cache entry becomes readable only once the request that writes
|
|
5
|
+
* it has started streaming. N children started at the same instant with a
|
|
6
|
+
* byte-identical prefix (same agent prompt, same first-message reminder stack,
|
|
7
|
+
* same model) therefore all pay the cache write; none reads what the others
|
|
8
|
+
* are writing. The gate lets the FIRST run for a prefix key go, holds the rest
|
|
9
|
+
* until that run's first assistant `message_start` (the stream has begun, the
|
|
10
|
+
* entry exists), then releases them — every follower reads the prefix instead
|
|
11
|
+
* of writing it. Followers of a key that recently streamed pass straight
|
|
12
|
+
* through; a leader that never streams (spawn failure, provider error) hands
|
|
13
|
+
* the lead to the next waiter; a leader that is merely slow times out so a
|
|
14
|
+
* stalled first request can never hold a whole fan-out.
|
|
15
|
+
*
|
|
16
|
+
* Pure (no pi imports); wired in subagents/runner.ts and workflow/agent-session.ts.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
type Entry =
|
|
20
|
+
| { kind: "warming"; done: Promise<void>; resolve: () => void }
|
|
21
|
+
| { kind: "warm"; at: number };
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* The gate key for a child run: same prompt identity + cwd + model spec = same
|
|
25
|
+
* request prefix. Both fan-out sites (subagents/runner.ts, workflow/agent-session.ts)
|
|
26
|
+
* build their key here so the scheme cannot drift between them.
|
|
27
|
+
*/
|
|
28
|
+
export function prefixWarmKey(promptIdentity: string, cwd: string, modelSpec: string | undefined): string {
|
|
29
|
+
return `${promptIdentity}|${cwd}|${modelSpec ?? ""}`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Prompt identity of a fresh (non-fork) child: its agent definition, else the base prompt. */
|
|
33
|
+
export function agentPromptIdentity(agentName: string | undefined): string {
|
|
34
|
+
return agentName ? `agent:${agentName}` : "base";
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface PrefixWarmGateOptions {
|
|
38
|
+
/** How long a leader may take to stream before waiters are released anyway. */
|
|
39
|
+
timeoutMs?: number;
|
|
40
|
+
/** How long after the last stream start a key counts as warm (the 5-minute TTL minus slack). */
|
|
41
|
+
warmForMs?: number;
|
|
42
|
+
/** Clock, for tests. */
|
|
43
|
+
now?: () => number;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** What the caller reports when it is done with its admission. */
|
|
47
|
+
export type Release = (streamed: boolean) => void;
|
|
48
|
+
|
|
49
|
+
export class PrefixWarmGate {
|
|
50
|
+
private readonly entries = new Map<string, Entry>();
|
|
51
|
+
private readonly timeoutMs: number;
|
|
52
|
+
private readonly warmForMs: number;
|
|
53
|
+
private readonly now: () => number;
|
|
54
|
+
|
|
55
|
+
constructor(options: PrefixWarmGateOptions = {}) {
|
|
56
|
+
this.timeoutMs = options.timeoutMs ?? 20_000;
|
|
57
|
+
this.warmForMs = options.warmForMs ?? 4 * 60_000;
|
|
58
|
+
this.now = options.now ?? Date.now;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Wait until `key`'s prefix is (probably) cached, then return the release
|
|
63
|
+
* the caller must invoke: `release(true)` when its own response started
|
|
64
|
+
* streaming (refreshes the warm stamp; lets waiters go if it was the leader),
|
|
65
|
+
* `release(false)` if it gave up without streaming. Idempotent; the first
|
|
66
|
+
* call wins.
|
|
67
|
+
*/
|
|
68
|
+
async admit(key: string): Promise<Release> {
|
|
69
|
+
for (;;) {
|
|
70
|
+
const entry = this.entries.get(key);
|
|
71
|
+
if (entry?.kind === "warm" && this.now() - entry.at < this.warmForMs) {
|
|
72
|
+
return this.followerRelease(key);
|
|
73
|
+
}
|
|
74
|
+
if (entry?.kind === "warming") {
|
|
75
|
+
await entry.done;
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
return this.lead(key);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** How many keys are currently warming (for tests). */
|
|
83
|
+
get warmingCount(): number {
|
|
84
|
+
let count = 0;
|
|
85
|
+
for (const entry of this.entries.values()) if (entry.kind === "warming") count++;
|
|
86
|
+
return count;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
private lead(key: string): Release {
|
|
90
|
+
let resolve!: () => void;
|
|
91
|
+
const done = new Promise<void>((r) => {
|
|
92
|
+
resolve = r;
|
|
93
|
+
});
|
|
94
|
+
const entry: Entry = { kind: "warming", done, resolve };
|
|
95
|
+
this.entries.set(key, entry);
|
|
96
|
+
// A leader that streams slowly must not hold the fan-out forever: on
|
|
97
|
+
// timeout everyone proceeds and the key is treated as warm.
|
|
98
|
+
const timer = setTimeout(() => {
|
|
99
|
+
if (this.entries.get(key) === entry) {
|
|
100
|
+
this.entries.set(key, { kind: "warm", at: this.now() });
|
|
101
|
+
resolve();
|
|
102
|
+
}
|
|
103
|
+
}, this.timeoutMs);
|
|
104
|
+
timer.unref?.();
|
|
105
|
+
|
|
106
|
+
let released = false;
|
|
107
|
+
return (streamed) => {
|
|
108
|
+
if (released) return;
|
|
109
|
+
released = true;
|
|
110
|
+
clearTimeout(timer);
|
|
111
|
+
if (this.entries.get(key) === entry) {
|
|
112
|
+
// Streamed: the entry exists, followers read it. Abandoned: back to
|
|
113
|
+
// cold, so the next waiter becomes the leader (its loop re-enters lead()).
|
|
114
|
+
if (streamed) this.entries.set(key, { kind: "warm", at: this.now() });
|
|
115
|
+
else this.entries.delete(key);
|
|
116
|
+
}
|
|
117
|
+
resolve();
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* `admit(key)` for an agent session: the session's own first assistant
|
|
123
|
+
* `message_start` (the stream has begun, the entry exists) releases it as
|
|
124
|
+
* streamed; the returned release is for the caller's `finally`, where
|
|
125
|
+
* `release(false)` is a no-op if streaming already released it and hands the
|
|
126
|
+
* lead on if the run never streamed. Structural session type keeps this
|
|
127
|
+
* module pi-free.
|
|
128
|
+
*/
|
|
129
|
+
async admitOnFirstToken(
|
|
130
|
+
key: string,
|
|
131
|
+
session: { subscribe(listener: (event: unknown) => void): () => void },
|
|
132
|
+
): Promise<Release> {
|
|
133
|
+
const release = await this.admit(key);
|
|
134
|
+
const stop = session.subscribe((event) => {
|
|
135
|
+
const e = event as { type?: string; message?: { role?: string } };
|
|
136
|
+
if (e.type === "message_start" && e.message?.role === "assistant") {
|
|
137
|
+
stop();
|
|
138
|
+
release(true);
|
|
139
|
+
}
|
|
140
|
+
});
|
|
141
|
+
return (streamed) => {
|
|
142
|
+
stop();
|
|
143
|
+
release(streamed);
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
private followerRelease(key: string): Release {
|
|
148
|
+
let released = false;
|
|
149
|
+
return (streamed) => {
|
|
150
|
+
if (released) return;
|
|
151
|
+
released = true;
|
|
152
|
+
if (!streamed) return;
|
|
153
|
+
const entry = this.entries.get(key);
|
|
154
|
+
if (entry?.kind === "warm") this.entries.set(key, { kind: "warm", at: this.now() });
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Process-tree termination for the shells One Code spawns in the background
|
|
3
|
+
* (`bash run_in_background`, `monitor`).
|
|
4
|
+
*
|
|
5
|
+
* A `$SHELL -c "cmd"` child is only the leader: with zsh (macOS default) a
|
|
6
|
+
* `cmd; echo` or a pipeline runs the command in further processes, and
|
|
7
|
+
* `child.kill()` signals the shell alone — the command lives on as an orphan
|
|
8
|
+
* holding the inherited stdout pipe, so `close` never fires (the task cannot
|
|
9
|
+
* finish, a `task_output block:true` waits its full timeout) and in a one-shot
|
|
10
|
+
* run the open pipe keeps node alive until the orphan exits
|
|
11
|
+
* (LIFECYCLE-REVIEW-2026-09-06 M2, measured: 3 min 21 s on a `tail -f | cat`).
|
|
12
|
+
*
|
|
13
|
+
* The producer spawns `detached` (own process group; `detachedSpawnOptions`)
|
|
14
|
+
* and stops it with `killProcessTree`, which signals the negative pid — the
|
|
15
|
+
* whole group — falling back to the child alone when the group is already gone.
|
|
16
|
+
* `stopProcessTree` adds the grace period: SIGTERM now, SIGKILL if the leader
|
|
17
|
+
* has not exited by then (a command that ignores SIGTERM still closes late,
|
|
18
|
+
* which is exactly the late callback the shutdown paths guard against).
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import type { ChildProcess } from "node:child_process";
|
|
22
|
+
|
|
23
|
+
/** SIGTERM → SIGKILL grace for a stopped background tree (bash tasks, monitors). */
|
|
24
|
+
export const KILL_GRACE_MS = 2_000;
|
|
25
|
+
|
|
26
|
+
/** `detached` everywhere but Windows, where process groups do not exist. */
|
|
27
|
+
export function detachedSpawnOptions(): { detached: boolean } {
|
|
28
|
+
return { detached: process.platform !== "win32" };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function killProcessTree(child: ChildProcess, signal: NodeJS.Signals = "SIGTERM"): void {
|
|
32
|
+
if (child.pid == null) return;
|
|
33
|
+
try {
|
|
34
|
+
process.kill(-child.pid, signal);
|
|
35
|
+
} catch {
|
|
36
|
+
try {
|
|
37
|
+
child.kill(signal);
|
|
38
|
+
} catch {
|
|
39
|
+
// Already gone.
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* SIGTERM the tree, then SIGKILL it after `graceMs` unless the leader exited.
|
|
46
|
+
* The timer is unref'd: it must never be what keeps a one-shot process alive.
|
|
47
|
+
*/
|
|
48
|
+
export function stopProcessTree(child: ChildProcess, graceMs: number): void {
|
|
49
|
+
killProcessTree(child, "SIGTERM");
|
|
50
|
+
if (child.exitCode !== null || child.signalCode !== null) return;
|
|
51
|
+
const timer = setTimeout(() => {
|
|
52
|
+
if (child.exitCode === null && child.signalCode === null) killProcessTree(child, "SIGKILL");
|
|
53
|
+
}, graceMs);
|
|
54
|
+
timer.unref?.();
|
|
55
|
+
child.once("exit", () => clearTimeout(timer));
|
|
56
|
+
}
|