claws-code 0.8.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/.claude/commands/claws-auto.md +90 -0
- package/.claude/commands/claws-bin.md +28 -0
- package/.claude/commands/claws-cleanup.md +28 -0
- package/.claude/commands/claws-do.md +82 -0
- package/.claude/commands/claws-fix.md +40 -0
- package/.claude/commands/claws-goal.md +111 -0
- package/.claude/commands/claws-help.md +54 -0
- package/.claude/commands/claws-plan.md +103 -0
- package/.claude/commands/claws-report.md +29 -0
- package/.claude/commands/claws-status.md +37 -0
- package/.claude/commands/claws-update.md +32 -0
- package/.claude/commands/claws.md +64 -0
- package/.claude/rules/claws-default-behavior.md +76 -0
- package/.claude/settings.json +112 -0
- package/.claude/settings.local.json +19 -0
- package/.claude/skills/claws-auto-engine/SKILL.md +97 -0
- package/.claude/skills/claws-goal-tracker/SKILL.md +106 -0
- package/.claude/skills/claws-prompt-templates/SKILL.md +203 -0
- package/.claude/skills/claws-wave-lead/SKILL.md +126 -0
- package/.claude/skills/claws-wave-subworker/SKILL.md +60 -0
- package/CHANGELOG.md +1949 -0
- package/LICENSE +21 -0
- package/README.md +420 -0
- package/bin/cli.js +84 -0
- package/cli.js +223 -0
- package/docs/ARCHITECTURE.md +511 -0
- package/docs/event-protocol.md +588 -0
- package/docs/features.md +562 -0
- package/docs/guide.md +891 -0
- package/docs/index.html +716 -0
- package/docs/protocol.md +323 -0
- package/extension/.vscodeignore +15 -0
- package/extension/CHANGELOG.md +1906 -0
- package/extension/LICENSE +21 -0
- package/extension/README.md +137 -0
- package/extension/docs/features.md +424 -0
- package/extension/docs/protocol.md +197 -0
- package/extension/esbuild.mjs +25 -0
- package/extension/icon.png +0 -0
- package/extension/native/.metadata.json +10 -0
- package/extension/native/node-pty/LICENSE +69 -0
- package/extension/native/node-pty/README.md +165 -0
- package/extension/native/node-pty/lib/conpty_console_list_agent.js +16 -0
- package/extension/native/node-pty/lib/conpty_console_list_agent.js.map +1 -0
- package/extension/native/node-pty/lib/eventEmitter2.js +47 -0
- package/extension/native/node-pty/lib/eventEmitter2.js.map +1 -0
- package/extension/native/node-pty/lib/index.js +52 -0
- package/extension/native/node-pty/lib/index.js.map +1 -0
- package/extension/native/node-pty/lib/interfaces.js +7 -0
- package/extension/native/node-pty/lib/interfaces.js.map +1 -0
- package/extension/native/node-pty/lib/shared/conout.js +11 -0
- package/extension/native/node-pty/lib/shared/conout.js.map +1 -0
- package/extension/native/node-pty/lib/terminal.js +190 -0
- package/extension/native/node-pty/lib/terminal.js.map +1 -0
- package/extension/native/node-pty/lib/types.js +7 -0
- package/extension/native/node-pty/lib/types.js.map +1 -0
- package/extension/native/node-pty/lib/unixTerminal.js +346 -0
- package/extension/native/node-pty/lib/unixTerminal.js.map +1 -0
- package/extension/native/node-pty/lib/utils.js +39 -0
- package/extension/native/node-pty/lib/utils.js.map +1 -0
- package/extension/native/node-pty/lib/windowsConoutConnection.js +125 -0
- package/extension/native/node-pty/lib/windowsConoutConnection.js.map +1 -0
- package/extension/native/node-pty/lib/windowsPtyAgent.js +320 -0
- package/extension/native/node-pty/lib/windowsPtyAgent.js.map +1 -0
- package/extension/native/node-pty/lib/windowsTerminal.js +199 -0
- package/extension/native/node-pty/lib/windowsTerminal.js.map +1 -0
- package/extension/native/node-pty/lib/worker/conoutSocketWorker.js +22 -0
- package/extension/native/node-pty/lib/worker/conoutSocketWorker.js.map +1 -0
- package/extension/native/node-pty/package.json +64 -0
- package/extension/native/node-pty/prebuilds/darwin-arm64/pty.node +0 -0
- package/extension/native/node-pty/prebuilds/darwin-arm64/spawn-helper +0 -0
- package/extension/native/node-pty/prebuilds/darwin-x64/pty.node +0 -0
- package/extension/native/node-pty/prebuilds/darwin-x64/spawn-helper +0 -0
- package/extension/native/node-pty/prebuilds/win32-arm64/conpty/OpenConsole.exe +0 -0
- package/extension/native/node-pty/prebuilds/win32-arm64/conpty/conpty.dll +0 -0
- package/extension/native/node-pty/prebuilds/win32-arm64/conpty.node +0 -0
- package/extension/native/node-pty/prebuilds/win32-arm64/conpty_console_list.node +0 -0
- package/extension/native/node-pty/prebuilds/win32-arm64/pty.node +0 -0
- package/extension/native/node-pty/prebuilds/win32-arm64/winpty-agent.exe +0 -0
- package/extension/native/node-pty/prebuilds/win32-arm64/winpty.dll +0 -0
- package/extension/native/node-pty/prebuilds/win32-x64/conpty/OpenConsole.exe +0 -0
- package/extension/native/node-pty/prebuilds/win32-x64/conpty/conpty.dll +0 -0
- package/extension/native/node-pty/prebuilds/win32-x64/conpty.node +0 -0
- package/extension/native/node-pty/prebuilds/win32-x64/conpty_console_list.node +0 -0
- package/extension/native/node-pty/prebuilds/win32-x64/pty.node +0 -0
- package/extension/native/node-pty/prebuilds/win32-x64/winpty-agent.exe +0 -0
- package/extension/native/node-pty/prebuilds/win32-x64/winpty.dll +0 -0
- package/extension/package-lock.json +605 -0
- package/extension/package.json +343 -0
- package/extension/scripts/bundle-native.mjs +104 -0
- package/extension/scripts/deploy-dev.mjs +60 -0
- package/extension/src/ansi-strip.ts +52 -0
- package/extension/src/backends/vscode/claws-pty.ts +483 -0
- package/extension/src/backends/vscode/status-bar.ts +99 -0
- package/extension/src/backends/vscode/vscode-backend.ts +282 -0
- package/extension/src/capture-store.ts +125 -0
- package/extension/src/event-log.ts +629 -0
- package/extension/src/event-schemas.ts +478 -0
- package/extension/src/extension.js +492 -0
- package/extension/src/extension.ts +873 -0
- package/extension/src/lifecycle-engine.ts +60 -0
- package/extension/src/lifecycle-rules.ts +171 -0
- package/extension/src/lifecycle-store.ts +506 -0
- package/extension/src/peer-registry.ts +176 -0
- package/extension/src/pipeline-registry.ts +82 -0
- package/extension/src/platform.ts +64 -0
- package/extension/src/protocol.ts +532 -0
- package/extension/src/server-config.ts +98 -0
- package/extension/src/server.ts +2210 -0
- package/extension/src/task-registry.ts +51 -0
- package/extension/src/terminal-backend.ts +211 -0
- package/extension/src/terminal-manager.ts +395 -0
- package/extension/src/topic-registry.ts +70 -0
- package/extension/src/topic-utils.ts +46 -0
- package/extension/src/transport.ts +45 -0
- package/extension/src/uninstall-cleanup.ts +232 -0
- package/extension/src/wave-registry.ts +314 -0
- package/extension/src/websocket-transport.ts +153 -0
- package/extension/tsconfig.json +23 -0
- package/lib/capabilities.js +145 -0
- package/lib/dry-run.js +43 -0
- package/lib/install.js +1018 -0
- package/lib/mcp-setup.js +92 -0
- package/lib/platform.js +240 -0
- package/lib/preflight.js +152 -0
- package/lib/shell-hook.js +343 -0
- package/lib/uninstall.js +162 -0
- package/lib/verify.js +166 -0
- package/mcp_server.js +3529 -0
- package/package.json +48 -0
- package/rules/claws-default-behavior.md +72 -0
- package/scripts/_helpers/atomic-file.mjs +137 -0
- package/scripts/_helpers/fix-repair.js +64 -0
- package/scripts/_helpers/json-safe.mjs +218 -0
- package/scripts/bump-version.sh +84 -0
- package/scripts/codegen/gen-docs.mjs +61 -0
- package/scripts/codegen/gen-json-schema.mjs +62 -0
- package/scripts/codegen/gen-mcp-tools.mjs +358 -0
- package/scripts/codegen/gen-types.mjs +172 -0
- package/scripts/codegen/index.mjs +42 -0
- package/scripts/dev-hooks/check-extension-dirs.js +77 -0
- package/scripts/dev-hooks/check-open-claws-terminals.js +70 -0
- package/scripts/dev-hooks/check-stale-main.js +55 -0
- package/scripts/dev-hooks/check-tag-pushed.js +51 -0
- package/scripts/dev-hooks/check-tag-vs-main.js +56 -0
- package/scripts/dev-vsix-install.sh +60 -0
- package/scripts/fix.sh +702 -0
- package/scripts/gen-client-types.mjs +81 -0
- package/scripts/git-hooks/pre-commit +31 -0
- package/scripts/hooks/lifecycle-state.js +61 -0
- package/scripts/hooks/package.json +4 -0
- package/scripts/hooks/post-tool-use-claws.js +292 -0
- package/scripts/hooks/pre-bash-no-verify-block.js +72 -0
- package/scripts/hooks/pre-tool-use-claws.js +206 -0
- package/scripts/hooks/session-start-claws.js +97 -0
- package/scripts/hooks/stop-claws.js +88 -0
- package/scripts/inject-claude-md.js +205 -0
- package/scripts/inject-dev-hooks.js +96 -0
- package/scripts/inject-global-claude-md.js +140 -0
- package/scripts/inject-settings-hooks.js +370 -0
- package/scripts/install.ps1 +146 -0
- package/scripts/install.sh +1729 -0
- package/scripts/monitor-arm-watch.js +155 -0
- package/scripts/rebuild-node-pty.sh +245 -0
- package/scripts/report.sh +232 -0
- package/scripts/shell-hook.fish +164 -0
- package/scripts/shell-hook.ps1 +33 -0
- package/scripts/shell-hook.sh +232 -0
- package/scripts/stream-events.js +399 -0
- package/scripts/terminal-wrapper.sh +36 -0
- package/scripts/test-enforcement.sh +132 -0
- package/scripts/test-install.sh +174 -0
- package/scripts/test-installer-parity.sh +135 -0
- package/scripts/test-template-enforcement.sh +76 -0
- package/scripts/uninstall.sh +143 -0
- package/scripts/update.sh +337 -0
- package/scripts/verify-release.sh +323 -0
- package/scripts/verify-wrapped.sh +194 -0
- package/templates/CLAUDE.global.md +135 -0
- package/templates/CLAUDE.project.md +37 -0
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Task registry types for the claws/2 Agentic SDLC protocol.
|
|
3
|
+
* Tasks are in-memory only — cleared when the extension reloads.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Lifecycle status of a task. Terminal states are 'succeeded', 'failed',
|
|
8
|
+
* and 'skipped' — a task in any of these cannot be mutated by further
|
|
9
|
+
* update/complete calls (complete is idempotent on the terminal state).
|
|
10
|
+
*/
|
|
11
|
+
export type TaskStatus = 'pending' | 'running' | 'blocked' | 'succeeded' | 'failed' | 'skipped';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* In-memory record of a single task tracked by the server. Created by
|
|
15
|
+
* `task.assign` and mutated by `task.update` / `task.complete` / `task.cancel`.
|
|
16
|
+
* Exposed verbatim (shallow-copied) in `task.list` responses.
|
|
17
|
+
*/
|
|
18
|
+
export interface TaskRecord {
|
|
19
|
+
/** Unique task id, format "t_NNN" (zero-padded to 3 digits). */
|
|
20
|
+
taskId: string;
|
|
21
|
+
/** Short human-readable description of the task. */
|
|
22
|
+
title: string;
|
|
23
|
+
/** The prompt or instruction to deliver to the assignee. */
|
|
24
|
+
prompt: string;
|
|
25
|
+
/** peerId of the worker assigned this task. */
|
|
26
|
+
assignee: string;
|
|
27
|
+
/** peerId of the orchestrator that created the task. */
|
|
28
|
+
assignedBy: string;
|
|
29
|
+
status: TaskStatus;
|
|
30
|
+
progressPct?: number;
|
|
31
|
+
note?: string;
|
|
32
|
+
result?: unknown;
|
|
33
|
+
artifacts?: Array<{ type: string; path: string }>;
|
|
34
|
+
assignedAt: number;
|
|
35
|
+
updatedAt: number;
|
|
36
|
+
completedAt?: number;
|
|
37
|
+
timeoutMs?: number;
|
|
38
|
+
/** Set to true when task.cancel has been called; worker should observe and complete with skipped. */
|
|
39
|
+
cancelRequested?: boolean;
|
|
40
|
+
cancelReason?: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Allocates a task id from a monotonic sequence. The wire format is a
|
|
45
|
+
* stable "t_" prefix followed by the sequence number zero-padded to 3
|
|
46
|
+
* digits (e.g. "t_001", "t_042"). Callers must pre-increment their own
|
|
47
|
+
* counter and pass the new value in — this function is pure.
|
|
48
|
+
*/
|
|
49
|
+
export function allocTaskId(seq: number): string {
|
|
50
|
+
return `t_${String(seq).padStart(3, '0')}`;
|
|
51
|
+
}
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
// extension/src/terminal-backend.ts
|
|
2
|
+
// TerminalBackend interface — the single seam between Claws core and the
|
|
3
|
+
// platform that actually runs terminal processes.
|
|
4
|
+
//
|
|
5
|
+
// Implementations:
|
|
6
|
+
// VsCodeBackend — extension/src/backends/vscode/vscode-backend.ts
|
|
7
|
+
// TmuxBackend — extension/src/backends/tmux/tmux-backend.ts (v0.9)
|
|
8
|
+
|
|
9
|
+
// ─── Shared data types ────────────────────────────────────────────────────
|
|
10
|
+
|
|
11
|
+
/** Options for creating a new terminal. */
|
|
12
|
+
export interface BackendCreateOptions {
|
|
13
|
+
name?: string;
|
|
14
|
+
cwd?: string;
|
|
15
|
+
/** When true, the backend MUST capture all pty output to a log. Required for readLog. */
|
|
16
|
+
wrapped?: boolean;
|
|
17
|
+
shellPath?: string;
|
|
18
|
+
shellArgs?: string[];
|
|
19
|
+
env?: Record<string, string>;
|
|
20
|
+
/** AC-1: when present, propagated as CLAWS_TERMINAL_CORR_ID env var to the spawned pty
|
|
21
|
+
* and used to emit system.terminal.ready on the first pty byte. */
|
|
22
|
+
correlationId?: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Snapshot of one terminal as seen by the backend. */
|
|
26
|
+
export interface BackendTerminalInfo {
|
|
27
|
+
/** Backend-assigned stable string ID (e.g. "3" for VS Code, "claws:@3" for tmux window index). */
|
|
28
|
+
id: string;
|
|
29
|
+
name: string;
|
|
30
|
+
/** PID of the shell (or primary process) running inside the terminal. null if unknown. */
|
|
31
|
+
shellPid: number | null;
|
|
32
|
+
/** True if this terminal is capturing pty output to a log file. */
|
|
33
|
+
wrapped: boolean;
|
|
34
|
+
/** Absolute path to the log file if wrapped and file-based. null for in-memory capture. */
|
|
35
|
+
logPath: string | null;
|
|
36
|
+
/** Platform-specific status. */
|
|
37
|
+
status: 'alive' | 'closed' | 'unknown';
|
|
38
|
+
/** Vehicle lifecycle state (VsCodeBackend only — optional). */
|
|
39
|
+
vehicleState?: string;
|
|
40
|
+
/** Shell process PID (VS Code terminal.processId — separate from pty shellPid in wrapped case). */
|
|
41
|
+
pid?: number | null;
|
|
42
|
+
/** True if VS Code shell integration is active (VS Code only). */
|
|
43
|
+
hasShellIntegration?: boolean;
|
|
44
|
+
/** pty mode ('pty' | 'pipe' | 'none') — VsCodeBackend only. */
|
|
45
|
+
ptyMode?: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Result of a readLog call. Mirrors the existing CaptureSlice shape. */
|
|
49
|
+
export interface BackendLogSlice {
|
|
50
|
+
bytes: string;
|
|
51
|
+
offset: number;
|
|
52
|
+
nextOffset: number;
|
|
53
|
+
totalSize: number;
|
|
54
|
+
truncated: boolean;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Options for sendText. */
|
|
58
|
+
export interface BackendSendOptions {
|
|
59
|
+
/** Append a newline/Enter after the text. Default true. */
|
|
60
|
+
newline?: boolean;
|
|
61
|
+
/**
|
|
62
|
+
* Use bracketed-paste mode to wrap the text (prevents line-by-line
|
|
63
|
+
* fragmentation in shell/TUI programs). Default false.
|
|
64
|
+
*/
|
|
65
|
+
paste?: boolean;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// ─── Events emitted by a TerminalBackend ─────────────────────────────────
|
|
69
|
+
|
|
70
|
+
export interface TerminalCreatedEvent {
|
|
71
|
+
id: string;
|
|
72
|
+
name: string;
|
|
73
|
+
wrapped: boolean;
|
|
74
|
+
logPath: string | null;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export interface TerminalClosedEvent {
|
|
78
|
+
id: string;
|
|
79
|
+
/** Who initiated the close. Matches TerminalCloseOrigin enum in event-schemas.ts. */
|
|
80
|
+
origin: 'orchestrator' | 'user' | 'process_exit' | 'backend'
|
|
81
|
+
| 'marker' | 'error' | 'timeout' | 'pub_complete'
|
|
82
|
+
| 'wave_violation' | 'idle_timeout' | 'ttl_max';
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export interface TerminalDataEvent {
|
|
86
|
+
id: string;
|
|
87
|
+
/** Raw pty bytes (may contain ANSI codes). */
|
|
88
|
+
data: string;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** AC-1: emitted by a backend on the first pty byte of a wrapped terminal that was created
|
|
92
|
+
* with a correlationId. Subscribers (server.ts) translate this into system.terminal.ready. */
|
|
93
|
+
export interface TerminalReadyEvent {
|
|
94
|
+
id: string;
|
|
95
|
+
correlationId: string;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** AF-AC Phase 1: emitted by a backend when the underlying pty process exits, before the
|
|
99
|
+
* VS Code terminal UI is torn down. Fires from ClawsPty.handleExit via onProcessExitHook. */
|
|
100
|
+
export interface TerminalProcessExitedEvent {
|
|
101
|
+
id: string;
|
|
102
|
+
correlationId: string | null;
|
|
103
|
+
exitCode: number;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export interface ForegroundProcessInfo {
|
|
107
|
+
pid: number | null;
|
|
108
|
+
/** Basename of the foreground command (e.g. 'claude', 'bash', 'vim'). null if unknown. */
|
|
109
|
+
basename: string | null;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// ─── The interface ────────────────────────────────────────────────────────
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* TerminalBackend is the single seam between Claws core (server.ts, lifecycle,
|
|
116
|
+
* wave army) and the platform that actually runs terminal processes.
|
|
117
|
+
*/
|
|
118
|
+
export interface TerminalBackend {
|
|
119
|
+
// ── Lifecycle ──────────────────────────────────────────────────────────
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Start the backend. Called once at server startup.
|
|
123
|
+
* Resolves when the backend is ready to accept commands.
|
|
124
|
+
*/
|
|
125
|
+
start(): Promise<void>;
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Tear down the backend. Called on extension deactivate or server stop.
|
|
129
|
+
* Does NOT close terminals — call closeTerminal for that.
|
|
130
|
+
*/
|
|
131
|
+
dispose(): void;
|
|
132
|
+
|
|
133
|
+
// ── Terminal CRUD ──────────────────────────────────────────────────────
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Create a new terminal. If opts.wrapped is true, the backend MUST pipe pty
|
|
137
|
+
* output so readLog works.
|
|
138
|
+
*/
|
|
139
|
+
createTerminal(opts: BackendCreateOptions): Promise<{ id: string; logPath: string | null }>;
|
|
140
|
+
|
|
141
|
+
/** List all currently live terminals managed by this backend instance. */
|
|
142
|
+
listTerminals(): Promise<BackendTerminalInfo[]>;
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Send text into the terminal's pty input stream.
|
|
146
|
+
* Must be a no-op if the terminal is not alive; MUST NOT throw.
|
|
147
|
+
*/
|
|
148
|
+
sendText(id: string, text: string, opts?: BackendSendOptions): Promise<void>;
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Close and destroy a terminal. Idempotent — does not throw if already closed.
|
|
152
|
+
*/
|
|
153
|
+
closeTerminal(id: string, origin?: TerminalClosedEvent['origin']): Promise<void>;
|
|
154
|
+
|
|
155
|
+
// ── Log reading ────────────────────────────────────────────────────────
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Read pty output from a wrapped terminal's capture log.
|
|
159
|
+
* Throws if the terminal is not wrapped.
|
|
160
|
+
* offset: byte position to read from (undefined → tail N bytes)
|
|
161
|
+
* limit: max bytes to return
|
|
162
|
+
* strip: strip ANSI escape codes before returning
|
|
163
|
+
*/
|
|
164
|
+
readLog(id: string, offset: number | undefined, limit: number, strip: boolean): Promise<BackendLogSlice>;
|
|
165
|
+
|
|
166
|
+
// ── Process inspection ─────────────────────────────────────────────────
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Return the foreground process running inside the terminal.
|
|
170
|
+
* Returns { pid: null, basename: null } if the backend cannot determine this.
|
|
171
|
+
*/
|
|
172
|
+
getForegroundProcess(id: string): Promise<ForegroundProcessInfo>;
|
|
173
|
+
|
|
174
|
+
// ── Events ─────────────────────────────────────────────────────────────
|
|
175
|
+
|
|
176
|
+
on(event: 'terminal:created', listener: (ev: TerminalCreatedEvent) => void): this;
|
|
177
|
+
on(event: 'terminal:closed', listener: (ev: TerminalClosedEvent) => void): this;
|
|
178
|
+
on(event: 'terminal:data', listener: (ev: TerminalDataEvent) => void): this;
|
|
179
|
+
/** AC-1: first pty byte for a wrapped terminal created with correlationId. */
|
|
180
|
+
on(event: 'terminal:ready', listener: (ev: TerminalReadyEvent) => void): this;
|
|
181
|
+
/** AF-AC Phase 1: pty process exited, fires before VS Code UI teardown. */
|
|
182
|
+
on(event: 'terminal:process_exited', listener: (ev: TerminalProcessExitedEvent) => void): this;
|
|
183
|
+
off(event: string, listener: (...args: unknown[]) => void): this;
|
|
184
|
+
|
|
185
|
+
// ── Optional capabilities ──────────────────────────────────────────────
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Bring the terminal into the foreground. No-op if not supported.
|
|
189
|
+
*/
|
|
190
|
+
focusTerminal?(id: string): Promise<void>;
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Execute a command with output capture and exit code detection.
|
|
194
|
+
* Optional — server falls back to sendText + readLog polling if absent.
|
|
195
|
+
*/
|
|
196
|
+
execCommand?(id: string, command: string, timeoutMs: number): Promise<{
|
|
197
|
+
output: string;
|
|
198
|
+
exitCode: number | null;
|
|
199
|
+
}>;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Factory function signature. Each backend module exports a function matching this.
|
|
204
|
+
*/
|
|
205
|
+
export type TerminalBackendFactory = (opts: BackendFactoryOptions) => TerminalBackend;
|
|
206
|
+
|
|
207
|
+
export interface BackendFactoryOptions {
|
|
208
|
+
workspaceRoot: string;
|
|
209
|
+
logger: (msg: string) => void;
|
|
210
|
+
captureStore: import('./capture-store').CaptureStore;
|
|
211
|
+
}
|
|
@@ -0,0 +1,395 @@
|
|
|
1
|
+
import * as vscode from 'vscode';
|
|
2
|
+
import { CaptureStore } from './capture-store';
|
|
3
|
+
import { ClawsPty } from './backends/vscode/claws-pty';
|
|
4
|
+
import { TerminalDescriptor } from './protocol';
|
|
5
|
+
import { VehicleStateName, TerminalCloseOrigin } from './event-schemas';
|
|
6
|
+
|
|
7
|
+
type StateChangeCallback = (id: string, from: VehicleStateName | null, to: VehicleStateName) => void;
|
|
8
|
+
type ContentChangeCallback = (id: string, pid: number | null, basename: string | null) => void;
|
|
9
|
+
type TerminalCloseCallback = (id: string, wrapped: boolean, origin: TerminalCloseOrigin) => void;
|
|
10
|
+
|
|
11
|
+
const VALID_TRANSITIONS: Readonly<Record<VehicleStateName, readonly VehicleStateName[]>> = {
|
|
12
|
+
PROVISIONING: ['BOOTING', 'CLOSING'],
|
|
13
|
+
BOOTING: ['READY', 'CLOSING'],
|
|
14
|
+
READY: ['BUSY', 'IDLE', 'CLOSING'],
|
|
15
|
+
BUSY: ['IDLE', 'CLOSING'],
|
|
16
|
+
IDLE: ['BUSY', 'CLOSING'],
|
|
17
|
+
CLOSING: ['CLOSED'],
|
|
18
|
+
CLOSED: [],
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const CONTENT_DETECTION_INTERVAL_MS = 2000;
|
|
22
|
+
|
|
23
|
+
interface TerminalRecord {
|
|
24
|
+
id: string;
|
|
25
|
+
terminal: vscode.Terminal;
|
|
26
|
+
pty: ClawsPty | null;
|
|
27
|
+
wrapped: boolean;
|
|
28
|
+
logPath: string | null;
|
|
29
|
+
name: string;
|
|
30
|
+
vehicleState: VehicleStateName;
|
|
31
|
+
contentDetectionTimer: NodeJS.Timeout | null;
|
|
32
|
+
lastForegroundBasename: string | null | undefined;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface CreateOptions {
|
|
36
|
+
name?: string;
|
|
37
|
+
cwd?: string;
|
|
38
|
+
wrapped?: boolean;
|
|
39
|
+
shellPath?: string;
|
|
40
|
+
env?: Record<string, string>;
|
|
41
|
+
show?: boolean;
|
|
42
|
+
preserveFocus?: boolean;
|
|
43
|
+
/** AC-1: propagated as CLAWS_TERMINAL_CORR_ID env var to the spawned pty. */
|
|
44
|
+
correlationId?: string;
|
|
45
|
+
/** AC-1: called on the first pty byte; used by VsCodeBackend to emit terminal:ready. */
|
|
46
|
+
onFirstOutput?: (id: string) => void;
|
|
47
|
+
/** AF-AC Phase 1: called from ClawsPty.handleExit before closeEmitter.fire. */
|
|
48
|
+
onProcessExit?: (termId: string, code: number) => void;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// If VS Code never calls our Pseudoterminal.open() hook within this window
|
|
52
|
+
// after a programmatic createWrapped, we treat the ClawsPty as orphaned and
|
|
53
|
+
// dispose it. Covers the pathological case where the extension host crashes
|
|
54
|
+
// or VS Code silently drops the terminal spec.
|
|
55
|
+
const UNOPENED_PTY_TIMEOUT_MS = 60_000;
|
|
56
|
+
// Interval at which we scan for stale un-opened PTYs. 10s is frequent enough
|
|
57
|
+
// that cleanup feels responsive but infrequent enough that it's invisible
|
|
58
|
+
// in perf traces.
|
|
59
|
+
const UNOPENED_PTY_SCAN_INTERVAL_MS = 10_000;
|
|
60
|
+
|
|
61
|
+
export class TerminalManager {
|
|
62
|
+
private readonly records = new Map<string, TerminalRecord>();
|
|
63
|
+
private readonly byTerminal = new Map<vscode.Terminal, string>();
|
|
64
|
+
private nextId = 1;
|
|
65
|
+
private unopenedScanTimer: NodeJS.Timeout | null = null;
|
|
66
|
+
private onStateChange: StateChangeCallback | null = null;
|
|
67
|
+
private onContentChange: ContentChangeCallback | null = null;
|
|
68
|
+
private onTerminalClose: TerminalCloseCallback | null = null;
|
|
69
|
+
|
|
70
|
+
constructor(
|
|
71
|
+
private readonly captureStore: CaptureStore,
|
|
72
|
+
private readonly logger: (msg: string) => void,
|
|
73
|
+
) {
|
|
74
|
+
this.startUnopenedScan();
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Wire the vehicle state change callback. Called by ClawsServer after construction. */
|
|
78
|
+
setStateChangeCallback(cb: StateChangeCallback): void {
|
|
79
|
+
this.onStateChange = cb;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Wire the content change callback. Called by ClawsServer after construction. */
|
|
83
|
+
setContentChangeCallback(cb: ContentChangeCallback): void {
|
|
84
|
+
this.onContentChange = cb;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Wire the terminal close callback. Fires for every Claws-tracked terminal on close. */
|
|
88
|
+
setTerminalCloseCallback(cb: TerminalCloseCallback): void {
|
|
89
|
+
this.onTerminalClose = cb;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
private transitionState(rec: TerminalRecord, to: VehicleStateName): void {
|
|
93
|
+
const from = rec.vehicleState;
|
|
94
|
+
if (!VALID_TRANSITIONS[from]?.includes(to)) {
|
|
95
|
+
this.logger(`[terminal-manager] invalid state transition ${from} → ${to} for terminal ${rec.id}`);
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
rec.vehicleState = to;
|
|
99
|
+
this.onStateChange?.(rec.id, from, to);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
private emitInitialState(rec: TerminalRecord): void {
|
|
103
|
+
this.onStateChange?.(rec.id, null, rec.vehicleState);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
private startContentDetection(rec: TerminalRecord): void {
|
|
107
|
+
if (rec.contentDetectionTimer || !rec.pty) return;
|
|
108
|
+
const poll = () => {
|
|
109
|
+
if (!rec.pty) return;
|
|
110
|
+
const { pid, basename } = rec.pty.getForegroundProcess();
|
|
111
|
+
if (basename !== rec.lastForegroundBasename) {
|
|
112
|
+
rec.lastForegroundBasename = basename;
|
|
113
|
+
this.onContentChange?.(rec.id, pid, basename);
|
|
114
|
+
}
|
|
115
|
+
};
|
|
116
|
+
// Fire immediately after a short delay to capture the initial shell state.
|
|
117
|
+
setTimeout(poll, 1500);
|
|
118
|
+
const timer = setInterval(poll, CONTENT_DETECTION_INTERVAL_MS);
|
|
119
|
+
if (typeof timer.unref === 'function') timer.unref();
|
|
120
|
+
rec.contentDetectionTimer = timer;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
private stopContentDetection(rec: TerminalRecord): void {
|
|
124
|
+
if (rec.contentDetectionTimer) {
|
|
125
|
+
clearInterval(rec.contentDetectionTimer);
|
|
126
|
+
rec.contentDetectionTimer = null;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
adoptExisting(terminals: readonly vscode.Terminal[]): void {
|
|
131
|
+
for (const t of terminals) this.idFor(t);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
get terminalCount(): number { return this.records.size; }
|
|
135
|
+
|
|
136
|
+
idFor(terminal: vscode.Terminal): string {
|
|
137
|
+
const existing = this.byTerminal.get(terminal);
|
|
138
|
+
if (existing) return existing;
|
|
139
|
+
const id = String(this.nextId++);
|
|
140
|
+
this.byTerminal.set(terminal, id);
|
|
141
|
+
this.records.set(id, {
|
|
142
|
+
id,
|
|
143
|
+
terminal,
|
|
144
|
+
pty: null,
|
|
145
|
+
wrapped: false,
|
|
146
|
+
logPath: null,
|
|
147
|
+
name: terminal.name,
|
|
148
|
+
vehicleState: 'PROVISIONING',
|
|
149
|
+
contentDetectionTimer: null,
|
|
150
|
+
lastForegroundBasename: undefined,
|
|
151
|
+
});
|
|
152
|
+
return id;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
terminalById(id: string | number): vscode.Terminal | null {
|
|
156
|
+
return this.records.get(String(id))?.terminal ?? null;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
recordById(id: string | number): TerminalRecord | null {
|
|
160
|
+
return this.records.get(String(id)) ?? null;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* LH-9: Snapshot of currently-tracked terminal IDs. Used for boot
|
|
165
|
+
* reconciliation against lifecycle-state.json — any spawned_worker not
|
|
166
|
+
* in this set has died while we were down and should be marked closed.
|
|
167
|
+
*/
|
|
168
|
+
liveTerminalIds(): Set<string> {
|
|
169
|
+
return new Set(this.records.keys());
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Describe a terminal WITHOUT mutating state. If the terminal has never
|
|
174
|
+
* been adopted by the manager (no entry in byTerminal), we return a
|
|
175
|
+
* minimal descriptor with `status: 'unknown'` and no stable id. Adoption
|
|
176
|
+
* happens elsewhere — typically in the `onDidOpenTerminal` event handler.
|
|
177
|
+
*/
|
|
178
|
+
async describe(terminal: vscode.Terminal): Promise<TerminalDescriptor> {
|
|
179
|
+
const existingId = this.byTerminal.get(terminal);
|
|
180
|
+
let pid: number | null = null;
|
|
181
|
+
try {
|
|
182
|
+
const p = await terminal.processId;
|
|
183
|
+
pid = p ?? null;
|
|
184
|
+
} catch {
|
|
185
|
+
pid = null;
|
|
186
|
+
}
|
|
187
|
+
if (!existingId) {
|
|
188
|
+
return {
|
|
189
|
+
id: '',
|
|
190
|
+
name: terminal.name,
|
|
191
|
+
pid,
|
|
192
|
+
ptyPid: null,
|
|
193
|
+
hasShellIntegration: !!terminal.shellIntegration,
|
|
194
|
+
active: vscode.window.activeTerminal === terminal,
|
|
195
|
+
logPath: null,
|
|
196
|
+
wrapped: false,
|
|
197
|
+
status: 'unknown',
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
const rec = this.records.get(existingId);
|
|
201
|
+
// R7: surface the real shell pid from our ClawsPty (ptyProc.pid or childProc.pid).
|
|
202
|
+
// VS Code's `terminal.processId` is null for Pseudoterminal-based terminals.
|
|
203
|
+
const ptyPid = rec?.pty?.pid ?? null;
|
|
204
|
+
const ptyMode = rec?.pty?.mode;
|
|
205
|
+
return {
|
|
206
|
+
id: existingId,
|
|
207
|
+
name: terminal.name,
|
|
208
|
+
pid,
|
|
209
|
+
ptyPid,
|
|
210
|
+
ptyMode,
|
|
211
|
+
hasShellIntegration: !!terminal.shellIntegration,
|
|
212
|
+
active: vscode.window.activeTerminal === terminal,
|
|
213
|
+
logPath: rec?.logPath ?? null,
|
|
214
|
+
wrapped: rec?.wrapped ?? false,
|
|
215
|
+
status: 'adopted',
|
|
216
|
+
vehicleState: rec?.vehicleState,
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
async describeAll(): Promise<TerminalDescriptor[]> {
|
|
221
|
+
const out: TerminalDescriptor[] = [];
|
|
222
|
+
for (const t of vscode.window.terminals) {
|
|
223
|
+
out.push(await this.describe(t));
|
|
224
|
+
}
|
|
225
|
+
return out;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
createWrapped(options: CreateOptions): { id: string; terminal: vscode.Terminal; pty: ClawsPty } {
|
|
229
|
+
const id = String(this.nextId++);
|
|
230
|
+
const rec: TerminalRecord = {
|
|
231
|
+
id,
|
|
232
|
+
terminal: null as unknown as vscode.Terminal, // set below after createTerminal
|
|
233
|
+
pty: null,
|
|
234
|
+
wrapped: true,
|
|
235
|
+
logPath: null,
|
|
236
|
+
name: options.name || `Claws ${id}`,
|
|
237
|
+
vehicleState: 'PROVISIONING',
|
|
238
|
+
contentDetectionTimer: null,
|
|
239
|
+
lastForegroundBasename: undefined,
|
|
240
|
+
};
|
|
241
|
+
this.records.set(id, rec);
|
|
242
|
+
|
|
243
|
+
// Emit PROVISIONING immediately, then transition to BOOTING synchronously.
|
|
244
|
+
this.emitInitialState(rec);
|
|
245
|
+
this.transitionState(rec, 'BOOTING');
|
|
246
|
+
|
|
247
|
+
const pty = new ClawsPty({
|
|
248
|
+
terminalId: id,
|
|
249
|
+
shellPath: options.shellPath,
|
|
250
|
+
cwd: options.cwd,
|
|
251
|
+
env: options.env,
|
|
252
|
+
correlationId: options.correlationId,
|
|
253
|
+
captureStore: this.captureStore,
|
|
254
|
+
logger: this.logger,
|
|
255
|
+
// When VS Code calls open() on the Pseudoterminal, flip to READY and
|
|
256
|
+
// start polling for foreground process changes.
|
|
257
|
+
onOpenHook: () => {
|
|
258
|
+
this.transitionState(rec, 'READY');
|
|
259
|
+
this.startContentDetection(rec);
|
|
260
|
+
},
|
|
261
|
+
onFirstOutputHook: options.onFirstOutput ? () => options.onFirstOutput!(id) : undefined,
|
|
262
|
+
onProcessExitHook: options.onProcessExit ? (code: number) => options.onProcessExit!(id, code) : undefined,
|
|
263
|
+
});
|
|
264
|
+
rec.pty = pty;
|
|
265
|
+
|
|
266
|
+
const terminal = vscode.window.createTerminal({
|
|
267
|
+
name: options.name || `Claws ${id}`,
|
|
268
|
+
pty,
|
|
269
|
+
});
|
|
270
|
+
rec.terminal = terminal;
|
|
271
|
+
this.byTerminal.set(terminal, id);
|
|
272
|
+
|
|
273
|
+
if (options.show !== false) terminal.show(options.preserveFocus !== false);
|
|
274
|
+
return { id, terminal, pty };
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
createStandard(options: CreateOptions): { id: string; terminal: vscode.Terminal } {
|
|
278
|
+
const id = String(this.nextId++);
|
|
279
|
+
const terminal = vscode.window.createTerminal({
|
|
280
|
+
name: options.name || `Claws ${id}`,
|
|
281
|
+
cwd: options.cwd,
|
|
282
|
+
shellPath: options.shellPath,
|
|
283
|
+
env: options.env,
|
|
284
|
+
});
|
|
285
|
+
this.byTerminal.set(terminal, id);
|
|
286
|
+
this.records.set(id, {
|
|
287
|
+
id,
|
|
288
|
+
terminal,
|
|
289
|
+
pty: null,
|
|
290
|
+
wrapped: false,
|
|
291
|
+
logPath: null,
|
|
292
|
+
name: terminal.name,
|
|
293
|
+
vehicleState: 'PROVISIONING',
|
|
294
|
+
contentDetectionTimer: null,
|
|
295
|
+
lastForegroundBasename: undefined,
|
|
296
|
+
});
|
|
297
|
+
if (options.show !== false) terminal.show(options.preserveFocus !== false);
|
|
298
|
+
return { id, terminal };
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
close(id: string | number, origin: TerminalCloseOrigin = 'orchestrator'): boolean {
|
|
302
|
+
const key = String(id);
|
|
303
|
+
const rec = this.records.get(key);
|
|
304
|
+
if (!rec) return false;
|
|
305
|
+
this.stopContentDetection(rec);
|
|
306
|
+
this.transitionState(rec, 'CLOSING');
|
|
307
|
+
this.transitionState(rec, 'CLOSED');
|
|
308
|
+
// Invoke callback synchronously BEFORE dispose+map mutation. VS Code fires
|
|
309
|
+
// onDidCloseTerminal asynchronously, so by the time onTerminalClosed runs,
|
|
310
|
+
// byTerminal.delete has already cleared the entry and the function bails at
|
|
311
|
+
// its early-return guard — the callback was never reached. This direct call
|
|
312
|
+
// ensures system.terminal.closed always emits for programmatic closes.
|
|
313
|
+
// See .local/audits/lifecycle-silent-mutation-trace.md.
|
|
314
|
+
this.onTerminalClose?.(key, rec.wrapped, origin);
|
|
315
|
+
try { rec.terminal.dispose(); } catch { /* ignore */ }
|
|
316
|
+
this.byTerminal.delete(rec.terminal);
|
|
317
|
+
this.records.delete(key);
|
|
318
|
+
this.captureStore.clear(key);
|
|
319
|
+
return true;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
onTerminalClosed(terminal: vscode.Terminal): void {
|
|
323
|
+
const id = this.byTerminal.get(terminal);
|
|
324
|
+
if (!id) return;
|
|
325
|
+
this.byTerminal.delete(terminal);
|
|
326
|
+
const rec = this.records.get(id);
|
|
327
|
+
if (rec) {
|
|
328
|
+
this.stopContentDetection(rec);
|
|
329
|
+
this.transitionState(rec, 'CLOSING');
|
|
330
|
+
this.transitionState(rec, 'CLOSED');
|
|
331
|
+
this.onTerminalClose?.(id, rec.wrapped, 'user');
|
|
332
|
+
}
|
|
333
|
+
if (rec?.pty) rec.pty.close();
|
|
334
|
+
this.records.delete(id);
|
|
335
|
+
this.captureStore.clear(id);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
reserveNextId(): string {
|
|
339
|
+
return String(this.nextId++);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
linkProfileTerminal(id: string, terminal: vscode.Terminal, pty: ClawsPty): void {
|
|
343
|
+
this.byTerminal.set(terminal, id);
|
|
344
|
+
this.records.set(id, {
|
|
345
|
+
id,
|
|
346
|
+
terminal,
|
|
347
|
+
pty,
|
|
348
|
+
wrapped: true,
|
|
349
|
+
logPath: null,
|
|
350
|
+
name: terminal.name,
|
|
351
|
+
vehicleState: 'PROVISIONING',
|
|
352
|
+
contentDetectionTimer: null,
|
|
353
|
+
lastForegroundBasename: undefined,
|
|
354
|
+
});
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/**
|
|
358
|
+
* Tear down internal timers and dispose any tracked ClawsPty instances.
|
|
359
|
+
* Call this during extension deactivation.
|
|
360
|
+
*/
|
|
361
|
+
dispose(): void {
|
|
362
|
+
for (const rec of this.records.values()) {
|
|
363
|
+
this.stopContentDetection(rec);
|
|
364
|
+
}
|
|
365
|
+
if (this.unopenedScanTimer) {
|
|
366
|
+
clearInterval(this.unopenedScanTimer);
|
|
367
|
+
this.unopenedScanTimer = null;
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
private startUnopenedScan(): void {
|
|
372
|
+
// setInterval is `unref`ed so it never holds the event loop open on its
|
|
373
|
+
// own — matters for unit-test processes that shouldn't hang on exit.
|
|
374
|
+
const timer = setInterval(() => this.scanUnopenedPtys(), UNOPENED_PTY_SCAN_INTERVAL_MS);
|
|
375
|
+
if (typeof timer.unref === 'function') timer.unref();
|
|
376
|
+
this.unopenedScanTimer = timer;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
private scanUnopenedPtys(): void {
|
|
380
|
+
for (const [id, rec] of this.records) {
|
|
381
|
+
if (!rec.pty) continue;
|
|
382
|
+
if (rec.pty.hasOpened()) continue;
|
|
383
|
+
if (rec.pty.ageMs() < UNOPENED_PTY_TIMEOUT_MS) continue;
|
|
384
|
+
this.logger(
|
|
385
|
+
`[terminal-manager] pty id=${id} never opened after ${rec.pty.ageMs()}ms — disposing orphan`,
|
|
386
|
+
);
|
|
387
|
+
this.stopContentDetection(rec);
|
|
388
|
+
try { rec.pty.close(); } catch { /* ignore */ }
|
|
389
|
+
try { rec.terminal.dispose(); } catch { /* ignore */ }
|
|
390
|
+
this.byTerminal.delete(rec.terminal);
|
|
391
|
+
this.records.delete(id);
|
|
392
|
+
this.captureStore.clear(id);
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
}
|