@kata-sh/cli 0.1.0 → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +156 -0
- package/dist/app-paths.d.ts +4 -0
- package/dist/app-paths.js +6 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +56 -0
- package/dist/loader.d.ts +2 -0
- package/dist/loader.js +95 -0
- package/dist/resource-loader.d.ts +18 -0
- package/dist/resource-loader.js +50 -0
- package/dist/wizard.d.ts +15 -0
- package/dist/wizard.js +159 -0
- package/package.json +50 -21
- package/pkg/dist/modes/interactive/theme/dark.json +85 -0
- package/pkg/dist/modes/interactive/theme/light.json +84 -0
- package/pkg/dist/modes/interactive/theme/theme-schema.json +335 -0
- package/pkg/dist/modes/interactive/theme/theme.d.ts +78 -0
- package/pkg/dist/modes/interactive/theme/theme.d.ts.map +1 -0
- package/pkg/dist/modes/interactive/theme/theme.js +949 -0
- package/pkg/dist/modes/interactive/theme/theme.js.map +1 -0
- package/pkg/package.json +8 -0
- package/scripts/postinstall.js +45 -0
- package/src/resources/AGENTS.md +108 -0
- package/src/resources/KATA-WORKFLOW.md +661 -0
- package/src/resources/agents/researcher.md +29 -0
- package/src/resources/agents/scout.md +56 -0
- package/src/resources/agents/worker.md +31 -0
- package/src/resources/extensions/ask-user-questions.ts +200 -0
- package/src/resources/extensions/bg-shell/index.ts +2758 -0
- package/src/resources/extensions/browser-tools/BROWSER-TOOLS-V2-PROPOSAL.md +1277 -0
- package/src/resources/extensions/browser-tools/core.js +1057 -0
- package/src/resources/extensions/browser-tools/index.ts +4916 -0
- package/src/resources/extensions/browser-tools/package.json +20 -0
- package/src/resources/extensions/context7/index.ts +428 -0
- package/src/resources/extensions/context7/package.json +11 -0
- package/src/resources/extensions/get-secrets-from-user.ts +352 -0
- package/src/resources/extensions/github/formatters.ts +207 -0
- package/src/resources/extensions/github/gh-api.ts +537 -0
- package/src/resources/extensions/github/index.ts +778 -0
- package/src/resources/extensions/kata/activity-log.ts +88 -0
- package/src/resources/extensions/kata/auto.ts +2786 -0
- package/src/resources/extensions/kata/commands.ts +355 -0
- package/src/resources/extensions/kata/crash-recovery.ts +85 -0
- package/src/resources/extensions/kata/dashboard-overlay.ts +516 -0
- package/src/resources/extensions/kata/docs/preferences-reference.md +103 -0
- package/src/resources/extensions/kata/doctor.ts +683 -0
- package/src/resources/extensions/kata/files.ts +730 -0
- package/src/resources/extensions/kata/gitignore.ts +165 -0
- package/src/resources/extensions/kata/guided-flow.ts +976 -0
- package/src/resources/extensions/kata/index.ts +556 -0
- package/src/resources/extensions/kata/metrics.ts +397 -0
- package/src/resources/extensions/kata/observability-validator.ts +408 -0
- package/src/resources/extensions/kata/package.json +11 -0
- package/src/resources/extensions/kata/paths.ts +346 -0
- package/src/resources/extensions/kata/preferences.ts +695 -0
- package/src/resources/extensions/kata/prompt-loader.ts +50 -0
- package/src/resources/extensions/kata/prompts/complete-milestone.md +25 -0
- package/src/resources/extensions/kata/prompts/complete-slice.md +27 -0
- package/src/resources/extensions/kata/prompts/discuss.md +151 -0
- package/src/resources/extensions/kata/prompts/doctor-heal.md +29 -0
- package/src/resources/extensions/kata/prompts/execute-task.md +64 -0
- package/src/resources/extensions/kata/prompts/guided-complete-slice.md +1 -0
- package/src/resources/extensions/kata/prompts/guided-discuss-milestone.md +3 -0
- package/src/resources/extensions/kata/prompts/guided-discuss-slice.md +59 -0
- package/src/resources/extensions/kata/prompts/guided-execute-task.md +1 -0
- package/src/resources/extensions/kata/prompts/guided-plan-milestone.md +23 -0
- package/src/resources/extensions/kata/prompts/guided-plan-slice.md +1 -0
- package/src/resources/extensions/kata/prompts/guided-research-slice.md +11 -0
- package/src/resources/extensions/kata/prompts/guided-resume-task.md +1 -0
- package/src/resources/extensions/kata/prompts/plan-milestone.md +47 -0
- package/src/resources/extensions/kata/prompts/plan-slice.md +63 -0
- package/src/resources/extensions/kata/prompts/queue.md +85 -0
- package/src/resources/extensions/kata/prompts/reassess-roadmap.md +48 -0
- package/src/resources/extensions/kata/prompts/replan-slice.md +39 -0
- package/src/resources/extensions/kata/prompts/research-milestone.md +37 -0
- package/src/resources/extensions/kata/prompts/research-slice.md +28 -0
- package/src/resources/extensions/kata/prompts/run-uat.md +109 -0
- package/src/resources/extensions/kata/prompts/system.md +341 -0
- package/src/resources/extensions/kata/session-forensics.ts +550 -0
- package/src/resources/extensions/kata/skill-discovery.ts +137 -0
- package/src/resources/extensions/kata/state.ts +509 -0
- package/src/resources/extensions/kata/templates/context.md +76 -0
- package/src/resources/extensions/kata/templates/decisions.md +8 -0
- package/src/resources/extensions/kata/templates/milestone-summary.md +73 -0
- package/src/resources/extensions/kata/templates/plan.md +133 -0
- package/src/resources/extensions/kata/templates/preferences.md +15 -0
- package/src/resources/extensions/kata/templates/project.md +31 -0
- package/src/resources/extensions/kata/templates/reassessment.md +28 -0
- package/src/resources/extensions/kata/templates/requirements.md +81 -0
- package/src/resources/extensions/kata/templates/research.md +46 -0
- package/src/resources/extensions/kata/templates/roadmap.md +118 -0
- package/src/resources/extensions/kata/templates/slice-context.md +58 -0
- package/src/resources/extensions/kata/templates/slice-summary.md +99 -0
- package/src/resources/extensions/kata/templates/state.md +19 -0
- package/src/resources/extensions/kata/templates/task-plan.md +52 -0
- package/src/resources/extensions/kata/templates/task-summary.md +57 -0
- package/src/resources/extensions/kata/templates/uat.md +54 -0
- package/src/resources/extensions/kata/tests/activity-log-prune.test.ts +327 -0
- package/src/resources/extensions/kata/tests/auto-preflight.test.ts +97 -0
- package/src/resources/extensions/kata/tests/auto-supervisor.test.mjs +53 -0
- package/src/resources/extensions/kata/tests/complete-milestone.test.ts +317 -0
- package/src/resources/extensions/kata/tests/cost-projection.test.ts +160 -0
- package/src/resources/extensions/kata/tests/derive-state-deps.test.ts +477 -0
- package/src/resources/extensions/kata/tests/derive-state.test.ts +1013 -0
- package/src/resources/extensions/kata/tests/doctor.test.ts +718 -0
- package/src/resources/extensions/kata/tests/idle-recovery.test.ts +490 -0
- package/src/resources/extensions/kata/tests/metrics-io.test.ts +254 -0
- package/src/resources/extensions/kata/tests/metrics.test.ts +217 -0
- package/src/resources/extensions/kata/tests/must-have-parser.test.ts +309 -0
- package/src/resources/extensions/kata/tests/parsers.test.ts +1257 -0
- package/src/resources/extensions/kata/tests/plan-milestone.test.ts +185 -0
- package/src/resources/extensions/kata/tests/plan-quality-validator.test.ts +386 -0
- package/src/resources/extensions/kata/tests/reassess-prompt.test.ts +208 -0
- package/src/resources/extensions/kata/tests/replan-slice.test.ts +686 -0
- package/src/resources/extensions/kata/tests/requirements.test.ts +151 -0
- package/src/resources/extensions/kata/tests/resolve-ts-hooks.mjs +17 -0
- package/src/resources/extensions/kata/tests/resolve-ts.mjs +11 -0
- package/src/resources/extensions/kata/tests/run-uat.test.ts +383 -0
- package/src/resources/extensions/kata/tests/unit-runtime.test.ts +388 -0
- package/src/resources/extensions/kata/tests/workspace-index.test.ts +118 -0
- package/src/resources/extensions/kata/tests/worktree.test.ts +222 -0
- package/src/resources/extensions/kata/types.ts +159 -0
- package/src/resources/extensions/kata/unit-runtime.ts +163 -0
- package/src/resources/extensions/kata/workspace-index.ts +203 -0
- package/src/resources/extensions/kata/worktree.ts +182 -0
- package/src/resources/extensions/mac-tools/index.ts +852 -0
- package/src/resources/extensions/mac-tools/swift-cli/Package.swift +22 -0
- package/src/resources/extensions/mac-tools/swift-cli/Sources/main.swift +1318 -0
- package/src/resources/extensions/search-the-web/cache.ts +78 -0
- package/src/resources/extensions/search-the-web/format.ts +258 -0
- package/src/resources/extensions/search-the-web/http.ts +238 -0
- package/src/resources/extensions/search-the-web/index.ts +68 -0
- package/src/resources/extensions/search-the-web/tool-fetch-page.ts +519 -0
- package/src/resources/extensions/search-the-web/tool-llm-context.ts +404 -0
- package/src/resources/extensions/search-the-web/tool-search.ts +503 -0
- package/src/resources/extensions/search-the-web/url-utils.ts +91 -0
- package/src/resources/extensions/shared/confirm-ui.ts +126 -0
- package/src/resources/extensions/shared/interview-ui.ts +822 -0
- package/src/resources/extensions/shared/next-action-ui.ts +235 -0
- package/src/resources/extensions/shared/progress-widget.ts +282 -0
- package/src/resources/extensions/shared/thinking-widget.ts +107 -0
- package/src/resources/extensions/shared/ui.ts +400 -0
- package/src/resources/extensions/shared/wizard-ui.ts +551 -0
- package/src/resources/extensions/slash-commands/audit.ts +92 -0
- package/src/resources/extensions/slash-commands/create-extension.ts +375 -0
- package/src/resources/extensions/slash-commands/create-slash-command.ts +280 -0
- package/src/resources/extensions/slash-commands/index.ts +12 -0
- package/src/resources/extensions/slash-commands/kata-run.ts +34 -0
- package/src/resources/extensions/subagent/agents.ts +126 -0
- package/src/resources/extensions/subagent/index.ts +1293 -0
- package/src/resources/skills/debug-like-expert/SKILL.md +231 -0
- package/src/resources/skills/debug-like-expert/references/debugging-mindset.md +253 -0
- package/src/resources/skills/debug-like-expert/references/hypothesis-testing.md +373 -0
- package/src/resources/skills/debug-like-expert/references/investigation-techniques.md +337 -0
- package/src/resources/skills/debug-like-expert/references/verification-patterns.md +425 -0
- package/src/resources/skills/debug-like-expert/references/when-to-research.md +361 -0
- package/src/resources/skills/frontend-design/SKILL.md +45 -0
- package/src/resources/skills/swiftui/SKILL.md +208 -0
- package/src/resources/skills/swiftui/references/animations.md +921 -0
- package/src/resources/skills/swiftui/references/architecture.md +1561 -0
- package/src/resources/skills/swiftui/references/layout-system.md +1186 -0
- package/src/resources/skills/swiftui/references/navigation.md +1492 -0
- package/src/resources/skills/swiftui/references/networking-async.md +214 -0
- package/src/resources/skills/swiftui/references/performance.md +1706 -0
- package/src/resources/skills/swiftui/references/platform-integration.md +204 -0
- package/src/resources/skills/swiftui/references/state-management.md +1443 -0
- package/src/resources/skills/swiftui/references/swiftdata.md +297 -0
- package/src/resources/skills/swiftui/references/testing-debugging.md +247 -0
- package/src/resources/skills/swiftui/references/uikit-appkit-interop.md +218 -0
- package/src/resources/skills/swiftui/workflows/add-feature.md +191 -0
- package/src/resources/skills/swiftui/workflows/build-new-app.md +311 -0
- package/src/resources/skills/swiftui/workflows/debug-swiftui.md +192 -0
- package/src/resources/skills/swiftui/workflows/optimize-performance.md +197 -0
- package/src/resources/skills/swiftui/workflows/ship-app.md +203 -0
- package/src/resources/skills/swiftui/workflows/write-tests.md +235 -0
- package/dist/commands/task.d.ts +0 -9
- package/dist/commands/task.d.ts.map +0 -1
- package/dist/commands/task.js +0 -129
- package/dist/commands/task.js.map +0 -1
- package/dist/commands/task.test.d.ts +0 -2
- package/dist/commands/task.test.d.ts.map +0 -1
- package/dist/commands/task.test.js +0 -169
- package/dist/commands/task.test.js.map +0 -1
- package/dist/e2e/task-e2e.test.d.ts +0 -2
- package/dist/e2e/task-e2e.test.d.ts.map +0 -1
- package/dist/e2e/task-e2e.test.js +0 -173
- package/dist/e2e/task-e2e.test.js.map +0 -1
- package/dist/index.d.ts +0 -3
- package/dist/index.d.ts.map +0 -1
- package/dist/index.js +0 -93
- package/dist/index.js.map +0 -1
- package/dist/slug.d.ts +0 -2
- package/dist/slug.d.ts.map +0 -1
- package/dist/slug.js +0 -12
- package/dist/slug.js.map +0 -1
- package/dist/slug.test.d.ts +0 -2
- package/dist/slug.test.d.ts.map +0 -1
- package/dist/slug.test.js +0 -32
- package/dist/slug.test.js.map +0 -1
|
@@ -0,0 +1,2758 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Background Shell Extension v2
|
|
3
|
+
*
|
|
4
|
+
* A next-generation background process manager designed for agentic workflows.
|
|
5
|
+
* Provides intelligent process lifecycle management, structured output digests,
|
|
6
|
+
* event-driven readiness detection, and context-efficient communication.
|
|
7
|
+
*
|
|
8
|
+
* Key capabilities:
|
|
9
|
+
* - Multi-tier output: digest (30 tokens) → highlights → raw (full context)
|
|
10
|
+
* - Readiness detection: port probing, pattern matching, auto-classification
|
|
11
|
+
* - Process lifecycle events: starting → ready → error → exited
|
|
12
|
+
* - Output diffing & dedup: detect novel errors vs. repeated noise
|
|
13
|
+
* - Process groups: manage related processes as a unit
|
|
14
|
+
* - Cross-session persistence: survive context resets
|
|
15
|
+
* - Expect-style interactions: send_and_wait for interactive CLIs
|
|
16
|
+
* - Context injection: proactive alerts for crashes and state changes
|
|
17
|
+
*
|
|
18
|
+
* Tools:
|
|
19
|
+
* bg_shell — start, output, digest, wait_for_ready, send, send_and_wait,
|
|
20
|
+
* signal, list, kill, restart, group_status
|
|
21
|
+
*
|
|
22
|
+
* Commands:
|
|
23
|
+
* /bg — interactive process manager overlay
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { StringEnum } from "@mariozechner/pi-ai";
|
|
27
|
+
import type {
|
|
28
|
+
ExtensionAPI,
|
|
29
|
+
ExtensionContext,
|
|
30
|
+
Theme,
|
|
31
|
+
} from "@mariozechner/pi-coding-agent";
|
|
32
|
+
import {
|
|
33
|
+
truncateHead,
|
|
34
|
+
DEFAULT_MAX_BYTES,
|
|
35
|
+
DEFAULT_MAX_LINES,
|
|
36
|
+
} from "@mariozechner/pi-coding-agent";
|
|
37
|
+
import {
|
|
38
|
+
Text,
|
|
39
|
+
truncateToWidth,
|
|
40
|
+
visibleWidth,
|
|
41
|
+
matchesKey,
|
|
42
|
+
Key,
|
|
43
|
+
} from "@mariozechner/pi-tui";
|
|
44
|
+
import { Type } from "@sinclair/typebox";
|
|
45
|
+
import { spawn, type ChildProcess } from "node:child_process";
|
|
46
|
+
import { createConnection } from "node:net";
|
|
47
|
+
import { randomUUID } from "node:crypto";
|
|
48
|
+
import { writeFileSync, readFileSync, existsSync, mkdirSync } from "node:fs";
|
|
49
|
+
import { join } from "node:path";
|
|
50
|
+
|
|
51
|
+
// ── Types ──────────────────────────────────────────────────────────────────
|
|
52
|
+
|
|
53
|
+
type ProcessStatus =
|
|
54
|
+
| "starting"
|
|
55
|
+
| "ready"
|
|
56
|
+
| "error"
|
|
57
|
+
| "exited"
|
|
58
|
+
| "crashed";
|
|
59
|
+
|
|
60
|
+
type ProcessType = "server" | "build" | "test" | "watcher" | "generic";
|
|
61
|
+
|
|
62
|
+
interface ProcessEvent {
|
|
63
|
+
type:
|
|
64
|
+
| "started"
|
|
65
|
+
| "ready"
|
|
66
|
+
| "error_detected"
|
|
67
|
+
| "recovered"
|
|
68
|
+
| "exited"
|
|
69
|
+
| "crashed"
|
|
70
|
+
| "output"
|
|
71
|
+
| "port_open"
|
|
72
|
+
| "pattern_match";
|
|
73
|
+
timestamp: number;
|
|
74
|
+
detail: string;
|
|
75
|
+
data?: Record<string, unknown>;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
interface OutputDigest {
|
|
79
|
+
status: ProcessStatus;
|
|
80
|
+
uptime: string;
|
|
81
|
+
errors: string[];
|
|
82
|
+
warnings: string[];
|
|
83
|
+
urls: string[];
|
|
84
|
+
ports: number[];
|
|
85
|
+
lastActivity: string;
|
|
86
|
+
outputLines: number;
|
|
87
|
+
changeSummary: string;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
interface OutputLine {
|
|
91
|
+
stream: "stdout" | "stderr";
|
|
92
|
+
line: string;
|
|
93
|
+
ts: number;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
interface BgProcess {
|
|
97
|
+
id: string;
|
|
98
|
+
label: string;
|
|
99
|
+
command: string;
|
|
100
|
+
cwd: string;
|
|
101
|
+
startedAt: number;
|
|
102
|
+
proc: ChildProcess;
|
|
103
|
+
/** Unified chronologically-interleaved output buffer */
|
|
104
|
+
output: OutputLine[];
|
|
105
|
+
exitCode: number | null;
|
|
106
|
+
signal: string | null;
|
|
107
|
+
alive: boolean;
|
|
108
|
+
/** Tracks how many lines in the unified output buffer the LLM has already seen */
|
|
109
|
+
lastReadIndex: number;
|
|
110
|
+
/** Process classification */
|
|
111
|
+
processType: ProcessType;
|
|
112
|
+
/** Current lifecycle status */
|
|
113
|
+
status: ProcessStatus;
|
|
114
|
+
/** Detected ports */
|
|
115
|
+
ports: number[];
|
|
116
|
+
/** Detected URLs */
|
|
117
|
+
urls: string[];
|
|
118
|
+
/** Accumulated errors since last read */
|
|
119
|
+
recentErrors: string[];
|
|
120
|
+
/** Accumulated warnings since last read */
|
|
121
|
+
recentWarnings: string[];
|
|
122
|
+
/** Lifecycle events log */
|
|
123
|
+
events: ProcessEvent[];
|
|
124
|
+
/** Ready pattern (regex string) */
|
|
125
|
+
readyPattern: string | null;
|
|
126
|
+
/** Ready port to probe */
|
|
127
|
+
readyPort: number | null;
|
|
128
|
+
/** Whether readiness was ever achieved */
|
|
129
|
+
wasReady: boolean;
|
|
130
|
+
/** Group membership */
|
|
131
|
+
group: string | null;
|
|
132
|
+
/** Last error count snapshot for diff detection */
|
|
133
|
+
lastErrorCount: number;
|
|
134
|
+
/** Last warning count snapshot for diff detection */
|
|
135
|
+
lastWarningCount: number;
|
|
136
|
+
/** Dedup tracker: hash → count of repeated lines */
|
|
137
|
+
lineDedup: Map<string, number>;
|
|
138
|
+
/** Total raw lines (before dedup) for token savings calc */
|
|
139
|
+
totalRawLines: number;
|
|
140
|
+
/** Env snapshot (keys only, no values for security) */
|
|
141
|
+
envKeys: string[];
|
|
142
|
+
/** Restart count */
|
|
143
|
+
restartCount: number;
|
|
144
|
+
/** Original start config for restart */
|
|
145
|
+
startConfig: { command: string; cwd: string; label: string; processType: ProcessType; readyPattern: string | null; readyPort: number | null; group: string | null };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
interface BgProcessInfo {
|
|
149
|
+
id: string;
|
|
150
|
+
label: string;
|
|
151
|
+
command: string;
|
|
152
|
+
cwd: string;
|
|
153
|
+
startedAt: number;
|
|
154
|
+
alive: boolean;
|
|
155
|
+
exitCode: number | null;
|
|
156
|
+
signal: string | null;
|
|
157
|
+
outputLines: number;
|
|
158
|
+
stdoutLines: number;
|
|
159
|
+
stderrLines: number;
|
|
160
|
+
status: ProcessStatus;
|
|
161
|
+
processType: ProcessType;
|
|
162
|
+
ports: number[];
|
|
163
|
+
urls: string[];
|
|
164
|
+
group: string | null;
|
|
165
|
+
restartCount: number;
|
|
166
|
+
uptime: string;
|
|
167
|
+
recentErrorCount: number;
|
|
168
|
+
recentWarningCount: number;
|
|
169
|
+
eventCount: number;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// ── Constants ──────────────────────────────────────────────────────────────
|
|
173
|
+
|
|
174
|
+
const MAX_BUFFER_LINES = 5000;
|
|
175
|
+
const MAX_EVENTS = 200;
|
|
176
|
+
const DEAD_PROCESS_TTL = 10 * 60 * 1000;
|
|
177
|
+
const PORT_PROBE_TIMEOUT = 500;
|
|
178
|
+
const READY_POLL_INTERVAL = 250;
|
|
179
|
+
const DEFAULT_READY_TIMEOUT = 30000;
|
|
180
|
+
|
|
181
|
+
// ── Pattern Databases ──────────────────────────────────────────────────────
|
|
182
|
+
|
|
183
|
+
/** Patterns that indicate a process is ready/listening */
|
|
184
|
+
const READINESS_PATTERNS: RegExp[] = [
|
|
185
|
+
// Node/JS servers
|
|
186
|
+
/listening\s+on\s+(?:port\s+)?(\d+)/i,
|
|
187
|
+
/server\s+(?:is\s+)?(?:running|started|listening)\s+(?:at|on)\s+/i,
|
|
188
|
+
/ready\s+(?:in|on|at)\s+/i,
|
|
189
|
+
/started\s+(?:server\s+)?on\s+/i,
|
|
190
|
+
// Next.js / Vite / etc
|
|
191
|
+
/Local:\s*https?:\/\//i,
|
|
192
|
+
/➜\s+Local:\s*/i,
|
|
193
|
+
/compiled\s+(?:successfully|client\s+and\s+server)/i,
|
|
194
|
+
// Python
|
|
195
|
+
/running\s+on\s+https?:\/\//i,
|
|
196
|
+
/Uvicorn\s+running/i,
|
|
197
|
+
/Development\s+server\s+is\s+running/i,
|
|
198
|
+
// Generic
|
|
199
|
+
/press\s+ctrl[\-+]c\s+to\s+(?:quit|stop)/i,
|
|
200
|
+
/watching\s+for\s+(?:file\s+)?changes/i,
|
|
201
|
+
/build\s+(?:completed|succeeded|finished)/i,
|
|
202
|
+
];
|
|
203
|
+
|
|
204
|
+
/** Patterns that indicate errors */
|
|
205
|
+
const ERROR_PATTERNS: RegExp[] = [
|
|
206
|
+
/\berror\b[\s:[\](]/i,
|
|
207
|
+
/\bERROR\b/,
|
|
208
|
+
/\bfailed\b/i,
|
|
209
|
+
/\bFAILED\b/,
|
|
210
|
+
/\bfatal\b/i,
|
|
211
|
+
/\bFATAL\b/,
|
|
212
|
+
/\bexception\b/i,
|
|
213
|
+
/\bpanic\b/i,
|
|
214
|
+
/\bsegmentation\s+fault\b/i,
|
|
215
|
+
/\bsyntax\s*error\b/i,
|
|
216
|
+
/\btype\s*error\b/i,
|
|
217
|
+
/\breference\s*error\b/i,
|
|
218
|
+
/Cannot\s+find\s+module/i,
|
|
219
|
+
/Module\s+not\s+found/i,
|
|
220
|
+
/ENOENT/,
|
|
221
|
+
/EACCES/,
|
|
222
|
+
/EADDRINUSE/,
|
|
223
|
+
/TS\d{4,5}:/, // TypeScript errors
|
|
224
|
+
/E\d{4,5}:/, // Rust errors
|
|
225
|
+
/\[ERROR\]/,
|
|
226
|
+
/✖|✗|❌/, // Common error symbols
|
|
227
|
+
];
|
|
228
|
+
|
|
229
|
+
/** Patterns that indicate warnings */
|
|
230
|
+
const WARNING_PATTERNS: RegExp[] = [
|
|
231
|
+
/\bwarning\b[\s:[\](]/i,
|
|
232
|
+
/\bWARN(?:ING)?\b/,
|
|
233
|
+
/\bdeprecated\b/i,
|
|
234
|
+
/\bDEPRECATED\b/,
|
|
235
|
+
/⚠️?/,
|
|
236
|
+
/\[WARN\]/,
|
|
237
|
+
];
|
|
238
|
+
|
|
239
|
+
/** Patterns to extract URLs */
|
|
240
|
+
const URL_PATTERN = /https?:\/\/[^\s"'<>)\]]+/gi;
|
|
241
|
+
|
|
242
|
+
/** Patterns to extract port numbers from "listening" messages */
|
|
243
|
+
const PORT_PATTERN = /(?:port|listening\s+on|:)\s*(\d{2,5})\b/gi;
|
|
244
|
+
|
|
245
|
+
/** Patterns indicating test results */
|
|
246
|
+
const TEST_RESULT_PATTERNS: RegExp[] = [
|
|
247
|
+
/(\d+)\s+(?:tests?\s+)?passed/i,
|
|
248
|
+
/(\d+)\s+(?:tests?\s+)?failed/i,
|
|
249
|
+
/Tests?:\s+(\d+)\s+passed/i,
|
|
250
|
+
/(\d+)\s+passing/i,
|
|
251
|
+
/(\d+)\s+failing/i,
|
|
252
|
+
/PASS|FAIL/,
|
|
253
|
+
];
|
|
254
|
+
|
|
255
|
+
/** Patterns indicating build completion */
|
|
256
|
+
const BUILD_COMPLETE_PATTERNS: RegExp[] = [
|
|
257
|
+
/build\s+(?:completed|succeeded|finished|done)/i,
|
|
258
|
+
/compiled\s+(?:successfully|with\s+\d+\s+(?:error|warning))/i,
|
|
259
|
+
/✓\s+Built/i,
|
|
260
|
+
/webpack\s+\d+\.\d+/i,
|
|
261
|
+
/bundle\s+(?:is\s+)?ready/i,
|
|
262
|
+
];
|
|
263
|
+
|
|
264
|
+
// ── Process Registry ───────────────────────────────────────────────────────
|
|
265
|
+
|
|
266
|
+
const processes = new Map<string, BgProcess>();
|
|
267
|
+
|
|
268
|
+
/** Pending alerts to inject into the next agent context */
|
|
269
|
+
let pendingAlerts: string[] = [];
|
|
270
|
+
|
|
271
|
+
function addOutputLine(bg: BgProcess, stream: "stdout" | "stderr", line: string): void {
|
|
272
|
+
bg.output.push({ stream, line, ts: Date.now() });
|
|
273
|
+
if (bg.output.length > MAX_BUFFER_LINES) {
|
|
274
|
+
const excess = bg.output.length - MAX_BUFFER_LINES;
|
|
275
|
+
bg.output.splice(0, excess);
|
|
276
|
+
// Adjust the read cursor so incremental delivery stays correct
|
|
277
|
+
bg.lastReadIndex = Math.max(0, bg.lastReadIndex - excess);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function addEvent(bg: BgProcess, event: Omit<ProcessEvent, "timestamp">): void {
|
|
282
|
+
const ev: ProcessEvent = { ...event, timestamp: Date.now() };
|
|
283
|
+
bg.events.push(ev);
|
|
284
|
+
if (bg.events.length > MAX_EVENTS) {
|
|
285
|
+
bg.events.splice(0, bg.events.length - MAX_EVENTS);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function getInfo(p: BgProcess): BgProcessInfo {
|
|
290
|
+
const stdoutLines = p.output.filter(l => l.stream === "stdout").length;
|
|
291
|
+
const stderrLines = p.output.filter(l => l.stream === "stderr").length;
|
|
292
|
+
return {
|
|
293
|
+
id: p.id,
|
|
294
|
+
label: p.label,
|
|
295
|
+
command: p.command,
|
|
296
|
+
cwd: p.cwd,
|
|
297
|
+
startedAt: p.startedAt,
|
|
298
|
+
alive: p.alive,
|
|
299
|
+
exitCode: p.exitCode,
|
|
300
|
+
signal: p.signal,
|
|
301
|
+
outputLines: p.output.length,
|
|
302
|
+
stdoutLines,
|
|
303
|
+
stderrLines,
|
|
304
|
+
status: p.status,
|
|
305
|
+
processType: p.processType,
|
|
306
|
+
ports: p.ports,
|
|
307
|
+
urls: p.urls,
|
|
308
|
+
group: p.group,
|
|
309
|
+
restartCount: p.restartCount,
|
|
310
|
+
uptime: formatUptime(Date.now() - p.startedAt),
|
|
311
|
+
recentErrorCount: p.recentErrors.length,
|
|
312
|
+
recentWarningCount: p.recentWarnings.length,
|
|
313
|
+
eventCount: p.events.length,
|
|
314
|
+
};
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// ── Process Type Detection ─────────────────────────────────────────────────
|
|
318
|
+
|
|
319
|
+
function detectProcessType(command: string): ProcessType {
|
|
320
|
+
const cmd = command.toLowerCase();
|
|
321
|
+
|
|
322
|
+
// Server patterns
|
|
323
|
+
if (
|
|
324
|
+
/\b(serve|server|dev|start)\b/.test(cmd) &&
|
|
325
|
+
/\b(npm|yarn|pnpm|bun|node|next|vite|nuxt|astro|remix|gatsby|uvicorn|flask|django|rails|cargo)\b/.test(cmd)
|
|
326
|
+
) return "server";
|
|
327
|
+
if (/\b(uvicorn|gunicorn|flask\s+run|manage\.py\s+runserver|rails\s+s)\b/.test(cmd)) return "server";
|
|
328
|
+
if (/\b(http-server|live-server|serve)\b/.test(cmd)) return "server";
|
|
329
|
+
|
|
330
|
+
// Build patterns
|
|
331
|
+
if (/\b(build|compile|make|tsc|webpack|rollup|esbuild|swc)\b/.test(cmd)) {
|
|
332
|
+
if (/\b(watch|--watch|-w)\b/.test(cmd)) return "watcher";
|
|
333
|
+
return "build";
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// Test patterns
|
|
337
|
+
if (/\b(test|jest|vitest|mocha|pytest|cargo\s+test|go\s+test|rspec)\b/.test(cmd)) return "test";
|
|
338
|
+
|
|
339
|
+
// Watcher patterns
|
|
340
|
+
if (/\b(watch|nodemon|chokidar|fswatch|inotifywait)\b/.test(cmd)) return "watcher";
|
|
341
|
+
|
|
342
|
+
return "generic";
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// ── Output Analysis ────────────────────────────────────────────────────────
|
|
346
|
+
|
|
347
|
+
function analyzeLine(bg: BgProcess, line: string, stream: "stdout" | "stderr"): void {
|
|
348
|
+
// Error detection
|
|
349
|
+
if (ERROR_PATTERNS.some(p => p.test(line))) {
|
|
350
|
+
bg.recentErrors.push(line.trim().slice(0, 200)); // Cap line length
|
|
351
|
+
if (bg.recentErrors.length > 50) bg.recentErrors.splice(0, bg.recentErrors.length - 50);
|
|
352
|
+
|
|
353
|
+
if (bg.status === "ready") {
|
|
354
|
+
bg.status = "error";
|
|
355
|
+
addEvent(bg, {
|
|
356
|
+
type: "error_detected",
|
|
357
|
+
detail: line.trim().slice(0, 200),
|
|
358
|
+
data: { errorCount: bg.recentErrors.length },
|
|
359
|
+
});
|
|
360
|
+
pushAlert(bg, `error_detected: ${line.trim().slice(0, 120)}`);
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// Warning detection
|
|
365
|
+
if (WARNING_PATTERNS.some(p => p.test(line))) {
|
|
366
|
+
bg.recentWarnings.push(line.trim().slice(0, 200));
|
|
367
|
+
if (bg.recentWarnings.length > 50) bg.recentWarnings.splice(0, bg.recentWarnings.length - 50);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// URL extraction
|
|
371
|
+
const urlMatches = line.match(URL_PATTERN);
|
|
372
|
+
if (urlMatches) {
|
|
373
|
+
for (const url of urlMatches) {
|
|
374
|
+
if (!bg.urls.includes(url)) {
|
|
375
|
+
bg.urls.push(url);
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// Port extraction
|
|
381
|
+
let portMatch: RegExpExecArray | null;
|
|
382
|
+
const portRe = new RegExp(PORT_PATTERN.source, PORT_PATTERN.flags);
|
|
383
|
+
while ((portMatch = portRe.exec(line)) !== null) {
|
|
384
|
+
const port = parseInt(portMatch[1], 10);
|
|
385
|
+
if (port > 0 && port <= 65535 && !bg.ports.includes(port)) {
|
|
386
|
+
bg.ports.push(port);
|
|
387
|
+
addEvent(bg, {
|
|
388
|
+
type: "port_open",
|
|
389
|
+
detail: `Port ${port} detected`,
|
|
390
|
+
data: { port },
|
|
391
|
+
});
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// Readiness detection
|
|
396
|
+
if (bg.status === "starting") {
|
|
397
|
+
// Check custom ready pattern first
|
|
398
|
+
if (bg.readyPattern) {
|
|
399
|
+
try {
|
|
400
|
+
if (new RegExp(bg.readyPattern, "i").test(line)) {
|
|
401
|
+
transitionToReady(bg, `Custom pattern matched: ${line.trim().slice(0, 100)}`);
|
|
402
|
+
}
|
|
403
|
+
} catch { /* invalid regex, skip */ }
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
// Check built-in readiness patterns
|
|
407
|
+
if (bg.status === "starting" && READINESS_PATTERNS.some(p => p.test(line))) {
|
|
408
|
+
transitionToReady(bg, `Readiness pattern matched: ${line.trim().slice(0, 100)}`);
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
// Recovery detection: if we were in error and see a success pattern
|
|
413
|
+
if (bg.status === "error") {
|
|
414
|
+
if (READINESS_PATTERNS.some(p => p.test(line)) || BUILD_COMPLETE_PATTERNS.some(p => p.test(line))) {
|
|
415
|
+
bg.status = "ready";
|
|
416
|
+
bg.recentErrors = [];
|
|
417
|
+
addEvent(bg, { type: "recovered", detail: "Process recovered from error state" });
|
|
418
|
+
pushAlert(bg, "recovered — errors cleared");
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
// Dedup tracking
|
|
423
|
+
bg.totalRawLines++;
|
|
424
|
+
const lineHash = line.trim().slice(0, 100);
|
|
425
|
+
bg.lineDedup.set(lineHash, (bg.lineDedup.get(lineHash) || 0) + 1);
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
function transitionToReady(bg: BgProcess, detail: string): void {
|
|
429
|
+
bg.status = "ready";
|
|
430
|
+
bg.wasReady = true;
|
|
431
|
+
addEvent(bg, { type: "ready", detail });
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
function pushAlert(bg: BgProcess, message: string): void {
|
|
435
|
+
pendingAlerts.push(`[bg:${bg.id} ${bg.label}] ${message}`);
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
// ── Port Probing ───────────────────────────────────────────────────────────
|
|
439
|
+
|
|
440
|
+
function probePort(port: number, host: string = "127.0.0.1"): Promise<boolean> {
|
|
441
|
+
return new Promise((resolve) => {
|
|
442
|
+
const socket = createConnection({ port, host, timeout: PORT_PROBE_TIMEOUT }, () => {
|
|
443
|
+
socket.destroy();
|
|
444
|
+
resolve(true);
|
|
445
|
+
});
|
|
446
|
+
socket.on("error", () => {
|
|
447
|
+
socket.destroy();
|
|
448
|
+
resolve(false);
|
|
449
|
+
});
|
|
450
|
+
socket.on("timeout", () => {
|
|
451
|
+
socket.destroy();
|
|
452
|
+
resolve(false);
|
|
453
|
+
});
|
|
454
|
+
});
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
// ── Digest Generation ──────────────────────────────────────────────────────
|
|
458
|
+
|
|
459
|
+
function generateDigest(bg: BgProcess, mutate: boolean = false): OutputDigest {
|
|
460
|
+
// Change summary: what's different since last read
|
|
461
|
+
const newErrors = bg.recentErrors.length - bg.lastErrorCount;
|
|
462
|
+
const newWarnings = bg.recentWarnings.length - bg.lastWarningCount;
|
|
463
|
+
const newLines = bg.output.length - bg.lastReadIndex;
|
|
464
|
+
|
|
465
|
+
let changeSummary: string;
|
|
466
|
+
if (newLines === 0) {
|
|
467
|
+
changeSummary = "no new output";
|
|
468
|
+
} else {
|
|
469
|
+
const parts: string[] = [];
|
|
470
|
+
parts.push(`${newLines} new lines`);
|
|
471
|
+
if (newErrors > 0) parts.push(`${newErrors} new errors`);
|
|
472
|
+
if (newWarnings > 0) parts.push(`${newWarnings} new warnings`);
|
|
473
|
+
changeSummary = parts.join(", ");
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
// Only mutate snapshot counters when explicitly requested (e.g. from tool calls)
|
|
477
|
+
if (mutate) {
|
|
478
|
+
bg.lastErrorCount = bg.recentErrors.length;
|
|
479
|
+
bg.lastWarningCount = bg.recentWarnings.length;
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
return {
|
|
483
|
+
status: bg.status,
|
|
484
|
+
uptime: formatUptime(Date.now() - bg.startedAt),
|
|
485
|
+
errors: bg.recentErrors.slice(-5), // Last 5 errors
|
|
486
|
+
warnings: bg.recentWarnings.slice(-3), // Last 3 warnings
|
|
487
|
+
urls: bg.urls,
|
|
488
|
+
ports: bg.ports,
|
|
489
|
+
lastActivity: bg.events.length > 0
|
|
490
|
+
? formatTimeAgo(bg.events[bg.events.length - 1].timestamp)
|
|
491
|
+
: "none",
|
|
492
|
+
outputLines: bg.output.length,
|
|
493
|
+
changeSummary,
|
|
494
|
+
};
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
// ── Highlight Extraction ───────────────────────────────────────────────────
|
|
498
|
+
|
|
499
|
+
function getHighlights(bg: BgProcess, maxLines: number = 15): string[] {
|
|
500
|
+
const lines: string[] = [];
|
|
501
|
+
|
|
502
|
+
// Collect significant lines
|
|
503
|
+
const significant: { line: string; score: number; idx: number }[] = [];
|
|
504
|
+
for (let i = 0; i < bg.output.length; i++) {
|
|
505
|
+
const entry = bg.output[i];
|
|
506
|
+
let score = 0;
|
|
507
|
+
if (ERROR_PATTERNS.some(p => p.test(entry.line))) score += 10;
|
|
508
|
+
if (WARNING_PATTERNS.some(p => p.test(entry.line))) score += 5;
|
|
509
|
+
if (URL_PATTERN.test(entry.line)) score += 3;
|
|
510
|
+
if (READINESS_PATTERNS.some(p => p.test(entry.line))) score += 8;
|
|
511
|
+
if (TEST_RESULT_PATTERNS.some(p => p.test(entry.line))) score += 7;
|
|
512
|
+
if (BUILD_COMPLETE_PATTERNS.some(p => p.test(entry.line))) score += 6;
|
|
513
|
+
// Boost recent lines so highlights favor fresh output over stale
|
|
514
|
+
if (i >= bg.output.length - 50) score += 2;
|
|
515
|
+
if (score > 0) {
|
|
516
|
+
significant.push({ line: entry.line.trim().slice(0, 300), score, idx: i });
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
// Sort by significance (tie-break by recency)
|
|
521
|
+
significant.sort((a, b) => b.score - a.score || b.idx - a.idx);
|
|
522
|
+
const top = significant.slice(0, maxLines);
|
|
523
|
+
|
|
524
|
+
if (top.length === 0) {
|
|
525
|
+
// If nothing significant, show last few lines
|
|
526
|
+
const tail = bg.output.slice(-5);
|
|
527
|
+
for (const l of tail) lines.push(l.line.trim().slice(0, 300));
|
|
528
|
+
} else {
|
|
529
|
+
for (const entry of top) lines.push(entry.line);
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
return lines;
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
// ── Process Start ──────────────────────────────────────────────────────────
|
|
536
|
+
|
|
537
|
+
interface StartOptions {
|
|
538
|
+
command: string;
|
|
539
|
+
cwd: string;
|
|
540
|
+
label?: string;
|
|
541
|
+
type?: ProcessType;
|
|
542
|
+
readyPattern?: string;
|
|
543
|
+
readyPort?: number;
|
|
544
|
+
group?: string;
|
|
545
|
+
env?: Record<string, string>;
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
function startProcess(opts: StartOptions): BgProcess {
|
|
549
|
+
const id = randomUUID().slice(0, 8);
|
|
550
|
+
const processType = opts.type || detectProcessType(opts.command);
|
|
551
|
+
|
|
552
|
+
const env = { ...process.env, ...(opts.env || {}) };
|
|
553
|
+
|
|
554
|
+
const proc = spawn("bash", ["-c", opts.command], {
|
|
555
|
+
cwd: opts.cwd,
|
|
556
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
557
|
+
env,
|
|
558
|
+
detached: true,
|
|
559
|
+
});
|
|
560
|
+
|
|
561
|
+
const bg: BgProcess = {
|
|
562
|
+
id,
|
|
563
|
+
label: opts.label || opts.command.slice(0, 60),
|
|
564
|
+
command: opts.command,
|
|
565
|
+
cwd: opts.cwd,
|
|
566
|
+
startedAt: Date.now(),
|
|
567
|
+
proc,
|
|
568
|
+
output: [],
|
|
569
|
+
exitCode: null,
|
|
570
|
+
signal: null,
|
|
571
|
+
alive: true,
|
|
572
|
+
lastReadIndex: 0,
|
|
573
|
+
processType,
|
|
574
|
+
status: "starting",
|
|
575
|
+
ports: [],
|
|
576
|
+
urls: [],
|
|
577
|
+
recentErrors: [],
|
|
578
|
+
recentWarnings: [],
|
|
579
|
+
events: [],
|
|
580
|
+
readyPattern: opts.readyPattern || null,
|
|
581
|
+
readyPort: opts.readyPort || null,
|
|
582
|
+
wasReady: false,
|
|
583
|
+
group: opts.group || null,
|
|
584
|
+
lastErrorCount: 0,
|
|
585
|
+
lastWarningCount: 0,
|
|
586
|
+
lineDedup: new Map(),
|
|
587
|
+
totalRawLines: 0,
|
|
588
|
+
envKeys: Object.keys(opts.env || {}),
|
|
589
|
+
restartCount: 0,
|
|
590
|
+
startConfig: {
|
|
591
|
+
command: opts.command,
|
|
592
|
+
cwd: opts.cwd,
|
|
593
|
+
label: opts.label || opts.command.slice(0, 60),
|
|
594
|
+
processType,
|
|
595
|
+
readyPattern: opts.readyPattern || null,
|
|
596
|
+
readyPort: opts.readyPort || null,
|
|
597
|
+
group: opts.group || null,
|
|
598
|
+
},
|
|
599
|
+
};
|
|
600
|
+
|
|
601
|
+
addEvent(bg, { type: "started", detail: `Process started: ${opts.command.slice(0, 100)}` });
|
|
602
|
+
|
|
603
|
+
proc.stdout?.on("data", (chunk: Buffer) => {
|
|
604
|
+
const lines = chunk.toString().split("\n");
|
|
605
|
+
for (const line of lines) {
|
|
606
|
+
if (line.length > 0) {
|
|
607
|
+
addOutputLine(bg, "stdout", line);
|
|
608
|
+
analyzeLine(bg, line, "stdout");
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
});
|
|
612
|
+
|
|
613
|
+
proc.stderr?.on("data", (chunk: Buffer) => {
|
|
614
|
+
const lines = chunk.toString().split("\n");
|
|
615
|
+
for (const line of lines) {
|
|
616
|
+
if (line.length > 0) {
|
|
617
|
+
addOutputLine(bg, "stderr", line);
|
|
618
|
+
analyzeLine(bg, line, "stderr");
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
});
|
|
622
|
+
|
|
623
|
+
proc.on("exit", (code, sig) => {
|
|
624
|
+
bg.alive = false;
|
|
625
|
+
bg.exitCode = code;
|
|
626
|
+
bg.signal = sig ?? null;
|
|
627
|
+
|
|
628
|
+
if (code === 0) {
|
|
629
|
+
bg.status = "exited";
|
|
630
|
+
addEvent(bg, { type: "exited", detail: `Exited cleanly (code 0)` });
|
|
631
|
+
} else {
|
|
632
|
+
bg.status = "crashed";
|
|
633
|
+
const lastErrors = bg.recentErrors.slice(-3).join("; ");
|
|
634
|
+
const detail = `Crashed with code ${code}${sig ? ` (signal ${sig})` : ""}${lastErrors ? ` — ${lastErrors}` : ""}`;
|
|
635
|
+
addEvent(bg, {
|
|
636
|
+
type: "crashed",
|
|
637
|
+
detail,
|
|
638
|
+
data: { exitCode: code, signal: sig, lastErrors: bg.recentErrors.slice(-5) },
|
|
639
|
+
});
|
|
640
|
+
pushAlert(bg, `CRASHED (code ${code})${lastErrors ? `: ${lastErrors.slice(0, 120)}` : ""}`);
|
|
641
|
+
}
|
|
642
|
+
});
|
|
643
|
+
|
|
644
|
+
proc.on("error", (err) => {
|
|
645
|
+
bg.alive = false;
|
|
646
|
+
bg.status = "crashed";
|
|
647
|
+
addOutputLine(bg, "stderr", `[spawn error] ${err.message}`);
|
|
648
|
+
addEvent(bg, { type: "crashed", detail: `Spawn error: ${err.message}` });
|
|
649
|
+
pushAlert(bg, `spawn error: ${err.message}`);
|
|
650
|
+
});
|
|
651
|
+
|
|
652
|
+
// Port probing for server-type processes
|
|
653
|
+
if (bg.readyPort) {
|
|
654
|
+
startPortProbing(bg, bg.readyPort);
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
processes.set(id, bg);
|
|
658
|
+
return bg;
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
// ── Port Probing Loop ──────────────────────────────────────────────────────
|
|
662
|
+
|
|
663
|
+
function startPortProbing(bg: BgProcess, port: number): void {
|
|
664
|
+
const interval = setInterval(async () => {
|
|
665
|
+
if (!bg.alive || bg.status !== "starting") {
|
|
666
|
+
clearInterval(interval);
|
|
667
|
+
return;
|
|
668
|
+
}
|
|
669
|
+
const open = await probePort(port);
|
|
670
|
+
if (open) {
|
|
671
|
+
clearInterval(interval);
|
|
672
|
+
if (!bg.ports.includes(port)) bg.ports.push(port);
|
|
673
|
+
transitionToReady(bg, `Port ${port} is open`);
|
|
674
|
+
addEvent(bg, { type: "port_open", detail: `Port ${port} is open`, data: { port } });
|
|
675
|
+
}
|
|
676
|
+
}, READY_POLL_INTERVAL);
|
|
677
|
+
|
|
678
|
+
// Stop probing after timeout
|
|
679
|
+
setTimeout(() => clearInterval(interval), DEFAULT_READY_TIMEOUT);
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
// ── Process Kill ───────────────────────────────────────────────────────────
|
|
683
|
+
|
|
684
|
+
function killProcess(id: string, sig: NodeJS.Signals = "SIGTERM"): boolean {
|
|
685
|
+
const bg = processes.get(id);
|
|
686
|
+
if (!bg) return false;
|
|
687
|
+
if (!bg.alive) return true;
|
|
688
|
+
try {
|
|
689
|
+
if (bg.proc.pid) {
|
|
690
|
+
try {
|
|
691
|
+
process.kill(-bg.proc.pid, sig);
|
|
692
|
+
} catch {
|
|
693
|
+
bg.proc.kill(sig);
|
|
694
|
+
}
|
|
695
|
+
} else {
|
|
696
|
+
bg.proc.kill(sig);
|
|
697
|
+
}
|
|
698
|
+
return true;
|
|
699
|
+
} catch {
|
|
700
|
+
return false;
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
// ── Process Restart ────────────────────────────────────────────────────────
|
|
705
|
+
|
|
706
|
+
async function restartProcess(id: string): Promise<BgProcess | null> {
|
|
707
|
+
const old = processes.get(id);
|
|
708
|
+
if (!old) return null;
|
|
709
|
+
|
|
710
|
+
const config = old.startConfig;
|
|
711
|
+
const restartCount = old.restartCount + 1;
|
|
712
|
+
|
|
713
|
+
// Kill old process
|
|
714
|
+
if (old.alive) {
|
|
715
|
+
killProcess(id, "SIGTERM");
|
|
716
|
+
await new Promise(r => setTimeout(r, 300));
|
|
717
|
+
if (old.alive) {
|
|
718
|
+
killProcess(id, "SIGKILL");
|
|
719
|
+
await new Promise(r => setTimeout(r, 200));
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
processes.delete(id);
|
|
723
|
+
|
|
724
|
+
// Start new one
|
|
725
|
+
const newBg = startProcess({
|
|
726
|
+
command: config.command,
|
|
727
|
+
cwd: config.cwd,
|
|
728
|
+
label: config.label,
|
|
729
|
+
type: config.processType,
|
|
730
|
+
readyPattern: config.readyPattern || undefined,
|
|
731
|
+
readyPort: config.readyPort || undefined,
|
|
732
|
+
group: config.group || undefined,
|
|
733
|
+
});
|
|
734
|
+
newBg.restartCount = restartCount;
|
|
735
|
+
|
|
736
|
+
return newBg;
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
// ── Output Retrieval (multi-tier) ──────────────────────────────────────────
|
|
740
|
+
|
|
741
|
+
interface GetOutputOptions {
|
|
742
|
+
stream: "stdout" | "stderr" | "both";
|
|
743
|
+
tail?: number;
|
|
744
|
+
filter?: string;
|
|
745
|
+
incremental?: boolean;
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
function getOutput(bg: BgProcess, opts: GetOutputOptions): string {
|
|
749
|
+
const { stream, tail, filter, incremental } = opts;
|
|
750
|
+
|
|
751
|
+
// Get the relevant slice of the unified buffer (already in chronological order)
|
|
752
|
+
let entries: OutputLine[];
|
|
753
|
+
if (incremental) {
|
|
754
|
+
entries = bg.output.slice(bg.lastReadIndex);
|
|
755
|
+
bg.lastReadIndex = bg.output.length;
|
|
756
|
+
} else {
|
|
757
|
+
entries = [...bg.output];
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
// Filter by stream if requested
|
|
761
|
+
if (stream !== "both") {
|
|
762
|
+
entries = entries.filter(e => e.stream === stream);
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
// Apply regex filter
|
|
766
|
+
if (filter) {
|
|
767
|
+
try {
|
|
768
|
+
const re = new RegExp(filter, "i");
|
|
769
|
+
entries = entries.filter(e => re.test(e.line));
|
|
770
|
+
} catch { /* invalid regex */ }
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
// Tail
|
|
774
|
+
if (tail && tail > 0 && entries.length > tail) {
|
|
775
|
+
entries = entries.slice(-tail);
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
const lines = entries.map(e => e.line);
|
|
779
|
+
const raw = lines.join("\n");
|
|
780
|
+
const truncation = truncateHead(raw, {
|
|
781
|
+
maxLines: DEFAULT_MAX_LINES,
|
|
782
|
+
maxBytes: DEFAULT_MAX_BYTES,
|
|
783
|
+
});
|
|
784
|
+
|
|
785
|
+
let result = truncation.content;
|
|
786
|
+
if (truncation.truncated) {
|
|
787
|
+
result += `\n\n[Output truncated: showing ${truncation.outputLines}/${truncation.totalLines} lines]`;
|
|
788
|
+
}
|
|
789
|
+
return result;
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
// ── Wait for Ready ─────────────────────────────────────────────────────────
|
|
793
|
+
|
|
794
|
+
async function waitForReady(bg: BgProcess, timeout: number, signal?: AbortSignal): Promise<{ ready: boolean; detail: string }> {
|
|
795
|
+
const start = Date.now();
|
|
796
|
+
|
|
797
|
+
while (Date.now() - start < timeout) {
|
|
798
|
+
if (signal?.aborted) {
|
|
799
|
+
return { ready: false, detail: "Cancelled" };
|
|
800
|
+
}
|
|
801
|
+
if (!bg.alive) {
|
|
802
|
+
return {
|
|
803
|
+
ready: false,
|
|
804
|
+
detail: `Process exited before becoming ready (code ${bg.exitCode})${bg.recentErrors.length > 0 ? ` — ${bg.recentErrors.slice(-1)[0]}` : ""}`,
|
|
805
|
+
};
|
|
806
|
+
}
|
|
807
|
+
if (bg.status === "ready") {
|
|
808
|
+
return {
|
|
809
|
+
ready: true,
|
|
810
|
+
detail: bg.events.find(e => e.type === "ready")?.detail || "Process is ready",
|
|
811
|
+
};
|
|
812
|
+
}
|
|
813
|
+
await new Promise(r => setTimeout(r, READY_POLL_INTERVAL));
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
// Timeout — try port probe as last resort
|
|
817
|
+
if (bg.readyPort) {
|
|
818
|
+
const open = await probePort(bg.readyPort);
|
|
819
|
+
if (open) {
|
|
820
|
+
transitionToReady(bg, `Port ${bg.readyPort} is open (detected at timeout)`);
|
|
821
|
+
return { ready: true, detail: `Port ${bg.readyPort} is open` };
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
return { ready: false, detail: `Timed out after ${timeout}ms waiting for ready signal` };
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
// ── Send and Wait ──────────────────────────────────────────────────────────
|
|
829
|
+
|
|
830
|
+
async function sendAndWait(
|
|
831
|
+
bg: BgProcess,
|
|
832
|
+
input: string,
|
|
833
|
+
waitPattern: string,
|
|
834
|
+
timeout: number,
|
|
835
|
+
signal?: AbortSignal,
|
|
836
|
+
): Promise<{ matched: boolean; output: string }> {
|
|
837
|
+
// Snapshot the current position in the unified buffer before sending
|
|
838
|
+
const startIndex = bg.output.length;
|
|
839
|
+
bg.proc.stdin?.write(input + "\n");
|
|
840
|
+
|
|
841
|
+
let re: RegExp;
|
|
842
|
+
try {
|
|
843
|
+
re = new RegExp(waitPattern, "i");
|
|
844
|
+
} catch {
|
|
845
|
+
return { matched: false, output: "Invalid wait pattern regex" };
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
const start = Date.now();
|
|
849
|
+
while (Date.now() - start < timeout) {
|
|
850
|
+
if (signal?.aborted) {
|
|
851
|
+
const newEntries = bg.output.slice(startIndex);
|
|
852
|
+
return { matched: false, output: newEntries.map(e => e.line).join("\n") || "(cancelled)" };
|
|
853
|
+
}
|
|
854
|
+
const newEntries = bg.output.slice(startIndex);
|
|
855
|
+
for (const entry of newEntries) {
|
|
856
|
+
if (re.test(entry.line)) {
|
|
857
|
+
return { matched: true, output: newEntries.map(e => e.line).join("\n") };
|
|
858
|
+
}
|
|
859
|
+
}
|
|
860
|
+
await new Promise(r => setTimeout(r, 100));
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
const newEntries = bg.output.slice(startIndex);
|
|
864
|
+
return { matched: false, output: newEntries.map(e => e.line).join("\n") || "(no output)" };
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
// ── Group Operations ───────────────────────────────────────────────────────
|
|
868
|
+
|
|
869
|
+
function getGroupProcesses(group: string): BgProcess[] {
|
|
870
|
+
return Array.from(processes.values()).filter(p => p.group === group);
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
function getGroupStatus(group: string): {
|
|
874
|
+
group: string;
|
|
875
|
+
healthy: boolean;
|
|
876
|
+
processes: { id: string; label: string; status: ProcessStatus; alive: boolean }[];
|
|
877
|
+
} {
|
|
878
|
+
const procs = getGroupProcesses(group);
|
|
879
|
+
const healthy = procs.length > 0 && procs.every(p => p.alive && (p.status === "ready" || p.status === "starting"));
|
|
880
|
+
return {
|
|
881
|
+
group,
|
|
882
|
+
healthy,
|
|
883
|
+
processes: procs.map(p => ({
|
|
884
|
+
id: p.id,
|
|
885
|
+
label: p.label,
|
|
886
|
+
status: p.status,
|
|
887
|
+
alive: p.alive,
|
|
888
|
+
})),
|
|
889
|
+
};
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
// ── Persistence ────────────────────────────────────────────────────────────
|
|
893
|
+
|
|
894
|
+
interface ProcessManifest {
|
|
895
|
+
id: string;
|
|
896
|
+
label: string;
|
|
897
|
+
command: string;
|
|
898
|
+
cwd: string;
|
|
899
|
+
startedAt: number;
|
|
900
|
+
processType: ProcessType;
|
|
901
|
+
group: string | null;
|
|
902
|
+
readyPattern: string | null;
|
|
903
|
+
readyPort: number | null;
|
|
904
|
+
pid: number | undefined;
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
function getManifestPath(cwd: string): string {
|
|
908
|
+
const dir = join(cwd, ".bg-shell");
|
|
909
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
910
|
+
return join(dir, "manifest.json");
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
function persistManifest(cwd: string): void {
|
|
914
|
+
try {
|
|
915
|
+
const manifest: ProcessManifest[] = Array.from(processes.values())
|
|
916
|
+
.filter(p => p.alive)
|
|
917
|
+
.map(p => ({
|
|
918
|
+
id: p.id,
|
|
919
|
+
label: p.label,
|
|
920
|
+
command: p.command,
|
|
921
|
+
cwd: p.cwd,
|
|
922
|
+
startedAt: p.startedAt,
|
|
923
|
+
processType: p.processType,
|
|
924
|
+
group: p.group,
|
|
925
|
+
readyPattern: p.readyPattern,
|
|
926
|
+
readyPort: p.readyPort,
|
|
927
|
+
pid: p.proc.pid,
|
|
928
|
+
}));
|
|
929
|
+
writeFileSync(getManifestPath(cwd), JSON.stringify(manifest, null, 2));
|
|
930
|
+
} catch { /* best effort */ }
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
function loadManifest(cwd: string): ProcessManifest[] {
|
|
934
|
+
try {
|
|
935
|
+
const path = getManifestPath(cwd);
|
|
936
|
+
if (existsSync(path)) {
|
|
937
|
+
return JSON.parse(readFileSync(path, "utf-8"));
|
|
938
|
+
}
|
|
939
|
+
} catch { /* best effort */ }
|
|
940
|
+
return [];
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
// ── Utilities ──────────────────────────────────────────────────────────────
|
|
944
|
+
|
|
945
|
+
function formatUptime(ms: number): string {
|
|
946
|
+
const seconds = Math.floor(ms / 1000);
|
|
947
|
+
if (seconds < 60) return `${seconds}s`;
|
|
948
|
+
const minutes = Math.floor(seconds / 60);
|
|
949
|
+
if (minutes < 60) return `${minutes}m ${seconds % 60}s`;
|
|
950
|
+
const hours = Math.floor(minutes / 60);
|
|
951
|
+
return `${hours}h ${minutes % 60}m`;
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
function formatTimeAgo(timestamp: number): string {
|
|
955
|
+
return formatUptime(Date.now() - timestamp) + " ago";
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
// ── Cleanup ────────────────────────────────────────────────────────────────
|
|
959
|
+
|
|
960
|
+
function pruneDeadProcesses(): void {
|
|
961
|
+
const now = Date.now();
|
|
962
|
+
for (const [id, bg] of processes) {
|
|
963
|
+
if (!bg.alive && now - bg.startedAt > DEAD_PROCESS_TTL) {
|
|
964
|
+
processes.delete(id);
|
|
965
|
+
}
|
|
966
|
+
}
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
function cleanupAll(): void {
|
|
970
|
+
for (const [id, bg] of processes) {
|
|
971
|
+
if (bg.alive) killProcess(id, "SIGKILL");
|
|
972
|
+
}
|
|
973
|
+
processes.clear();
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
// ── Format Digest for LLM ──────────────────────────────────────────────────
|
|
977
|
+
|
|
978
|
+
function formatDigestText(bg: BgProcess, digest: OutputDigest): string {
|
|
979
|
+
let text = `Process ${bg.id} (${bg.label}):\n`;
|
|
980
|
+
text += ` status: ${digest.status}\n`;
|
|
981
|
+
text += ` type: ${bg.processType}\n`;
|
|
982
|
+
text += ` uptime: ${digest.uptime}\n`;
|
|
983
|
+
|
|
984
|
+
if (digest.ports.length > 0) text += ` ports: ${digest.ports.join(", ")}\n`;
|
|
985
|
+
if (digest.urls.length > 0) text += ` urls: ${digest.urls.join(", ")}\n`;
|
|
986
|
+
|
|
987
|
+
text += ` output: ${digest.outputLines} lines\n`;
|
|
988
|
+
text += ` changes: ${digest.changeSummary}`;
|
|
989
|
+
|
|
990
|
+
if (digest.errors.length > 0) {
|
|
991
|
+
text += `\n errors (${digest.errors.length}):`;
|
|
992
|
+
for (const err of digest.errors) {
|
|
993
|
+
text += `\n - ${err}`;
|
|
994
|
+
}
|
|
995
|
+
}
|
|
996
|
+
if (digest.warnings.length > 0) {
|
|
997
|
+
text += `\n warnings (${digest.warnings.length}):`;
|
|
998
|
+
for (const w of digest.warnings) {
|
|
999
|
+
text += `\n - ${w}`;
|
|
1000
|
+
}
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
return text;
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
// ── Extension Entry Point ──────────────────────────────────────────────────
|
|
1007
|
+
|
|
1008
|
+
export default function (pi: ExtensionAPI) {
|
|
1009
|
+
let latestCtx: ExtensionContext | null = null;
|
|
1010
|
+
|
|
1011
|
+
// Clean up on session shutdown
|
|
1012
|
+
pi.on("session_shutdown", async () => {
|
|
1013
|
+
cleanupAll();
|
|
1014
|
+
});
|
|
1015
|
+
|
|
1016
|
+
// ── Compaction Awareness: Survive Context Resets ───────────────────
|
|
1017
|
+
|
|
1018
|
+
/** Build a compact state summary of all alive processes for context re-injection */
|
|
1019
|
+
function buildProcessStateAlert(reason: string): void {
|
|
1020
|
+
const alive = Array.from(processes.values()).filter(p => p.alive);
|
|
1021
|
+
if (alive.length === 0) return;
|
|
1022
|
+
|
|
1023
|
+
const processSummaries = alive.map(p => {
|
|
1024
|
+
const portInfo = p.ports.length > 0 ? ` :${p.ports.join(",")}` : "";
|
|
1025
|
+
const urlInfo = p.urls.length > 0 ? ` ${p.urls[0]}` : "";
|
|
1026
|
+
const errInfo = p.recentErrors.length > 0 ? ` (${p.recentErrors.length} errors)` : "";
|
|
1027
|
+
const groupInfo = p.group ? ` [${p.group}]` : "";
|
|
1028
|
+
return ` - id:${p.id} "${p.label}" [${p.processType}] status:${p.status} uptime:${formatUptime(Date.now() - p.startedAt)}${portInfo}${urlInfo}${errInfo}${groupInfo}`;
|
|
1029
|
+
}).join("\n");
|
|
1030
|
+
|
|
1031
|
+
pendingAlerts.push(
|
|
1032
|
+
`${reason} ${alive.length} background process(es) are still running:\n${processSummaries}\nUse bg_shell digest/output/kill with these IDs.`
|
|
1033
|
+
);
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
// After compaction, the LLM loses all memory of running processes.
|
|
1037
|
+
// Queue a detailed alert so the next before_agent_start injects full state.
|
|
1038
|
+
pi.on("session_compact", async () => {
|
|
1039
|
+
buildProcessStateAlert("Context was compacted.");
|
|
1040
|
+
});
|
|
1041
|
+
|
|
1042
|
+
// Tree navigation also resets the agent's context.
|
|
1043
|
+
pi.on("session_tree", async () => {
|
|
1044
|
+
buildProcessStateAlert("Session tree was navigated.");
|
|
1045
|
+
});
|
|
1046
|
+
|
|
1047
|
+
// Session switch resets the agent's context.
|
|
1048
|
+
pi.on("session_switch", async () => {
|
|
1049
|
+
buildProcessStateAlert("Session was switched.");
|
|
1050
|
+
});
|
|
1051
|
+
|
|
1052
|
+
// ── Context Injection: Proactive Alerts ────────────────────────────
|
|
1053
|
+
|
|
1054
|
+
pi.on("before_agent_start", async (_event, _ctx) => {
|
|
1055
|
+
// Inject process status overview and any pending alerts
|
|
1056
|
+
const alerts = pendingAlerts.splice(0);
|
|
1057
|
+
const alive = Array.from(processes.values()).filter(p => p.alive);
|
|
1058
|
+
|
|
1059
|
+
if (alerts.length === 0 && alive.length === 0) return;
|
|
1060
|
+
|
|
1061
|
+
const parts: string[] = [];
|
|
1062
|
+
|
|
1063
|
+
if (alerts.length > 0) {
|
|
1064
|
+
parts.push(`Background process alerts:\n${alerts.map(a => ` ${a}`).join("\n")}`);
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
if (alive.length > 0) {
|
|
1068
|
+
const summary = alive.map(p => {
|
|
1069
|
+
const status = p.status === "ready" ? "✓" : p.status === "error" ? "✗" : p.status === "starting" ? "⋯" : "?";
|
|
1070
|
+
const portInfo = p.ports.length > 0 ? ` :${p.ports.join(",")}` : "";
|
|
1071
|
+
const errInfo = p.recentErrors.length > 0 ? ` (${p.recentErrors.length} errors)` : "";
|
|
1072
|
+
return ` ${status} ${p.id} ${p.label}${portInfo}${errInfo}`;
|
|
1073
|
+
}).join("\n");
|
|
1074
|
+
parts.push(`Background processes:\n${summary}`);
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1077
|
+
return {
|
|
1078
|
+
message: {
|
|
1079
|
+
customType: "bg-shell-status",
|
|
1080
|
+
content: parts.join("\n\n"),
|
|
1081
|
+
display: false,
|
|
1082
|
+
},
|
|
1083
|
+
};
|
|
1084
|
+
});
|
|
1085
|
+
|
|
1086
|
+
// ── Session Start: Discover Surviving Processes ────────────────────
|
|
1087
|
+
|
|
1088
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
1089
|
+
latestCtx = ctx;
|
|
1090
|
+
|
|
1091
|
+
// Check for surviving processes from previous session
|
|
1092
|
+
const manifest = loadManifest(ctx.cwd);
|
|
1093
|
+
if (manifest.length > 0) {
|
|
1094
|
+
// Check which PIDs are still alive
|
|
1095
|
+
const surviving: ProcessManifest[] = [];
|
|
1096
|
+
for (const entry of manifest) {
|
|
1097
|
+
if (entry.pid) {
|
|
1098
|
+
try {
|
|
1099
|
+
process.kill(entry.pid, 0); // Check if process exists
|
|
1100
|
+
surviving.push(entry);
|
|
1101
|
+
} catch { /* process is dead */ }
|
|
1102
|
+
}
|
|
1103
|
+
}
|
|
1104
|
+
|
|
1105
|
+
if (surviving.length > 0) {
|
|
1106
|
+
const summary = surviving.map(s =>
|
|
1107
|
+
` - ${s.id}: ${s.label} (pid ${s.pid}, type: ${s.processType}${s.group ? `, group: ${s.group}` : ""})`
|
|
1108
|
+
).join("\n");
|
|
1109
|
+
|
|
1110
|
+
pendingAlerts.push(
|
|
1111
|
+
`${surviving.length} background process(es) from previous session still running:\n${summary}\n Note: These processes are outside bg_shell's control. Kill them manually if needed.`
|
|
1112
|
+
);
|
|
1113
|
+
}
|
|
1114
|
+
}
|
|
1115
|
+
});
|
|
1116
|
+
|
|
1117
|
+
// ── Tool ─────────────────────────────────────────────────────────────
|
|
1118
|
+
|
|
1119
|
+
pi.registerTool({
|
|
1120
|
+
name: "bg_shell",
|
|
1121
|
+
label: "Background Shell",
|
|
1122
|
+
description:
|
|
1123
|
+
"Run shell commands in the background without blocking. Manages persistent background processes with intelligent lifecycle tracking. " +
|
|
1124
|
+
"Actions: start (launch with auto-classification & readiness detection), digest (structured summary ~30 tokens vs ~2000 raw), " +
|
|
1125
|
+
"output (raw lines with incremental delivery), wait_for_ready (block until process signals readiness), " +
|
|
1126
|
+
"send (write stdin), send_and_wait (expect-style: send + wait for output pattern), " +
|
|
1127
|
+
"signal (send OS signal), list (all processes with status), kill (terminate), restart (kill + relaunch), " +
|
|
1128
|
+
"group_status (health of a process group), highlights (significant output lines only).",
|
|
1129
|
+
|
|
1130
|
+
promptGuidelines: [
|
|
1131
|
+
"Use bg_shell to start long-running processes (servers, watchers, builds) that should not block the agent.",
|
|
1132
|
+
"After starting a server, use 'wait_for_ready' to efficiently block until it's listening — avoids polling loops entirely.",
|
|
1133
|
+
"Use 'digest' instead of 'output' when you just need status — it returns a structured ~30-token summary instead of ~2000 tokens of raw output.",
|
|
1134
|
+
"Use 'highlights' to see only significant output (errors, URLs, results) — typically 5-15 lines instead of hundreds.",
|
|
1135
|
+
"Use 'output' only when you need raw lines for debugging — add filter:'error|warning' to narrow results.",
|
|
1136
|
+
"The 'output' action returns only new output since the last check (incremental). Repeated calls are cheap on context.",
|
|
1137
|
+
"Set type:'server' and ready_port:3000 for dev servers so readiness detection is automatic.",
|
|
1138
|
+
"Set group:'my-stack' on related processes to manage them together with 'group_status'.",
|
|
1139
|
+
"Use 'send_and_wait' for interactive CLIs: send input and wait for expected output pattern.",
|
|
1140
|
+
"Use 'restart' to kill and relaunch with the same config — preserves restart count.",
|
|
1141
|
+
"Background processes are auto-classified (server/build/test/watcher) based on the command.",
|
|
1142
|
+
"Process crashes and errors are automatically surfaced as alerts at the start of your next turn — you don't need to poll.",
|
|
1143
|
+
],
|
|
1144
|
+
|
|
1145
|
+
parameters: Type.Object({
|
|
1146
|
+
action: StringEnum([
|
|
1147
|
+
"start",
|
|
1148
|
+
"digest",
|
|
1149
|
+
"output",
|
|
1150
|
+
"highlights",
|
|
1151
|
+
"wait_for_ready",
|
|
1152
|
+
"send",
|
|
1153
|
+
"send_and_wait",
|
|
1154
|
+
"signal",
|
|
1155
|
+
"list",
|
|
1156
|
+
"kill",
|
|
1157
|
+
"restart",
|
|
1158
|
+
"group_status",
|
|
1159
|
+
] as const),
|
|
1160
|
+
command: Type.Optional(
|
|
1161
|
+
Type.String({ description: "Shell command to run (for start)" }),
|
|
1162
|
+
),
|
|
1163
|
+
label: Type.Optional(
|
|
1164
|
+
Type.String({ description: "Short human-readable label for the process (for start)" }),
|
|
1165
|
+
),
|
|
1166
|
+
id: Type.Optional(
|
|
1167
|
+
Type.String({ description: "Process ID (for digest, output, highlights, wait_for_ready, send, send_and_wait, signal, kill, restart)" }),
|
|
1168
|
+
),
|
|
1169
|
+
stream: Type.Optional(
|
|
1170
|
+
StringEnum(["stdout", "stderr", "both"] as const),
|
|
1171
|
+
),
|
|
1172
|
+
tail: Type.Optional(
|
|
1173
|
+
Type.Number({ description: "Number of most recent lines to return (for output). Defaults to 100." }),
|
|
1174
|
+
),
|
|
1175
|
+
filter: Type.Optional(
|
|
1176
|
+
Type.String({ description: "Regex pattern to filter output lines (for output). Case-insensitive." }),
|
|
1177
|
+
),
|
|
1178
|
+
input: Type.Optional(
|
|
1179
|
+
Type.String({ description: "Text to write to process stdin (for send, send_and_wait)" }),
|
|
1180
|
+
),
|
|
1181
|
+
wait_pattern: Type.Optional(
|
|
1182
|
+
Type.String({ description: "Regex to wait for in output (for send_and_wait)" }),
|
|
1183
|
+
),
|
|
1184
|
+
signal_name: Type.Optional(
|
|
1185
|
+
Type.String({ description: "OS signal to send, e.g. SIGINT, SIGTERM, SIGHUP (for signal)" }),
|
|
1186
|
+
),
|
|
1187
|
+
timeout: Type.Optional(
|
|
1188
|
+
Type.Number({ description: "Timeout in milliseconds (for wait_for_ready, send_and_wait). Default: 30000" }),
|
|
1189
|
+
),
|
|
1190
|
+
type: Type.Optional(
|
|
1191
|
+
StringEnum(["server", "build", "test", "watcher", "generic"] as const),
|
|
1192
|
+
),
|
|
1193
|
+
ready_pattern: Type.Optional(
|
|
1194
|
+
Type.String({ description: "Regex pattern that indicates the process is ready (for start)" }),
|
|
1195
|
+
),
|
|
1196
|
+
ready_port: Type.Optional(
|
|
1197
|
+
Type.Number({ description: "Port to probe for readiness (for start). When open, process is considered ready." }),
|
|
1198
|
+
),
|
|
1199
|
+
group: Type.Optional(
|
|
1200
|
+
Type.String({ description: "Group name for related processes (for start, group_status)" }),
|
|
1201
|
+
),
|
|
1202
|
+
}),
|
|
1203
|
+
|
|
1204
|
+
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
|
1205
|
+
latestCtx = ctx;
|
|
1206
|
+
|
|
1207
|
+
switch (params.action) {
|
|
1208
|
+
// ── start ──────────────────────────────────────────
|
|
1209
|
+
case "start": {
|
|
1210
|
+
if (!params.command) {
|
|
1211
|
+
return {
|
|
1212
|
+
content: [{ type: "text" as const, text: "Error: 'command' is required for start" }],
|
|
1213
|
+
isError: true,
|
|
1214
|
+
};
|
|
1215
|
+
}
|
|
1216
|
+
|
|
1217
|
+
const bg = startProcess({
|
|
1218
|
+
command: params.command,
|
|
1219
|
+
cwd: ctx.cwd,
|
|
1220
|
+
label: params.label,
|
|
1221
|
+
type: params.type as ProcessType | undefined,
|
|
1222
|
+
readyPattern: params.ready_pattern,
|
|
1223
|
+
readyPort: params.ready_port,
|
|
1224
|
+
group: params.group,
|
|
1225
|
+
});
|
|
1226
|
+
|
|
1227
|
+
// Give the process a moment to potentially fail immediately
|
|
1228
|
+
await new Promise(r => setTimeout(r, 500));
|
|
1229
|
+
|
|
1230
|
+
// Persist manifest
|
|
1231
|
+
persistManifest(ctx.cwd);
|
|
1232
|
+
|
|
1233
|
+
const info = getInfo(bg);
|
|
1234
|
+
let text = `Started background process ${bg.id}\n`;
|
|
1235
|
+
text += ` label: ${bg.label}\n`;
|
|
1236
|
+
text += ` type: ${bg.processType}\n`;
|
|
1237
|
+
text += ` status: ${bg.status}\n`;
|
|
1238
|
+
text += ` command: ${bg.command}\n`;
|
|
1239
|
+
text += ` cwd: ${bg.cwd}`;
|
|
1240
|
+
|
|
1241
|
+
if (bg.group) text += `\n group: ${bg.group}`;
|
|
1242
|
+
if (bg.readyPort) text += `\n ready_port: ${bg.readyPort}`;
|
|
1243
|
+
if (bg.readyPattern) text += `\n ready_pattern: ${bg.readyPattern}`;
|
|
1244
|
+
if (bg.ports.length > 0) text += `\n detected ports: ${bg.ports.join(", ")}`;
|
|
1245
|
+
if (bg.urls.length > 0) text += `\n detected urls: ${bg.urls.join(", ")}`;
|
|
1246
|
+
|
|
1247
|
+
if (!bg.alive) {
|
|
1248
|
+
text += `\n exit code: ${bg.exitCode}`;
|
|
1249
|
+
const errLines = bg.output.filter(l => l.stream === "stderr").map(l => l.line);
|
|
1250
|
+
const errOut = errLines.join("\n").trim();
|
|
1251
|
+
if (errOut) text += `\n stderr:\n${errOut}`;
|
|
1252
|
+
}
|
|
1253
|
+
|
|
1254
|
+
return {
|
|
1255
|
+
content: [{ type: "text" as const, text }],
|
|
1256
|
+
details: { action: "start", process: info },
|
|
1257
|
+
};
|
|
1258
|
+
}
|
|
1259
|
+
|
|
1260
|
+
// ── digest ─────────────────────────────────────────
|
|
1261
|
+
case "digest": {
|
|
1262
|
+
// Can get digest for a single process or all
|
|
1263
|
+
if (params.id) {
|
|
1264
|
+
const bg = processes.get(params.id);
|
|
1265
|
+
if (!bg) {
|
|
1266
|
+
return {
|
|
1267
|
+
content: [{ type: "text" as const, text: `Error: No process found with id '${params.id}'` }],
|
|
1268
|
+
isError: true,
|
|
1269
|
+
};
|
|
1270
|
+
}
|
|
1271
|
+
const digest = generateDigest(bg, true);
|
|
1272
|
+
return {
|
|
1273
|
+
content: [{ type: "text" as const, text: formatDigestText(bg, digest) }],
|
|
1274
|
+
details: { action: "digest", process: getInfo(bg), digest },
|
|
1275
|
+
};
|
|
1276
|
+
}
|
|
1277
|
+
|
|
1278
|
+
// All processes digest
|
|
1279
|
+
const all = Array.from(processes.values());
|
|
1280
|
+
if (all.length === 0) {
|
|
1281
|
+
return {
|
|
1282
|
+
content: [{ type: "text" as const, text: "No background processes." }],
|
|
1283
|
+
details: { action: "digest", processes: [] },
|
|
1284
|
+
};
|
|
1285
|
+
}
|
|
1286
|
+
|
|
1287
|
+
const lines = all.map(bg => {
|
|
1288
|
+
const d = generateDigest(bg, true);
|
|
1289
|
+
const status = bg.alive
|
|
1290
|
+
? (bg.status === "ready" ? "✓" : bg.status === "error" ? "✗" : "⋯")
|
|
1291
|
+
: "○";
|
|
1292
|
+
const portInfo = d.ports.length > 0 ? ` :${d.ports.join(",")}` : "";
|
|
1293
|
+
const errInfo = d.errors.length > 0 ? ` (${d.errors.length} errors)` : "";
|
|
1294
|
+
return `${status} ${bg.id} ${bg.label} [${bg.processType}] ${d.uptime}${portInfo}${errInfo} — ${d.changeSummary}`;
|
|
1295
|
+
});
|
|
1296
|
+
|
|
1297
|
+
return {
|
|
1298
|
+
content: [{ type: "text" as const, text: `Background processes (${all.length}):\n${lines.join("\n")}` }],
|
|
1299
|
+
details: { action: "digest", count: all.length },
|
|
1300
|
+
};
|
|
1301
|
+
}
|
|
1302
|
+
|
|
1303
|
+
// ── highlights ──────────────────────────────────────
|
|
1304
|
+
case "highlights": {
|
|
1305
|
+
if (!params.id) {
|
|
1306
|
+
return {
|
|
1307
|
+
content: [{ type: "text" as const, text: "Error: 'id' is required for highlights" }],
|
|
1308
|
+
isError: true,
|
|
1309
|
+
};
|
|
1310
|
+
}
|
|
1311
|
+
|
|
1312
|
+
const bg = processes.get(params.id);
|
|
1313
|
+
if (!bg) {
|
|
1314
|
+
return {
|
|
1315
|
+
content: [{ type: "text" as const, text: `Error: No process found with id '${params.id}'` }],
|
|
1316
|
+
isError: true,
|
|
1317
|
+
};
|
|
1318
|
+
}
|
|
1319
|
+
|
|
1320
|
+
const highlights = getHighlights(bg, params.tail || 15);
|
|
1321
|
+
const info = getInfo(bg);
|
|
1322
|
+
let text = `Highlights for ${bg.id} (${bg.label}) — ${bg.status}:\n`;
|
|
1323
|
+
if (highlights.length === 0) {
|
|
1324
|
+
text += "(no significant output)";
|
|
1325
|
+
} else {
|
|
1326
|
+
text += highlights.join("\n");
|
|
1327
|
+
}
|
|
1328
|
+
|
|
1329
|
+
return {
|
|
1330
|
+
content: [{ type: "text" as const, text }],
|
|
1331
|
+
details: { action: "highlights", process: info, lineCount: highlights.length },
|
|
1332
|
+
};
|
|
1333
|
+
}
|
|
1334
|
+
|
|
1335
|
+
// ── output ─────────────────────────────────────────
|
|
1336
|
+
case "output": {
|
|
1337
|
+
if (!params.id) {
|
|
1338
|
+
return {
|
|
1339
|
+
content: [{ type: "text" as const, text: "Error: 'id' is required for output" }],
|
|
1340
|
+
isError: true,
|
|
1341
|
+
};
|
|
1342
|
+
}
|
|
1343
|
+
|
|
1344
|
+
const bg = processes.get(params.id);
|
|
1345
|
+
if (!bg) {
|
|
1346
|
+
return {
|
|
1347
|
+
content: [{ type: "text" as const, text: `Error: No process found with id '${params.id}'` }],
|
|
1348
|
+
isError: true,
|
|
1349
|
+
};
|
|
1350
|
+
}
|
|
1351
|
+
|
|
1352
|
+
const stream = params.stream || "both";
|
|
1353
|
+
const tail = params.tail ?? 100;
|
|
1354
|
+
const output = getOutput(bg, {
|
|
1355
|
+
stream,
|
|
1356
|
+
tail,
|
|
1357
|
+
filter: params.filter,
|
|
1358
|
+
incremental: true,
|
|
1359
|
+
});
|
|
1360
|
+
const info = getInfo(bg);
|
|
1361
|
+
|
|
1362
|
+
let text = `Process ${bg.id} (${bg.label})`;
|
|
1363
|
+
text += ` — ${bg.alive ? `${bg.status}` : `exited (code ${bg.exitCode})`}`;
|
|
1364
|
+
if (output) {
|
|
1365
|
+
text += `\n${output}`;
|
|
1366
|
+
} else {
|
|
1367
|
+
text += `\n(no new output since last check)`;
|
|
1368
|
+
}
|
|
1369
|
+
|
|
1370
|
+
return {
|
|
1371
|
+
content: [{ type: "text" as const, text }],
|
|
1372
|
+
details: { action: "output", process: info, stream, tail },
|
|
1373
|
+
};
|
|
1374
|
+
}
|
|
1375
|
+
|
|
1376
|
+
// ── wait_for_ready ──────────────────────────────────
|
|
1377
|
+
case "wait_for_ready": {
|
|
1378
|
+
if (!params.id) {
|
|
1379
|
+
return {
|
|
1380
|
+
content: [{ type: "text" as const, text: "Error: 'id' is required for wait_for_ready" }],
|
|
1381
|
+
isError: true,
|
|
1382
|
+
};
|
|
1383
|
+
}
|
|
1384
|
+
|
|
1385
|
+
const bg = processes.get(params.id);
|
|
1386
|
+
if (!bg) {
|
|
1387
|
+
return {
|
|
1388
|
+
content: [{ type: "text" as const, text: `Error: No process found with id '${params.id}'` }],
|
|
1389
|
+
isError: true,
|
|
1390
|
+
};
|
|
1391
|
+
}
|
|
1392
|
+
|
|
1393
|
+
// Already ready?
|
|
1394
|
+
if (bg.status === "ready") {
|
|
1395
|
+
const digest = generateDigest(bg, true);
|
|
1396
|
+
return {
|
|
1397
|
+
content: [{ type: "text" as const, text: `Process ${bg.id} is already ready.\n${formatDigestText(bg, digest)}` }],
|
|
1398
|
+
details: { action: "wait_for_ready", process: getInfo(bg), ready: true },
|
|
1399
|
+
};
|
|
1400
|
+
}
|
|
1401
|
+
|
|
1402
|
+
const timeout = params.timeout || DEFAULT_READY_TIMEOUT;
|
|
1403
|
+
const result = await waitForReady(bg, timeout, signal ?? undefined);
|
|
1404
|
+
|
|
1405
|
+
const digest = generateDigest(bg, true);
|
|
1406
|
+
let text: string;
|
|
1407
|
+
if (result.ready) {
|
|
1408
|
+
text = `✓ Process ${bg.id} is ready: ${result.detail}\n${formatDigestText(bg, digest)}`;
|
|
1409
|
+
} else {
|
|
1410
|
+
text = `✗ Process ${bg.id} not ready: ${result.detail}\n${formatDigestText(bg, digest)}`;
|
|
1411
|
+
}
|
|
1412
|
+
|
|
1413
|
+
return {
|
|
1414
|
+
content: [{ type: "text" as const, text }],
|
|
1415
|
+
details: { action: "wait_for_ready", process: getInfo(bg), ready: result.ready, detail: result.detail },
|
|
1416
|
+
};
|
|
1417
|
+
}
|
|
1418
|
+
|
|
1419
|
+
// ── send ───────────────────────────────────────────
|
|
1420
|
+
case "send": {
|
|
1421
|
+
if (!params.id) {
|
|
1422
|
+
return {
|
|
1423
|
+
content: [{ type: "text" as const, text: "Error: 'id' is required for send" }],
|
|
1424
|
+
isError: true,
|
|
1425
|
+
};
|
|
1426
|
+
}
|
|
1427
|
+
if (params.input === undefined) {
|
|
1428
|
+
return {
|
|
1429
|
+
content: [{ type: "text" as const, text: "Error: 'input' is required for send" }],
|
|
1430
|
+
isError: true,
|
|
1431
|
+
};
|
|
1432
|
+
}
|
|
1433
|
+
|
|
1434
|
+
const bg = processes.get(params.id);
|
|
1435
|
+
if (!bg) {
|
|
1436
|
+
return {
|
|
1437
|
+
content: [{ type: "text" as const, text: `Error: No process found with id '${params.id}'` }],
|
|
1438
|
+
isError: true,
|
|
1439
|
+
};
|
|
1440
|
+
}
|
|
1441
|
+
|
|
1442
|
+
if (!bg.alive) {
|
|
1443
|
+
return {
|
|
1444
|
+
content: [{ type: "text" as const, text: `Error: Process ${params.id} has already exited` }],
|
|
1445
|
+
isError: true,
|
|
1446
|
+
};
|
|
1447
|
+
}
|
|
1448
|
+
|
|
1449
|
+
try {
|
|
1450
|
+
bg.proc.stdin?.write(params.input + "\n");
|
|
1451
|
+
return {
|
|
1452
|
+
content: [{ type: "text" as const, text: `Sent input to process ${bg.id}` }],
|
|
1453
|
+
details: { action: "send", process: getInfo(bg) },
|
|
1454
|
+
};
|
|
1455
|
+
} catch (err) {
|
|
1456
|
+
return {
|
|
1457
|
+
content: [{ type: "text" as const, text: `Error writing to stdin: ${err instanceof Error ? err.message : String(err)}` }],
|
|
1458
|
+
isError: true,
|
|
1459
|
+
};
|
|
1460
|
+
}
|
|
1461
|
+
}
|
|
1462
|
+
|
|
1463
|
+
// ── send_and_wait ───────────────────────────────────
|
|
1464
|
+
case "send_and_wait": {
|
|
1465
|
+
if (!params.id) {
|
|
1466
|
+
return {
|
|
1467
|
+
content: [{ type: "text" as const, text: "Error: 'id' is required for send_and_wait" }],
|
|
1468
|
+
isError: true,
|
|
1469
|
+
};
|
|
1470
|
+
}
|
|
1471
|
+
if (params.input === undefined) {
|
|
1472
|
+
return {
|
|
1473
|
+
content: [{ type: "text" as const, text: "Error: 'input' is required for send_and_wait" }],
|
|
1474
|
+
isError: true,
|
|
1475
|
+
};
|
|
1476
|
+
}
|
|
1477
|
+
if (!params.wait_pattern) {
|
|
1478
|
+
return {
|
|
1479
|
+
content: [{ type: "text" as const, text: "Error: 'wait_pattern' is required for send_and_wait" }],
|
|
1480
|
+
isError: true,
|
|
1481
|
+
};
|
|
1482
|
+
}
|
|
1483
|
+
|
|
1484
|
+
const bg = processes.get(params.id);
|
|
1485
|
+
if (!bg) {
|
|
1486
|
+
return {
|
|
1487
|
+
content: [{ type: "text" as const, text: `Error: No process found with id '${params.id}'` }],
|
|
1488
|
+
isError: true,
|
|
1489
|
+
};
|
|
1490
|
+
}
|
|
1491
|
+
|
|
1492
|
+
if (!bg.alive) {
|
|
1493
|
+
return {
|
|
1494
|
+
content: [{ type: "text" as const, text: `Error: Process ${params.id} has already exited` }],
|
|
1495
|
+
isError: true,
|
|
1496
|
+
};
|
|
1497
|
+
}
|
|
1498
|
+
|
|
1499
|
+
const timeout = params.timeout || 10000;
|
|
1500
|
+
const result = await sendAndWait(bg, params.input, params.wait_pattern, timeout, signal ?? undefined);
|
|
1501
|
+
|
|
1502
|
+
let text: string;
|
|
1503
|
+
if (result.matched) {
|
|
1504
|
+
text = `✓ Pattern matched for process ${bg.id}\n${result.output}`;
|
|
1505
|
+
} else {
|
|
1506
|
+
text = `✗ Pattern not matched (timed out after ${timeout}ms)\n${result.output}`;
|
|
1507
|
+
}
|
|
1508
|
+
|
|
1509
|
+
return {
|
|
1510
|
+
content: [{ type: "text" as const, text }],
|
|
1511
|
+
details: { action: "send_and_wait", process: getInfo(bg), matched: result.matched },
|
|
1512
|
+
};
|
|
1513
|
+
}
|
|
1514
|
+
|
|
1515
|
+
// ── signal ─────────────────────────────────────────
|
|
1516
|
+
case "signal": {
|
|
1517
|
+
if (!params.id) {
|
|
1518
|
+
return {
|
|
1519
|
+
content: [{ type: "text" as const, text: "Error: 'id' is required for signal" }],
|
|
1520
|
+
isError: true,
|
|
1521
|
+
};
|
|
1522
|
+
}
|
|
1523
|
+
|
|
1524
|
+
const bg = processes.get(params.id);
|
|
1525
|
+
if (!bg) {
|
|
1526
|
+
return {
|
|
1527
|
+
content: [{ type: "text" as const, text: `Error: No process found with id '${params.id}'` }],
|
|
1528
|
+
isError: true,
|
|
1529
|
+
};
|
|
1530
|
+
}
|
|
1531
|
+
|
|
1532
|
+
const sig = (params.signal_name || "SIGINT") as NodeJS.Signals;
|
|
1533
|
+
const sent = killProcess(params.id, sig);
|
|
1534
|
+
|
|
1535
|
+
return {
|
|
1536
|
+
content: [{ type: "text" as const, text: sent ? `Sent ${sig} to process ${bg.id} (${bg.label})` : `Failed to send ${sig} to process ${bg.id}` }],
|
|
1537
|
+
details: { action: "signal", process: getInfo(bg), signal: sig },
|
|
1538
|
+
};
|
|
1539
|
+
}
|
|
1540
|
+
|
|
1541
|
+
// ── list ───────────────────────────────────────────
|
|
1542
|
+
case "list": {
|
|
1543
|
+
const all = Array.from(processes.values()).map(getInfo);
|
|
1544
|
+
|
|
1545
|
+
if (all.length === 0) {
|
|
1546
|
+
return {
|
|
1547
|
+
content: [{ type: "text" as const, text: "No background processes." }],
|
|
1548
|
+
details: { action: "list", processes: [] },
|
|
1549
|
+
};
|
|
1550
|
+
}
|
|
1551
|
+
|
|
1552
|
+
const lines = all.map(p => {
|
|
1553
|
+
const status = p.alive
|
|
1554
|
+
? (p.status === "ready" ? "✓ ready" : p.status === "error" ? "✗ error" : "⋯ starting")
|
|
1555
|
+
: `○ ${p.status} (code ${p.exitCode})`;
|
|
1556
|
+
const portInfo = p.ports.length > 0 ? ` :${p.ports.join(",")}` : "";
|
|
1557
|
+
const urlInfo = p.urls.length > 0 ? ` ${p.urls[0]}` : "";
|
|
1558
|
+
const groupInfo = p.group ? ` [${p.group}]` : "";
|
|
1559
|
+
return `${p.id} ${status} ${p.uptime} ${p.label} [${p.processType}]${portInfo}${urlInfo}${groupInfo}`;
|
|
1560
|
+
});
|
|
1561
|
+
|
|
1562
|
+
return {
|
|
1563
|
+
content: [{ type: "text" as const, text: `Background processes (${all.length}):\n${lines.join("\n")}` }],
|
|
1564
|
+
details: { action: "list", processes: all },
|
|
1565
|
+
};
|
|
1566
|
+
}
|
|
1567
|
+
|
|
1568
|
+
// ── kill ───────────────────────────────────────────
|
|
1569
|
+
case "kill": {
|
|
1570
|
+
if (!params.id) {
|
|
1571
|
+
return {
|
|
1572
|
+
content: [{ type: "text" as const, text: "Error: 'id' is required for kill" }],
|
|
1573
|
+
isError: true,
|
|
1574
|
+
};
|
|
1575
|
+
}
|
|
1576
|
+
|
|
1577
|
+
const bg = processes.get(params.id);
|
|
1578
|
+
if (!bg) {
|
|
1579
|
+
return {
|
|
1580
|
+
content: [{ type: "text" as const, text: `Error: No process found with id '${params.id}'` }],
|
|
1581
|
+
isError: true,
|
|
1582
|
+
};
|
|
1583
|
+
}
|
|
1584
|
+
|
|
1585
|
+
const killed = killProcess(params.id, "SIGTERM");
|
|
1586
|
+
await new Promise(r => setTimeout(r, 300));
|
|
1587
|
+
if (bg.alive) {
|
|
1588
|
+
killProcess(params.id, "SIGKILL");
|
|
1589
|
+
await new Promise(r => setTimeout(r, 200));
|
|
1590
|
+
}
|
|
1591
|
+
|
|
1592
|
+
const info = getInfo(bg);
|
|
1593
|
+
if (!bg.alive) processes.delete(params.id);
|
|
1594
|
+
|
|
1595
|
+
// Update manifest
|
|
1596
|
+
persistManifest(ctx.cwd);
|
|
1597
|
+
|
|
1598
|
+
return {
|
|
1599
|
+
content: [{ type: "text" as const, text: killed ? `Killed process ${bg.id} (${bg.label})` : `Failed to kill process ${bg.id}` }],
|
|
1600
|
+
details: { action: "kill", process: info },
|
|
1601
|
+
};
|
|
1602
|
+
}
|
|
1603
|
+
|
|
1604
|
+
// ── restart ────────────────────────────────────────
|
|
1605
|
+
case "restart": {
|
|
1606
|
+
if (!params.id) {
|
|
1607
|
+
return {
|
|
1608
|
+
content: [{ type: "text" as const, text: "Error: 'id' is required for restart" }],
|
|
1609
|
+
isError: true,
|
|
1610
|
+
};
|
|
1611
|
+
}
|
|
1612
|
+
|
|
1613
|
+
const newBg = await restartProcess(params.id);
|
|
1614
|
+
if (!newBg) {
|
|
1615
|
+
return {
|
|
1616
|
+
content: [{ type: "text" as const, text: `Error: No process found with id '${params.id}'` }],
|
|
1617
|
+
isError: true,
|
|
1618
|
+
};
|
|
1619
|
+
}
|
|
1620
|
+
|
|
1621
|
+
// Give it a moment
|
|
1622
|
+
await new Promise(r => setTimeout(r, 500));
|
|
1623
|
+
persistManifest(ctx.cwd);
|
|
1624
|
+
|
|
1625
|
+
const info = getInfo(newBg);
|
|
1626
|
+
let text = `Restarted process (restart #${newBg.restartCount})\n`;
|
|
1627
|
+
text += ` new id: ${newBg.id}\n`;
|
|
1628
|
+
text += ` label: ${newBg.label}\n`;
|
|
1629
|
+
text += ` type: ${newBg.processType}\n`;
|
|
1630
|
+
text += ` status: ${newBg.status}\n`;
|
|
1631
|
+
text += ` command: ${newBg.command}`;
|
|
1632
|
+
|
|
1633
|
+
return {
|
|
1634
|
+
content: [{ type: "text" as const, text }],
|
|
1635
|
+
details: { action: "restart", process: info, previousId: params.id },
|
|
1636
|
+
};
|
|
1637
|
+
}
|
|
1638
|
+
|
|
1639
|
+
// ── group_status ────────────────────────────────────
|
|
1640
|
+
case "group_status": {
|
|
1641
|
+
if (!params.group) {
|
|
1642
|
+
// List all groups
|
|
1643
|
+
const groups = new Set<string>();
|
|
1644
|
+
for (const p of processes.values()) {
|
|
1645
|
+
if (p.group) groups.add(p.group);
|
|
1646
|
+
}
|
|
1647
|
+
|
|
1648
|
+
if (groups.size === 0) {
|
|
1649
|
+
return {
|
|
1650
|
+
content: [{ type: "text" as const, text: "No process groups defined." }],
|
|
1651
|
+
details: { action: "group_status", groups: [] },
|
|
1652
|
+
};
|
|
1653
|
+
}
|
|
1654
|
+
|
|
1655
|
+
const statuses = Array.from(groups).map(g => {
|
|
1656
|
+
const gs = getGroupStatus(g);
|
|
1657
|
+
const icon = gs.healthy ? "✓" : "✗";
|
|
1658
|
+
const procs = gs.processes.map(p => `${p.id} (${p.status})`).join(", ");
|
|
1659
|
+
return `${icon} ${g}: ${procs}`;
|
|
1660
|
+
});
|
|
1661
|
+
|
|
1662
|
+
return {
|
|
1663
|
+
content: [{ type: "text" as const, text: `Process groups:\n${statuses.join("\n")}` }],
|
|
1664
|
+
details: { action: "group_status", groups: Array.from(groups) },
|
|
1665
|
+
};
|
|
1666
|
+
}
|
|
1667
|
+
|
|
1668
|
+
const gs = getGroupStatus(params.group);
|
|
1669
|
+
const icon = gs.healthy ? "✓" : "✗";
|
|
1670
|
+
let text = `${icon} Group '${params.group}' — ${gs.healthy ? "healthy" : "unhealthy"}\n`;
|
|
1671
|
+
for (const p of gs.processes) {
|
|
1672
|
+
text += ` ${p.id}: ${p.label} — ${p.status}${p.alive ? "" : " (dead)"}\n`;
|
|
1673
|
+
}
|
|
1674
|
+
|
|
1675
|
+
return {
|
|
1676
|
+
content: [{ type: "text" as const, text }],
|
|
1677
|
+
details: { action: "group_status", groupStatus: gs },
|
|
1678
|
+
};
|
|
1679
|
+
}
|
|
1680
|
+
|
|
1681
|
+
default:
|
|
1682
|
+
return {
|
|
1683
|
+
content: [{ type: "text" as const, text: `Unknown action: ${params.action}` }],
|
|
1684
|
+
isError: true,
|
|
1685
|
+
};
|
|
1686
|
+
}
|
|
1687
|
+
},
|
|
1688
|
+
|
|
1689
|
+
// ── Rendering ────────────────────────────────────────────────────
|
|
1690
|
+
|
|
1691
|
+
renderCall(args, theme) {
|
|
1692
|
+
let text = theme.fg("toolTitle", theme.bold("bg_shell "));
|
|
1693
|
+
text += theme.fg("accent", args.action);
|
|
1694
|
+
if (args.command) text += " " + theme.fg("muted", `$ ${args.command}`);
|
|
1695
|
+
if (args.id) text += " " + theme.fg("dim", `[${args.id}]`);
|
|
1696
|
+
if (args.label) text += " " + theme.fg("dim", `(${args.label})`);
|
|
1697
|
+
if (args.type) text += " " + theme.fg("dim", `type:${args.type}`);
|
|
1698
|
+
if (args.ready_port) text += " " + theme.fg("dim", `port:${args.ready_port}`);
|
|
1699
|
+
if (args.group) text += " " + theme.fg("dim", `group:${args.group}`);
|
|
1700
|
+
return new Text(text, 0, 0);
|
|
1701
|
+
},
|
|
1702
|
+
|
|
1703
|
+
renderResult(result, { expanded }, theme) {
|
|
1704
|
+
const details = result.details as Record<string, unknown> | undefined;
|
|
1705
|
+
if (!details) {
|
|
1706
|
+
const text = result.content[0];
|
|
1707
|
+
return new Text(text?.type === "text" ? text.text : "", 0, 0);
|
|
1708
|
+
}
|
|
1709
|
+
|
|
1710
|
+
const action = details.action as string;
|
|
1711
|
+
|
|
1712
|
+
if (result.isError) {
|
|
1713
|
+
const text = result.content[0];
|
|
1714
|
+
return new Text(
|
|
1715
|
+
theme.fg("error", text?.type === "text" ? text.text : "Error"),
|
|
1716
|
+
0, 0,
|
|
1717
|
+
);
|
|
1718
|
+
}
|
|
1719
|
+
|
|
1720
|
+
switch (action) {
|
|
1721
|
+
case "start": {
|
|
1722
|
+
const proc = details.process as BgProcessInfo;
|
|
1723
|
+
let text = theme.fg("success", "▸ Started ");
|
|
1724
|
+
text += theme.fg("accent", proc.id);
|
|
1725
|
+
text += " " + theme.fg("muted", proc.label);
|
|
1726
|
+
text += " " + theme.fg("dim", `[${proc.processType}]`);
|
|
1727
|
+
if (proc.ports.length > 0) text += " " + theme.fg("dim", `:${proc.ports.join(",")}`);
|
|
1728
|
+
if (!proc.alive) {
|
|
1729
|
+
text += " " + theme.fg("error", `(exited: ${proc.exitCode})`);
|
|
1730
|
+
}
|
|
1731
|
+
return new Text(text, 0, 0);
|
|
1732
|
+
}
|
|
1733
|
+
|
|
1734
|
+
case "digest": {
|
|
1735
|
+
const proc = details.process as BgProcessInfo | undefined;
|
|
1736
|
+
if (proc) {
|
|
1737
|
+
const statusIcon = proc.status === "ready" ? theme.fg("success", "✓")
|
|
1738
|
+
: proc.status === "error" ? theme.fg("error", "✗")
|
|
1739
|
+
: theme.fg("warning", "⋯");
|
|
1740
|
+
let text = `${statusIcon} ${theme.fg("accent", proc.id)} ${theme.fg("muted", proc.label)}`;
|
|
1741
|
+
if (expanded) {
|
|
1742
|
+
const rawText = result.content[0];
|
|
1743
|
+
if (rawText?.type === "text") {
|
|
1744
|
+
const lines = rawText.text.split("\n").slice(1);
|
|
1745
|
+
for (const line of lines.slice(0, 20)) {
|
|
1746
|
+
text += "\n " + theme.fg("dim", line);
|
|
1747
|
+
}
|
|
1748
|
+
}
|
|
1749
|
+
}
|
|
1750
|
+
return new Text(text, 0, 0);
|
|
1751
|
+
}
|
|
1752
|
+
return new Text(theme.fg("dim", `${details.count ?? 0} process(es)`), 0, 0);
|
|
1753
|
+
}
|
|
1754
|
+
|
|
1755
|
+
case "highlights": {
|
|
1756
|
+
const proc = details.process as BgProcessInfo;
|
|
1757
|
+
const lineCount = details.lineCount as number;
|
|
1758
|
+
let text = theme.fg("accent", proc.id) + " " + theme.fg("dim", `${lineCount} highlights`);
|
|
1759
|
+
if (expanded) {
|
|
1760
|
+
const rawText = result.content[0];
|
|
1761
|
+
if (rawText?.type === "text") {
|
|
1762
|
+
const lines = rawText.text.split("\n").slice(1);
|
|
1763
|
+
for (const line of lines.slice(0, 20)) {
|
|
1764
|
+
text += "\n " + theme.fg("toolOutput", line);
|
|
1765
|
+
}
|
|
1766
|
+
}
|
|
1767
|
+
}
|
|
1768
|
+
return new Text(text, 0, 0);
|
|
1769
|
+
}
|
|
1770
|
+
|
|
1771
|
+
case "output": {
|
|
1772
|
+
const proc = details.process as BgProcessInfo;
|
|
1773
|
+
const statusIcon = proc.alive
|
|
1774
|
+
? (proc.status === "ready" ? theme.fg("success", "●") : proc.status === "error" ? theme.fg("error", "●") : theme.fg("warning", "●"))
|
|
1775
|
+
: theme.fg("error", "○");
|
|
1776
|
+
let text = `${statusIcon} ${theme.fg("accent", proc.id)} ${theme.fg("muted", proc.label)}`;
|
|
1777
|
+
|
|
1778
|
+
if (expanded) {
|
|
1779
|
+
const rawText = result.content[0];
|
|
1780
|
+
if (rawText?.type === "text") {
|
|
1781
|
+
const lines = rawText.text.split("\n").slice(1);
|
|
1782
|
+
const show = lines.slice(0, 30);
|
|
1783
|
+
for (const line of show) {
|
|
1784
|
+
text += "\n " + theme.fg("toolOutput", line);
|
|
1785
|
+
}
|
|
1786
|
+
if (lines.length > 30) {
|
|
1787
|
+
text += `\n ${theme.fg("dim", `... ${lines.length - 30} more lines`)}`;
|
|
1788
|
+
}
|
|
1789
|
+
}
|
|
1790
|
+
} else {
|
|
1791
|
+
text += " " + theme.fg("dim", `(${proc.stdoutLines} stdout, ${proc.stderrLines} stderr lines)`);
|
|
1792
|
+
}
|
|
1793
|
+
return new Text(text, 0, 0);
|
|
1794
|
+
}
|
|
1795
|
+
|
|
1796
|
+
case "wait_for_ready": {
|
|
1797
|
+
const proc = details.process as BgProcessInfo;
|
|
1798
|
+
const ready = details.ready as boolean;
|
|
1799
|
+
if (ready) {
|
|
1800
|
+
let text = theme.fg("success", "✓ Ready ") + theme.fg("accent", proc.id);
|
|
1801
|
+
if (proc.ports.length > 0) text += " " + theme.fg("dim", `:${proc.ports.join(",")}`);
|
|
1802
|
+
if (proc.urls.length > 0) text += " " + theme.fg("dim", proc.urls[0]);
|
|
1803
|
+
return new Text(text, 0, 0);
|
|
1804
|
+
} else {
|
|
1805
|
+
return new Text(
|
|
1806
|
+
theme.fg("error", "✗ Not ready ") + theme.fg("accent", proc.id) + " " + theme.fg("dim", String(details.detail)),
|
|
1807
|
+
0, 0,
|
|
1808
|
+
);
|
|
1809
|
+
}
|
|
1810
|
+
}
|
|
1811
|
+
|
|
1812
|
+
case "send": {
|
|
1813
|
+
const proc = details.process as BgProcessInfo;
|
|
1814
|
+
return new Text(
|
|
1815
|
+
theme.fg("success", "→ ") + theme.fg("muted", `stdin → ${proc.id}`),
|
|
1816
|
+
0, 0,
|
|
1817
|
+
);
|
|
1818
|
+
}
|
|
1819
|
+
|
|
1820
|
+
case "send_and_wait": {
|
|
1821
|
+
const proc = details.process as BgProcessInfo;
|
|
1822
|
+
const matched = details.matched as boolean;
|
|
1823
|
+
if (matched) {
|
|
1824
|
+
return new Text(
|
|
1825
|
+
theme.fg("success", "✓ ") + theme.fg("muted", `Pattern matched — ${proc.id}`),
|
|
1826
|
+
0, 0,
|
|
1827
|
+
);
|
|
1828
|
+
}
|
|
1829
|
+
return new Text(
|
|
1830
|
+
theme.fg("warning", "✗ ") + theme.fg("muted", `Timed out — ${proc.id}`),
|
|
1831
|
+
0, 0,
|
|
1832
|
+
);
|
|
1833
|
+
}
|
|
1834
|
+
|
|
1835
|
+
case "signal": {
|
|
1836
|
+
const sig = details.signal as string;
|
|
1837
|
+
const proc = details.process as BgProcessInfo;
|
|
1838
|
+
return new Text(
|
|
1839
|
+
theme.fg("warning", `${sig} `) + theme.fg("muted", `→ ${proc.id}`),
|
|
1840
|
+
0, 0,
|
|
1841
|
+
);
|
|
1842
|
+
}
|
|
1843
|
+
|
|
1844
|
+
case "list": {
|
|
1845
|
+
const procs = details.processes as BgProcessInfo[];
|
|
1846
|
+
if (procs.length === 0) {
|
|
1847
|
+
return new Text(theme.fg("dim", "No background processes"), 0, 0);
|
|
1848
|
+
}
|
|
1849
|
+
let text = theme.fg("muted", `${procs.length} background process(es)`);
|
|
1850
|
+
if (expanded) {
|
|
1851
|
+
for (const p of procs) {
|
|
1852
|
+
const statusIcon = p.alive
|
|
1853
|
+
? (p.status === "ready" ? theme.fg("success", "●") : p.status === "error" ? theme.fg("error", "●") : theme.fg("warning", "●"))
|
|
1854
|
+
: theme.fg("error", "○");
|
|
1855
|
+
const portInfo = p.ports.length > 0 ? ` :${p.ports.join(",")}` : "";
|
|
1856
|
+
text += `\n ${statusIcon} ${theme.fg("accent", p.id)} ${theme.fg("dim", p.uptime)} ${theme.fg("muted", p.label)} [${p.processType}]${portInfo}`;
|
|
1857
|
+
}
|
|
1858
|
+
}
|
|
1859
|
+
return new Text(text, 0, 0);
|
|
1860
|
+
}
|
|
1861
|
+
|
|
1862
|
+
case "kill": {
|
|
1863
|
+
const proc = details.process as BgProcessInfo;
|
|
1864
|
+
return new Text(
|
|
1865
|
+
theme.fg("success", "✓ Killed ") + theme.fg("accent", proc.id) + " " + theme.fg("muted", proc.label),
|
|
1866
|
+
0, 0,
|
|
1867
|
+
);
|
|
1868
|
+
}
|
|
1869
|
+
|
|
1870
|
+
case "restart": {
|
|
1871
|
+
const proc = details.process as BgProcessInfo;
|
|
1872
|
+
return new Text(
|
|
1873
|
+
theme.fg("success", "↻ Restarted ") + theme.fg("accent", proc.id) + " " + theme.fg("muted", proc.label) + " " + theme.fg("dim", `#${proc.restartCount}`),
|
|
1874
|
+
0, 0,
|
|
1875
|
+
);
|
|
1876
|
+
}
|
|
1877
|
+
|
|
1878
|
+
case "group_status": {
|
|
1879
|
+
const gs = details.groupStatus as ReturnType<typeof getGroupStatus> | undefined;
|
|
1880
|
+
if (gs) {
|
|
1881
|
+
const icon = gs.healthy ? theme.fg("success", "✓") : theme.fg("error", "✗");
|
|
1882
|
+
return new Text(
|
|
1883
|
+
`${icon} ${theme.fg("accent", gs.group)} — ${gs.processes.length} process(es)`,
|
|
1884
|
+
0, 0,
|
|
1885
|
+
);
|
|
1886
|
+
}
|
|
1887
|
+
const groups = details.groups as string[];
|
|
1888
|
+
return new Text(theme.fg("dim", `${groups?.length ?? 0} group(s)`), 0, 0);
|
|
1889
|
+
}
|
|
1890
|
+
|
|
1891
|
+
default: {
|
|
1892
|
+
const text = result.content[0];
|
|
1893
|
+
return new Text(text?.type === "text" ? text.text : "", 0, 0);
|
|
1894
|
+
}
|
|
1895
|
+
}
|
|
1896
|
+
},
|
|
1897
|
+
});
|
|
1898
|
+
|
|
1899
|
+
// ── Slash command: /bg ────────────────────────────────────────────────
|
|
1900
|
+
|
|
1901
|
+
pi.registerCommand("bg", {
|
|
1902
|
+
description: "Manage background processes: /bg [list|output|kill|killall|groups] [id]",
|
|
1903
|
+
|
|
1904
|
+
getArgumentCompletions: (prefix: string) => {
|
|
1905
|
+
const subcommands = ["list", "output", "kill", "killall", "groups", "digest"];
|
|
1906
|
+
const parts = prefix.trim().split(/\s+/);
|
|
1907
|
+
|
|
1908
|
+
if (parts.length <= 1) {
|
|
1909
|
+
return subcommands
|
|
1910
|
+
.filter(cmd => cmd.startsWith(parts[0] ?? ""))
|
|
1911
|
+
.map(cmd => ({ value: cmd, label: cmd }));
|
|
1912
|
+
}
|
|
1913
|
+
|
|
1914
|
+
if (parts[0] === "output" || parts[0] === "kill" || parts[0] === "digest") {
|
|
1915
|
+
const idPrefix = parts[1] ?? "";
|
|
1916
|
+
return Array.from(processes.values())
|
|
1917
|
+
.filter(p => p.id.startsWith(idPrefix))
|
|
1918
|
+
.map(p => ({
|
|
1919
|
+
value: `${parts[0]} ${p.id}`,
|
|
1920
|
+
label: `${p.id} — ${p.label}`,
|
|
1921
|
+
}));
|
|
1922
|
+
}
|
|
1923
|
+
|
|
1924
|
+
return [];
|
|
1925
|
+
},
|
|
1926
|
+
|
|
1927
|
+
handler: async (args, ctx) => {
|
|
1928
|
+
const parts = args.trim().split(/\s+/);
|
|
1929
|
+
const sub = parts[0] || "list";
|
|
1930
|
+
|
|
1931
|
+
if (sub === "list" || sub === "") {
|
|
1932
|
+
if (processes.size === 0) {
|
|
1933
|
+
ctx.ui.notify("No background processes.", "info");
|
|
1934
|
+
return;
|
|
1935
|
+
}
|
|
1936
|
+
|
|
1937
|
+
if (!ctx.hasUI) {
|
|
1938
|
+
const lines = Array.from(processes.values()).map(p => {
|
|
1939
|
+
const statusIcon = p.alive
|
|
1940
|
+
? (p.status === "ready" ? "✓" : p.status === "error" ? "✗" : "⋯")
|
|
1941
|
+
: "○";
|
|
1942
|
+
const uptime = formatUptime(Date.now() - p.startedAt);
|
|
1943
|
+
const portInfo = p.ports.length > 0 ? ` :${p.ports.join(",")}` : "";
|
|
1944
|
+
return `${p.id} ${statusIcon} ${p.status} ${uptime} ${p.label} [${p.processType}]${portInfo}`;
|
|
1945
|
+
});
|
|
1946
|
+
ctx.ui.notify(lines.join("\n"), "info");
|
|
1947
|
+
return;
|
|
1948
|
+
}
|
|
1949
|
+
|
|
1950
|
+
await ctx.ui.custom<void>(
|
|
1951
|
+
(tui, theme, _kb, done) => {
|
|
1952
|
+
return new BgManagerOverlay(tui, theme, () => {
|
|
1953
|
+
done();
|
|
1954
|
+
refreshWidget();
|
|
1955
|
+
});
|
|
1956
|
+
},
|
|
1957
|
+
{
|
|
1958
|
+
overlay: true,
|
|
1959
|
+
overlayOptions: {
|
|
1960
|
+
width: "60%",
|
|
1961
|
+
minWidth: 50,
|
|
1962
|
+
maxHeight: "70%",
|
|
1963
|
+
anchor: "center",
|
|
1964
|
+
},
|
|
1965
|
+
},
|
|
1966
|
+
);
|
|
1967
|
+
return;
|
|
1968
|
+
}
|
|
1969
|
+
|
|
1970
|
+
if (sub === "output" || sub === "digest") {
|
|
1971
|
+
const id = parts[1];
|
|
1972
|
+
if (!id) {
|
|
1973
|
+
ctx.ui.notify(`Usage: /bg ${sub} <id>`, "error");
|
|
1974
|
+
return;
|
|
1975
|
+
}
|
|
1976
|
+
const bg = processes.get(id);
|
|
1977
|
+
if (!bg) {
|
|
1978
|
+
ctx.ui.notify(`No process with id '${id}'`, "error");
|
|
1979
|
+
return;
|
|
1980
|
+
}
|
|
1981
|
+
|
|
1982
|
+
if (!ctx.hasUI) {
|
|
1983
|
+
if (sub === "digest") {
|
|
1984
|
+
const digest = generateDigest(bg);
|
|
1985
|
+
ctx.ui.notify(formatDigestText(bg, digest), "info");
|
|
1986
|
+
} else {
|
|
1987
|
+
const output = getOutput(bg, { stream: "both", tail: 50 });
|
|
1988
|
+
ctx.ui.notify(output || "(no output)", "info");
|
|
1989
|
+
}
|
|
1990
|
+
return;
|
|
1991
|
+
}
|
|
1992
|
+
|
|
1993
|
+
await ctx.ui.custom<void>(
|
|
1994
|
+
(tui, theme, _kb, done) => {
|
|
1995
|
+
const overlay = new BgManagerOverlay(tui, theme, () => {
|
|
1996
|
+
done();
|
|
1997
|
+
refreshWidget();
|
|
1998
|
+
});
|
|
1999
|
+
const procs = Array.from(processes.values());
|
|
2000
|
+
const idx = procs.findIndex(p => p.id === id);
|
|
2001
|
+
if (idx >= 0) overlay.selectAndView(idx);
|
|
2002
|
+
return overlay;
|
|
2003
|
+
},
|
|
2004
|
+
{
|
|
2005
|
+
overlay: true,
|
|
2006
|
+
overlayOptions: {
|
|
2007
|
+
width: "60%",
|
|
2008
|
+
minWidth: 50,
|
|
2009
|
+
maxHeight: "70%",
|
|
2010
|
+
anchor: "center",
|
|
2011
|
+
},
|
|
2012
|
+
},
|
|
2013
|
+
);
|
|
2014
|
+
return;
|
|
2015
|
+
}
|
|
2016
|
+
|
|
2017
|
+
if (sub === "kill") {
|
|
2018
|
+
const id = parts[1];
|
|
2019
|
+
if (!id) {
|
|
2020
|
+
ctx.ui.notify("Usage: /bg kill <id>", "error");
|
|
2021
|
+
return;
|
|
2022
|
+
}
|
|
2023
|
+
const bg = processes.get(id);
|
|
2024
|
+
if (!bg) {
|
|
2025
|
+
ctx.ui.notify(`No process with id '${id}'`, "error");
|
|
2026
|
+
return;
|
|
2027
|
+
}
|
|
2028
|
+
killProcess(id, "SIGTERM");
|
|
2029
|
+
await new Promise(r => setTimeout(r, 300));
|
|
2030
|
+
if (bg.alive) {
|
|
2031
|
+
killProcess(id, "SIGKILL");
|
|
2032
|
+
await new Promise(r => setTimeout(r, 200));
|
|
2033
|
+
}
|
|
2034
|
+
if (!bg.alive) processes.delete(id);
|
|
2035
|
+
ctx.ui.notify(`Killed process ${id} (${bg.label})`, "info");
|
|
2036
|
+
return;
|
|
2037
|
+
}
|
|
2038
|
+
|
|
2039
|
+
if (sub === "killall") {
|
|
2040
|
+
const count = processes.size;
|
|
2041
|
+
cleanupAll();
|
|
2042
|
+
ctx.ui.notify(`Killed ${count} background process(es)`, "info");
|
|
2043
|
+
return;
|
|
2044
|
+
}
|
|
2045
|
+
|
|
2046
|
+
if (sub === "groups") {
|
|
2047
|
+
const groups = new Set<string>();
|
|
2048
|
+
for (const p of processes.values()) {
|
|
2049
|
+
if (p.group) groups.add(p.group);
|
|
2050
|
+
}
|
|
2051
|
+
if (groups.size === 0) {
|
|
2052
|
+
ctx.ui.notify("No process groups defined.", "info");
|
|
2053
|
+
return;
|
|
2054
|
+
}
|
|
2055
|
+
const lines = Array.from(groups).map(g => {
|
|
2056
|
+
const gs = getGroupStatus(g);
|
|
2057
|
+
const icon = gs.healthy ? "✓" : "✗";
|
|
2058
|
+
const procs = gs.processes.map(p => `${p.id}(${p.status})`).join(", ");
|
|
2059
|
+
return `${icon} ${g}: ${procs}`;
|
|
2060
|
+
});
|
|
2061
|
+
ctx.ui.notify(lines.join("\n"), "info");
|
|
2062
|
+
return;
|
|
2063
|
+
}
|
|
2064
|
+
|
|
2065
|
+
ctx.ui.notify("Usage: /bg [list|output|digest|kill|killall|groups] [id]", "info");
|
|
2066
|
+
},
|
|
2067
|
+
});
|
|
2068
|
+
|
|
2069
|
+
// ── Live Footer ──────────────────────────────────────────────────────
|
|
2070
|
+
|
|
2071
|
+
/** Whether we currently own the footer via setFooter */
|
|
2072
|
+
let footerActive = false;
|
|
2073
|
+
|
|
2074
|
+
function buildBgStatusText(th: Theme): string {
|
|
2075
|
+
const alive = Array.from(processes.values()).filter(p => p.alive);
|
|
2076
|
+
if (alive.length === 0) return "";
|
|
2077
|
+
|
|
2078
|
+
const sep = th.fg("dim", " · ");
|
|
2079
|
+
const items: string[] = [];
|
|
2080
|
+
for (const p of alive) {
|
|
2081
|
+
const statusIcon = p.status === "ready" ? th.fg("success", "●")
|
|
2082
|
+
: p.status === "error" ? th.fg("error", "●")
|
|
2083
|
+
: th.fg("warning", "●");
|
|
2084
|
+
const name = p.label.length > 14 ? p.label.slice(0, 12) + "…" : p.label;
|
|
2085
|
+
const portInfo = p.ports.length > 0 ? th.fg("dim", `:${p.ports[0]}`) : "";
|
|
2086
|
+
const errBadge = p.recentErrors.length > 0
|
|
2087
|
+
? th.fg("error", ` err:${p.recentErrors.length}`)
|
|
2088
|
+
: "";
|
|
2089
|
+
items.push(`${statusIcon} ${th.fg("muted", name)}${portInfo}${errBadge}`);
|
|
2090
|
+
}
|
|
2091
|
+
return items.join(sep);
|
|
2092
|
+
}
|
|
2093
|
+
|
|
2094
|
+
function formatTokenCount(count: number): string {
|
|
2095
|
+
if (count < 1000) return count.toString();
|
|
2096
|
+
if (count < 10000) return `${(count / 1000).toFixed(1)}k`;
|
|
2097
|
+
if (count < 1000000) return `${Math.round(count / 1000)}k`;
|
|
2098
|
+
if (count < 10000000) return `${(count / 1000000).toFixed(1)}M`;
|
|
2099
|
+
return `${Math.round(count / 1000000)}M`;
|
|
2100
|
+
}
|
|
2101
|
+
|
|
2102
|
+
/** Reference to tui for triggering re-renders when footer is active */
|
|
2103
|
+
let footerTui: { requestRender: () => void } | null = null;
|
|
2104
|
+
|
|
2105
|
+
function refreshWidget() {
|
|
2106
|
+
if (!latestCtx?.hasUI) return;
|
|
2107
|
+
const alive = Array.from(processes.values()).filter(p => p.alive);
|
|
2108
|
+
|
|
2109
|
+
if (alive.length === 0) {
|
|
2110
|
+
if (footerActive) {
|
|
2111
|
+
latestCtx.ui.setFooter(undefined);
|
|
2112
|
+
footerActive = false;
|
|
2113
|
+
footerTui = null;
|
|
2114
|
+
}
|
|
2115
|
+
return;
|
|
2116
|
+
}
|
|
2117
|
+
|
|
2118
|
+
if (footerActive) {
|
|
2119
|
+
// Footer already installed — just trigger a re-render
|
|
2120
|
+
footerTui?.requestRender();
|
|
2121
|
+
return;
|
|
2122
|
+
}
|
|
2123
|
+
|
|
2124
|
+
// Install custom footer that puts bg process info right-aligned on line 1
|
|
2125
|
+
footerActive = true;
|
|
2126
|
+
latestCtx.ui.setFooter((tui, th, footerData) => {
|
|
2127
|
+
footerTui = tui;
|
|
2128
|
+
const branchUnsub = footerData.onBranchChange(() => tui.requestRender());
|
|
2129
|
+
|
|
2130
|
+
return {
|
|
2131
|
+
render(width: number): string[] {
|
|
2132
|
+
// ── Line 1: pwd (branch) [session] ... bg status ──
|
|
2133
|
+
let pwd = process.cwd();
|
|
2134
|
+
const home = process.env.HOME || process.env.USERPROFILE;
|
|
2135
|
+
if (home && pwd.startsWith(home)) {
|
|
2136
|
+
pwd = `~${pwd.slice(home.length)}`;
|
|
2137
|
+
}
|
|
2138
|
+
const branch = footerData.getGitBranch();
|
|
2139
|
+
if (branch) pwd = `${pwd} (${branch})`;
|
|
2140
|
+
|
|
2141
|
+
const sessionName = latestCtx?.sessionManager?.getSessionName?.();
|
|
2142
|
+
if (sessionName) pwd = `${pwd} • ${sessionName}`;
|
|
2143
|
+
|
|
2144
|
+
const bgStatus = buildBgStatusText(th);
|
|
2145
|
+
const leftPwd = th.fg("dim", pwd);
|
|
2146
|
+
const leftWidth = visibleWidth(leftPwd);
|
|
2147
|
+
const rightWidth = visibleWidth(bgStatus);
|
|
2148
|
+
|
|
2149
|
+
let pwdLine: string;
|
|
2150
|
+
const minGap = 2;
|
|
2151
|
+
if (bgStatus && leftWidth + minGap + rightWidth <= width) {
|
|
2152
|
+
const pad = " ".repeat(width - leftWidth - rightWidth);
|
|
2153
|
+
pwdLine = leftPwd + pad + bgStatus;
|
|
2154
|
+
} else if (bgStatus) {
|
|
2155
|
+
// Truncate pwd to make room for bg status
|
|
2156
|
+
const availForPwd = width - rightWidth - minGap;
|
|
2157
|
+
if (availForPwd > 10) {
|
|
2158
|
+
const truncPwd = truncateToWidth(leftPwd, availForPwd, th.fg("dim", "…"));
|
|
2159
|
+
const truncWidth = visibleWidth(truncPwd);
|
|
2160
|
+
const pad = " ".repeat(Math.max(0, width - truncWidth - rightWidth));
|
|
2161
|
+
pwdLine = truncPwd + pad + bgStatus;
|
|
2162
|
+
} else {
|
|
2163
|
+
pwdLine = truncateToWidth(leftPwd, width, th.fg("dim", "…"));
|
|
2164
|
+
}
|
|
2165
|
+
} else {
|
|
2166
|
+
pwdLine = truncateToWidth(leftPwd, width, th.fg("dim", "…"));
|
|
2167
|
+
}
|
|
2168
|
+
|
|
2169
|
+
// ── Line 2: token stats (left) ... model (right) ──
|
|
2170
|
+
const ctx = latestCtx;
|
|
2171
|
+
const sm = ctx?.sessionManager;
|
|
2172
|
+
let totalInput = 0, totalOutput = 0;
|
|
2173
|
+
let totalCacheRead = 0, totalCacheWrite = 0, totalCost = 0;
|
|
2174
|
+
if (sm) {
|
|
2175
|
+
for (const entry of sm.getEntries()) {
|
|
2176
|
+
if (entry.type === "message" && (entry as any).message?.role === "assistant") {
|
|
2177
|
+
const u = (entry as any).message.usage;
|
|
2178
|
+
if (u) {
|
|
2179
|
+
totalInput += u.input || 0;
|
|
2180
|
+
totalOutput += u.output || 0;
|
|
2181
|
+
totalCacheRead += u.cacheRead || 0;
|
|
2182
|
+
totalCacheWrite += u.cacheWrite || 0;
|
|
2183
|
+
totalCost += u.cost?.total || 0;
|
|
2184
|
+
}
|
|
2185
|
+
}
|
|
2186
|
+
}
|
|
2187
|
+
}
|
|
2188
|
+
|
|
2189
|
+
const contextUsage = ctx?.getContextUsage?.();
|
|
2190
|
+
const contextWindow = contextUsage?.contextWindow ?? ctx?.model?.contextWindow ?? 0;
|
|
2191
|
+
const contextPercentValue = contextUsage?.percent ?? 0;
|
|
2192
|
+
const contextPercent = contextUsage?.percent !== null ? (contextPercentValue).toFixed(1) : "?";
|
|
2193
|
+
|
|
2194
|
+
const statsParts: string[] = [];
|
|
2195
|
+
if (totalInput) statsParts.push(`↑${formatTokenCount(totalInput)}`);
|
|
2196
|
+
if (totalOutput) statsParts.push(`↓${formatTokenCount(totalOutput)}`);
|
|
2197
|
+
if (totalCacheRead) statsParts.push(`R${formatTokenCount(totalCacheRead)}`);
|
|
2198
|
+
if (totalCacheWrite) statsParts.push(`W${formatTokenCount(totalCacheWrite)}`);
|
|
2199
|
+
if (totalCost) statsParts.push(`$${totalCost.toFixed(3)}`);
|
|
2200
|
+
|
|
2201
|
+
const contextDisplay = contextPercent === "?"
|
|
2202
|
+
? `?/${formatTokenCount(contextWindow)}`
|
|
2203
|
+
: `${contextPercent}%/${formatTokenCount(contextWindow)}`;
|
|
2204
|
+
let contextStr: string;
|
|
2205
|
+
if (contextPercentValue > 90) {
|
|
2206
|
+
contextStr = th.fg("error", contextDisplay);
|
|
2207
|
+
} else if (contextPercentValue > 70) {
|
|
2208
|
+
contextStr = th.fg("warning", contextDisplay);
|
|
2209
|
+
} else {
|
|
2210
|
+
contextStr = contextDisplay;
|
|
2211
|
+
}
|
|
2212
|
+
statsParts.push(contextStr);
|
|
2213
|
+
|
|
2214
|
+
let statsLeft = statsParts.join(" ");
|
|
2215
|
+
let statsLeftWidth = visibleWidth(statsLeft);
|
|
2216
|
+
if (statsLeftWidth > width) {
|
|
2217
|
+
statsLeft = truncateToWidth(statsLeft, width, "...");
|
|
2218
|
+
statsLeftWidth = visibleWidth(statsLeft);
|
|
2219
|
+
}
|
|
2220
|
+
|
|
2221
|
+
const modelName = ctx?.model?.id || "no-model";
|
|
2222
|
+
let rightSide = modelName;
|
|
2223
|
+
if (ctx?.model?.reasoning) {
|
|
2224
|
+
const thinkingLevel = (ctx as any).getThinkingLevel?.() || "off";
|
|
2225
|
+
rightSide = thinkingLevel === "off" ? `${modelName} • thinking off` : `${modelName} • ${thinkingLevel}`;
|
|
2226
|
+
}
|
|
2227
|
+
if (footerData.getAvailableProviderCount() > 1 && ctx?.model) {
|
|
2228
|
+
const withProvider = `(${ctx.model.provider}) ${rightSide}`;
|
|
2229
|
+
if (statsLeftWidth + 2 + visibleWidth(withProvider) <= width) {
|
|
2230
|
+
rightSide = withProvider;
|
|
2231
|
+
}
|
|
2232
|
+
}
|
|
2233
|
+
|
|
2234
|
+
const rightSideWidth = visibleWidth(rightSide);
|
|
2235
|
+
let statsLine: string;
|
|
2236
|
+
if (statsLeftWidth + 2 + rightSideWidth <= width) {
|
|
2237
|
+
const pad = " ".repeat(width - statsLeftWidth - rightSideWidth);
|
|
2238
|
+
statsLine = statsLeft + pad + rightSide;
|
|
2239
|
+
} else {
|
|
2240
|
+
const avail = width - statsLeftWidth - 2;
|
|
2241
|
+
if (avail > 0) {
|
|
2242
|
+
const truncRight = truncateToWidth(rightSide, avail, "");
|
|
2243
|
+
const truncRightWidth = visibleWidth(truncRight);
|
|
2244
|
+
const pad = " ".repeat(Math.max(0, width - statsLeftWidth - truncRightWidth));
|
|
2245
|
+
statsLine = statsLeft + pad + truncRight;
|
|
2246
|
+
} else {
|
|
2247
|
+
statsLine = statsLeft;
|
|
2248
|
+
}
|
|
2249
|
+
}
|
|
2250
|
+
|
|
2251
|
+
const dimStatsLeft = th.fg("dim", statsLeft);
|
|
2252
|
+
const remainder = statsLine.slice(statsLeft.length);
|
|
2253
|
+
const dimRemainder = th.fg("dim", remainder);
|
|
2254
|
+
|
|
2255
|
+
const lines = [pwdLine, dimStatsLeft + dimRemainder];
|
|
2256
|
+
|
|
2257
|
+
// ── Line 3 (optional): other extension statuses ──
|
|
2258
|
+
const extensionStatuses = footerData.getExtensionStatuses();
|
|
2259
|
+
// Filter out our own bg-shell status since it's already on line 1
|
|
2260
|
+
const otherStatuses = Array.from(extensionStatuses.entries())
|
|
2261
|
+
.filter(([key]) => key !== "bg-shell")
|
|
2262
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
2263
|
+
.map(([, text]) => text.replace(/[\r\n\t]/g, " ").replace(/ +/g, " ").trim());
|
|
2264
|
+
if (otherStatuses.length > 0) {
|
|
2265
|
+
lines.push(truncateToWidth(otherStatuses.join(" "), width, th.fg("dim", "...")));
|
|
2266
|
+
}
|
|
2267
|
+
|
|
2268
|
+
return lines;
|
|
2269
|
+
},
|
|
2270
|
+
invalidate() {},
|
|
2271
|
+
dispose() {
|
|
2272
|
+
branchUnsub();
|
|
2273
|
+
footerTui = null;
|
|
2274
|
+
},
|
|
2275
|
+
};
|
|
2276
|
+
});
|
|
2277
|
+
}
|
|
2278
|
+
|
|
2279
|
+
// Periodic maintenance
|
|
2280
|
+
const maintenanceInterval = setInterval(() => {
|
|
2281
|
+
pruneDeadProcesses();
|
|
2282
|
+
refreshWidget();
|
|
2283
|
+
// Persist manifest periodically
|
|
2284
|
+
if (latestCtx) {
|
|
2285
|
+
persistManifest(latestCtx.cwd);
|
|
2286
|
+
}
|
|
2287
|
+
}, 2000);
|
|
2288
|
+
|
|
2289
|
+
// Refresh widget after agent actions and session events
|
|
2290
|
+
for (const event of [
|
|
2291
|
+
"turn_end",
|
|
2292
|
+
"agent_end",
|
|
2293
|
+
"session_start",
|
|
2294
|
+
"session_switch",
|
|
2295
|
+
] as const) {
|
|
2296
|
+
pi.on(event, async (_event: unknown, ctx: ExtensionContext) => {
|
|
2297
|
+
latestCtx = ctx;
|
|
2298
|
+
refreshWidget();
|
|
2299
|
+
});
|
|
2300
|
+
}
|
|
2301
|
+
|
|
2302
|
+
pi.on("tool_execution_end", async (_event, ctx) => {
|
|
2303
|
+
latestCtx = ctx;
|
|
2304
|
+
refreshWidget();
|
|
2305
|
+
});
|
|
2306
|
+
|
|
2307
|
+
// ── Ctrl+Alt+B shortcut ──────────────────────────────────────────────
|
|
2308
|
+
|
|
2309
|
+
pi.registerShortcut(Key.ctrlAlt("b"), {
|
|
2310
|
+
description: "Open background process manager",
|
|
2311
|
+
handler: async (ctx) => {
|
|
2312
|
+
latestCtx = ctx;
|
|
2313
|
+
await ctx.ui.custom<void>(
|
|
2314
|
+
(tui, theme, _kb, done) => {
|
|
2315
|
+
return new BgManagerOverlay(tui, theme, () => {
|
|
2316
|
+
done();
|
|
2317
|
+
refreshWidget();
|
|
2318
|
+
});
|
|
2319
|
+
},
|
|
2320
|
+
{
|
|
2321
|
+
overlay: true,
|
|
2322
|
+
overlayOptions: {
|
|
2323
|
+
width: "60%",
|
|
2324
|
+
minWidth: 50,
|
|
2325
|
+
maxHeight: "70%",
|
|
2326
|
+
anchor: "center",
|
|
2327
|
+
},
|
|
2328
|
+
},
|
|
2329
|
+
);
|
|
2330
|
+
},
|
|
2331
|
+
});
|
|
2332
|
+
|
|
2333
|
+
// Clean up on shutdown
|
|
2334
|
+
pi.on("session_shutdown", async () => {
|
|
2335
|
+
clearInterval(maintenanceInterval);
|
|
2336
|
+
if (latestCtx) persistManifest(latestCtx.cwd);
|
|
2337
|
+
cleanupAll();
|
|
2338
|
+
});
|
|
2339
|
+
}
|
|
2340
|
+
|
|
2341
|
+
// ── TUI: Process Manager Overlay ───────────────────────────────────────────
|
|
2342
|
+
|
|
2343
|
+
class BgManagerOverlay {
|
|
2344
|
+
private tui: { requestRender: () => void };
|
|
2345
|
+
private theme: Theme;
|
|
2346
|
+
private onClose: () => void;
|
|
2347
|
+
private selected = 0;
|
|
2348
|
+
private mode: "list" | "output" | "events" = "list";
|
|
2349
|
+
private viewingProcess: BgProcess | null = null;
|
|
2350
|
+
private scrollOffset = 0;
|
|
2351
|
+
private cachedWidth?: number;
|
|
2352
|
+
private cachedLines?: string[];
|
|
2353
|
+
private refreshTimer: ReturnType<typeof setInterval>;
|
|
2354
|
+
|
|
2355
|
+
constructor(
|
|
2356
|
+
tui: { requestRender: () => void },
|
|
2357
|
+
theme: Theme,
|
|
2358
|
+
onClose: () => void,
|
|
2359
|
+
) {
|
|
2360
|
+
this.tui = tui;
|
|
2361
|
+
this.theme = theme;
|
|
2362
|
+
this.onClose = onClose;
|
|
2363
|
+
this.refreshTimer = setInterval(() => {
|
|
2364
|
+
this.invalidate();
|
|
2365
|
+
this.tui.requestRender();
|
|
2366
|
+
}, 1000);
|
|
2367
|
+
}
|
|
2368
|
+
|
|
2369
|
+
private getProcessList(): BgProcess[] {
|
|
2370
|
+
return Array.from(processes.values());
|
|
2371
|
+
}
|
|
2372
|
+
|
|
2373
|
+
selectAndView(index: number): void {
|
|
2374
|
+
const procs = this.getProcessList();
|
|
2375
|
+
if (index >= 0 && index < procs.length) {
|
|
2376
|
+
this.selected = index;
|
|
2377
|
+
this.viewingProcess = procs[index];
|
|
2378
|
+
this.mode = "output";
|
|
2379
|
+
this.scrollOffset = Math.max(0, procs[index].output.length - 20);
|
|
2380
|
+
}
|
|
2381
|
+
}
|
|
2382
|
+
|
|
2383
|
+
handleInput(data: string): void {
|
|
2384
|
+
if (this.mode === "output") {
|
|
2385
|
+
this.handleOutputInput(data);
|
|
2386
|
+
return;
|
|
2387
|
+
}
|
|
2388
|
+
if (this.mode === "events") {
|
|
2389
|
+
this.handleEventsInput(data);
|
|
2390
|
+
return;
|
|
2391
|
+
}
|
|
2392
|
+
this.handleListInput(data);
|
|
2393
|
+
}
|
|
2394
|
+
|
|
2395
|
+
private handleListInput(data: string): void {
|
|
2396
|
+
const procs = this.getProcessList();
|
|
2397
|
+
|
|
2398
|
+
if (matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl("c")) || matchesKey(data, Key.ctrlAlt("b"))) {
|
|
2399
|
+
clearInterval(this.refreshTimer);
|
|
2400
|
+
this.onClose();
|
|
2401
|
+
return;
|
|
2402
|
+
}
|
|
2403
|
+
|
|
2404
|
+
if (matchesKey(data, Key.up) || matchesKey(data, "k")) {
|
|
2405
|
+
if (this.selected > 0) {
|
|
2406
|
+
this.selected--;
|
|
2407
|
+
this.invalidate();
|
|
2408
|
+
this.tui.requestRender();
|
|
2409
|
+
}
|
|
2410
|
+
return;
|
|
2411
|
+
}
|
|
2412
|
+
|
|
2413
|
+
if (matchesKey(data, Key.down) || matchesKey(data, "j")) {
|
|
2414
|
+
if (this.selected < procs.length - 1) {
|
|
2415
|
+
this.selected++;
|
|
2416
|
+
this.invalidate();
|
|
2417
|
+
this.tui.requestRender();
|
|
2418
|
+
}
|
|
2419
|
+
return;
|
|
2420
|
+
}
|
|
2421
|
+
|
|
2422
|
+
if (matchesKey(data, Key.enter)) {
|
|
2423
|
+
const proc = procs[this.selected];
|
|
2424
|
+
if (proc) {
|
|
2425
|
+
this.viewingProcess = proc;
|
|
2426
|
+
this.mode = "output";
|
|
2427
|
+
this.scrollOffset = Math.max(0, proc.output.length - 20);
|
|
2428
|
+
this.invalidate();
|
|
2429
|
+
this.tui.requestRender();
|
|
2430
|
+
}
|
|
2431
|
+
return;
|
|
2432
|
+
}
|
|
2433
|
+
|
|
2434
|
+
// e = view events
|
|
2435
|
+
if (data === "e") {
|
|
2436
|
+
const proc = procs[this.selected];
|
|
2437
|
+
if (proc) {
|
|
2438
|
+
this.viewingProcess = proc;
|
|
2439
|
+
this.mode = "events";
|
|
2440
|
+
this.scrollOffset = Math.max(0, proc.events.length - 15);
|
|
2441
|
+
this.invalidate();
|
|
2442
|
+
this.tui.requestRender();
|
|
2443
|
+
}
|
|
2444
|
+
return;
|
|
2445
|
+
}
|
|
2446
|
+
|
|
2447
|
+
// r = restart
|
|
2448
|
+
if (data === "r") {
|
|
2449
|
+
const proc = procs[this.selected];
|
|
2450
|
+
if (proc) {
|
|
2451
|
+
restartProcess(proc.id).then(() => {
|
|
2452
|
+
this.invalidate();
|
|
2453
|
+
this.tui.requestRender();
|
|
2454
|
+
});
|
|
2455
|
+
}
|
|
2456
|
+
return;
|
|
2457
|
+
}
|
|
2458
|
+
|
|
2459
|
+
// x or d = kill selected
|
|
2460
|
+
if (data === "x" || data === "d") {
|
|
2461
|
+
const proc = procs[this.selected];
|
|
2462
|
+
if (proc && proc.alive) {
|
|
2463
|
+
killProcess(proc.id, "SIGTERM");
|
|
2464
|
+
setTimeout(() => {
|
|
2465
|
+
if (proc.alive) killProcess(proc.id, "SIGKILL");
|
|
2466
|
+
this.invalidate();
|
|
2467
|
+
this.tui.requestRender();
|
|
2468
|
+
}, 300);
|
|
2469
|
+
}
|
|
2470
|
+
return;
|
|
2471
|
+
}
|
|
2472
|
+
|
|
2473
|
+
// X or D = kill all
|
|
2474
|
+
if (data === "X" || data === "D") {
|
|
2475
|
+
cleanupAll();
|
|
2476
|
+
this.selected = 0;
|
|
2477
|
+
this.invalidate();
|
|
2478
|
+
this.tui.requestRender();
|
|
2479
|
+
return;
|
|
2480
|
+
}
|
|
2481
|
+
}
|
|
2482
|
+
|
|
2483
|
+
private handleOutputInput(data: string): void {
|
|
2484
|
+
if (matchesKey(data, Key.escape) || matchesKey(data, "q")) {
|
|
2485
|
+
this.mode = "list";
|
|
2486
|
+
this.viewingProcess = null;
|
|
2487
|
+
this.scrollOffset = 0;
|
|
2488
|
+
this.invalidate();
|
|
2489
|
+
this.tui.requestRender();
|
|
2490
|
+
return;
|
|
2491
|
+
}
|
|
2492
|
+
|
|
2493
|
+
// Tab to switch to events view
|
|
2494
|
+
if (matchesKey(data, Key.tab)) {
|
|
2495
|
+
this.mode = "events";
|
|
2496
|
+
if (this.viewingProcess) {
|
|
2497
|
+
this.scrollOffset = Math.max(0, this.viewingProcess.events.length - 15);
|
|
2498
|
+
}
|
|
2499
|
+
this.invalidate();
|
|
2500
|
+
this.tui.requestRender();
|
|
2501
|
+
return;
|
|
2502
|
+
}
|
|
2503
|
+
|
|
2504
|
+
if (matchesKey(data, Key.down) || matchesKey(data, "j")) {
|
|
2505
|
+
if (this.viewingProcess) {
|
|
2506
|
+
const total = this.viewingProcess.output.length;
|
|
2507
|
+
this.scrollOffset = Math.min(this.scrollOffset + 5, Math.max(0, total - 20));
|
|
2508
|
+
}
|
|
2509
|
+
this.invalidate();
|
|
2510
|
+
this.tui.requestRender();
|
|
2511
|
+
return;
|
|
2512
|
+
}
|
|
2513
|
+
|
|
2514
|
+
if (matchesKey(data, Key.up) || matchesKey(data, "k")) {
|
|
2515
|
+
this.scrollOffset = Math.max(0, this.scrollOffset - 5);
|
|
2516
|
+
this.invalidate();
|
|
2517
|
+
this.tui.requestRender();
|
|
2518
|
+
return;
|
|
2519
|
+
}
|
|
2520
|
+
|
|
2521
|
+
if (data === "G") {
|
|
2522
|
+
if (this.viewingProcess) {
|
|
2523
|
+
const total = this.viewingProcess.output.length;
|
|
2524
|
+
this.scrollOffset = Math.max(0, total - 20);
|
|
2525
|
+
}
|
|
2526
|
+
this.invalidate();
|
|
2527
|
+
this.tui.requestRender();
|
|
2528
|
+
return;
|
|
2529
|
+
}
|
|
2530
|
+
|
|
2531
|
+
if (data === "g") {
|
|
2532
|
+
this.scrollOffset = 0;
|
|
2533
|
+
this.invalidate();
|
|
2534
|
+
this.tui.requestRender();
|
|
2535
|
+
return;
|
|
2536
|
+
}
|
|
2537
|
+
}
|
|
2538
|
+
|
|
2539
|
+
private handleEventsInput(data: string): void {
|
|
2540
|
+
if (matchesKey(data, Key.escape) || matchesKey(data, "q")) {
|
|
2541
|
+
this.mode = "list";
|
|
2542
|
+
this.viewingProcess = null;
|
|
2543
|
+
this.scrollOffset = 0;
|
|
2544
|
+
this.invalidate();
|
|
2545
|
+
this.tui.requestRender();
|
|
2546
|
+
return;
|
|
2547
|
+
}
|
|
2548
|
+
|
|
2549
|
+
// Tab to switch back to output view
|
|
2550
|
+
if (matchesKey(data, Key.tab)) {
|
|
2551
|
+
this.mode = "output";
|
|
2552
|
+
if (this.viewingProcess) {
|
|
2553
|
+
this.scrollOffset = Math.max(0, this.viewingProcess.output.length - 20);
|
|
2554
|
+
}
|
|
2555
|
+
this.invalidate();
|
|
2556
|
+
this.tui.requestRender();
|
|
2557
|
+
return;
|
|
2558
|
+
}
|
|
2559
|
+
|
|
2560
|
+
if (matchesKey(data, Key.down) || matchesKey(data, "j")) {
|
|
2561
|
+
if (this.viewingProcess) {
|
|
2562
|
+
this.scrollOffset = Math.min(this.scrollOffset + 3, Math.max(0, this.viewingProcess.events.length - 10));
|
|
2563
|
+
}
|
|
2564
|
+
this.invalidate();
|
|
2565
|
+
this.tui.requestRender();
|
|
2566
|
+
return;
|
|
2567
|
+
}
|
|
2568
|
+
|
|
2569
|
+
if (matchesKey(data, Key.up) || matchesKey(data, "k")) {
|
|
2570
|
+
this.scrollOffset = Math.max(0, this.scrollOffset - 3);
|
|
2571
|
+
this.invalidate();
|
|
2572
|
+
this.tui.requestRender();
|
|
2573
|
+
return;
|
|
2574
|
+
}
|
|
2575
|
+
}
|
|
2576
|
+
|
|
2577
|
+
render(width: number): string[] {
|
|
2578
|
+
if (this.cachedLines && this.cachedWidth === width) {
|
|
2579
|
+
return this.cachedLines;
|
|
2580
|
+
}
|
|
2581
|
+
|
|
2582
|
+
let lines: string[];
|
|
2583
|
+
if (this.mode === "events") {
|
|
2584
|
+
lines = this.renderEvents(width);
|
|
2585
|
+
} else if (this.mode === "output") {
|
|
2586
|
+
lines = this.renderOutput(width);
|
|
2587
|
+
} else {
|
|
2588
|
+
lines = this.renderList(width);
|
|
2589
|
+
}
|
|
2590
|
+
|
|
2591
|
+
this.cachedWidth = width;
|
|
2592
|
+
this.cachedLines = lines;
|
|
2593
|
+
return lines;
|
|
2594
|
+
}
|
|
2595
|
+
|
|
2596
|
+
private box(inner: string[], width: number): string[] {
|
|
2597
|
+
const th = this.theme;
|
|
2598
|
+
const bdr = (s: string) => th.fg("borderMuted", s);
|
|
2599
|
+
const iw = width - 4;
|
|
2600
|
+
const lines: string[] = [];
|
|
2601
|
+
|
|
2602
|
+
lines.push(bdr("╭" + "─".repeat(width - 2) + "╮"));
|
|
2603
|
+
for (const line of inner) {
|
|
2604
|
+
const truncated = truncateToWidth(line, iw);
|
|
2605
|
+
const pad = Math.max(0, iw - visibleWidth(truncated));
|
|
2606
|
+
lines.push(bdr("│") + " " + truncated + " ".repeat(pad) + " " + bdr("│"));
|
|
2607
|
+
}
|
|
2608
|
+
lines.push(bdr("╰" + "─".repeat(width - 2) + "╯"));
|
|
2609
|
+
return lines;
|
|
2610
|
+
}
|
|
2611
|
+
|
|
2612
|
+
private renderList(width: number): string[] {
|
|
2613
|
+
const th = this.theme;
|
|
2614
|
+
const procs = this.getProcessList();
|
|
2615
|
+
const inner: string[] = [];
|
|
2616
|
+
|
|
2617
|
+
if (procs.length === 0) {
|
|
2618
|
+
inner.push(th.fg("dim", "No background processes."));
|
|
2619
|
+
inner.push("");
|
|
2620
|
+
inner.push(th.fg("dim", "esc close"));
|
|
2621
|
+
return this.box(inner, width);
|
|
2622
|
+
}
|
|
2623
|
+
|
|
2624
|
+
inner.push(th.fg("dim", "Background Processes"));
|
|
2625
|
+
inner.push("");
|
|
2626
|
+
|
|
2627
|
+
for (let i = 0; i < procs.length; i++) {
|
|
2628
|
+
const p = procs[i];
|
|
2629
|
+
const sel = i === this.selected;
|
|
2630
|
+
const pointer = sel ? th.fg("accent", "▸ ") : " ";
|
|
2631
|
+
|
|
2632
|
+
const statusIcon = p.alive
|
|
2633
|
+
? (p.status === "ready" ? th.fg("success", "●")
|
|
2634
|
+
: p.status === "error" ? th.fg("error", "●")
|
|
2635
|
+
: th.fg("warning", "●"))
|
|
2636
|
+
: th.fg("dim", "○");
|
|
2637
|
+
|
|
2638
|
+
const uptime = th.fg("dim", formatUptime(Date.now() - p.startedAt));
|
|
2639
|
+
const name = sel ? th.fg("text", p.label) : th.fg("muted", p.label);
|
|
2640
|
+
const typeTag = th.fg("dim", `[${p.processType}]`);
|
|
2641
|
+
const portInfo = p.ports.length > 0 ? th.fg("dim", ` :${p.ports.join(",")}`) : "";
|
|
2642
|
+
const errBadge = p.recentErrors.length > 0 ? th.fg("error", ` ⚠${p.recentErrors.length}`) : "";
|
|
2643
|
+
const groupTag = p.group ? th.fg("dim", ` {${p.group}}`) : "";
|
|
2644
|
+
const restartBadge = p.restartCount > 0 ? th.fg("warning", ` ↻${p.restartCount}`) : "";
|
|
2645
|
+
|
|
2646
|
+
const status = p.alive ? "" : " " + th.fg("dim", `exit ${p.exitCode}`);
|
|
2647
|
+
|
|
2648
|
+
inner.push(`${pointer}${statusIcon} ${name} ${typeTag} ${uptime}${portInfo}${errBadge}${groupTag}${restartBadge}${status}`);
|
|
2649
|
+
}
|
|
2650
|
+
|
|
2651
|
+
inner.push("");
|
|
2652
|
+
inner.push(th.fg("dim", "↑↓ select · enter output · e events · r restart · x kill · esc close"));
|
|
2653
|
+
|
|
2654
|
+
return this.box(inner, width);
|
|
2655
|
+
}
|
|
2656
|
+
|
|
2657
|
+
private renderOutput(width: number): string[] {
|
|
2658
|
+
const th = this.theme;
|
|
2659
|
+
const p = this.viewingProcess;
|
|
2660
|
+
if (!p) return [""];
|
|
2661
|
+
const inner: string[] = [];
|
|
2662
|
+
|
|
2663
|
+
const statusIcon = p.alive
|
|
2664
|
+
? (p.status === "ready" ? th.fg("success", "●")
|
|
2665
|
+
: p.status === "error" ? th.fg("error", "●")
|
|
2666
|
+
: th.fg("warning", "●"))
|
|
2667
|
+
: th.fg("dim", "○");
|
|
2668
|
+
const name = th.fg("muted", p.label);
|
|
2669
|
+
const uptime = th.fg("dim", formatUptime(Date.now() - p.startedAt));
|
|
2670
|
+
const typeTag = th.fg("dim", `[${p.processType}]`);
|
|
2671
|
+
const portInfo = p.ports.length > 0 ? th.fg("dim", ` :${p.ports.join(",")}`) : "";
|
|
2672
|
+
const tabIndicator = th.fg("accent", "[Output]") + " " + th.fg("dim", "Events");
|
|
2673
|
+
|
|
2674
|
+
inner.push(`${statusIcon} ${name} ${typeTag} ${uptime}${portInfo} ${tabIndicator}`);
|
|
2675
|
+
inner.push("");
|
|
2676
|
+
|
|
2677
|
+
// Unified buffer is already chronologically interleaved
|
|
2678
|
+
const allOutput = p.output;
|
|
2679
|
+
|
|
2680
|
+
const maxVisible = 18;
|
|
2681
|
+
const visible = allOutput.slice(this.scrollOffset, this.scrollOffset + maxVisible);
|
|
2682
|
+
|
|
2683
|
+
if (allOutput.length === 0) {
|
|
2684
|
+
inner.push(th.fg("dim", "(no output)"));
|
|
2685
|
+
} else {
|
|
2686
|
+
for (const entry of visible) {
|
|
2687
|
+
const isError = ERROR_PATTERNS.some(pat => pat.test(entry.line));
|
|
2688
|
+
const isWarning = !isError && WARNING_PATTERNS.some(pat => pat.test(entry.line));
|
|
2689
|
+
const prefix = entry.stream === "stderr" ? th.fg("error", "⚠ ") : "";
|
|
2690
|
+
const color = isError ? "error" : isWarning ? "warning" : "dim";
|
|
2691
|
+
inner.push(prefix + th.fg(color, entry.line));
|
|
2692
|
+
}
|
|
2693
|
+
|
|
2694
|
+
if (allOutput.length > maxVisible) {
|
|
2695
|
+
inner.push("");
|
|
2696
|
+
const pos = `${this.scrollOffset + 1}–${Math.min(this.scrollOffset + maxVisible, allOutput.length)} of ${allOutput.length}`;
|
|
2697
|
+
inner.push(th.fg("dim", pos));
|
|
2698
|
+
}
|
|
2699
|
+
}
|
|
2700
|
+
|
|
2701
|
+
inner.push("");
|
|
2702
|
+
inner.push(th.fg("dim", "↑↓ scroll · g/G top/end · tab events · q back"));
|
|
2703
|
+
|
|
2704
|
+
return this.box(inner, width);
|
|
2705
|
+
}
|
|
2706
|
+
|
|
2707
|
+
private renderEvents(width: number): string[] {
|
|
2708
|
+
const th = this.theme;
|
|
2709
|
+
const p = this.viewingProcess;
|
|
2710
|
+
if (!p) return [""];
|
|
2711
|
+
const inner: string[] = [];
|
|
2712
|
+
|
|
2713
|
+
const statusIcon = p.alive
|
|
2714
|
+
? (p.status === "ready" ? th.fg("success", "●")
|
|
2715
|
+
: p.status === "error" ? th.fg("error", "●")
|
|
2716
|
+
: th.fg("warning", "●"))
|
|
2717
|
+
: th.fg("dim", "○");
|
|
2718
|
+
const name = th.fg("muted", p.label);
|
|
2719
|
+
const uptime = th.fg("dim", formatUptime(Date.now() - p.startedAt));
|
|
2720
|
+
const tabIndicator = th.fg("dim", "Output") + " " + th.fg("accent", "[Events]");
|
|
2721
|
+
|
|
2722
|
+
inner.push(`${statusIcon} ${name} ${uptime} ${tabIndicator}`);
|
|
2723
|
+
inner.push("");
|
|
2724
|
+
|
|
2725
|
+
if (p.events.length === 0) {
|
|
2726
|
+
inner.push(th.fg("dim", "(no events)"));
|
|
2727
|
+
} else {
|
|
2728
|
+
const maxVisible = 15;
|
|
2729
|
+
const visible = p.events.slice(this.scrollOffset, this.scrollOffset + maxVisible);
|
|
2730
|
+
|
|
2731
|
+
for (const ev of visible) {
|
|
2732
|
+
const time = th.fg("dim", formatTimeAgo(ev.timestamp));
|
|
2733
|
+
const typeColor = ev.type === "crashed" || ev.type === "error_detected" ? "error"
|
|
2734
|
+
: ev.type === "ready" || ev.type === "recovered" ? "success"
|
|
2735
|
+
: ev.type === "port_open" ? "accent"
|
|
2736
|
+
: "dim";
|
|
2737
|
+
const typeLabel = th.fg(typeColor, ev.type);
|
|
2738
|
+
inner.push(`${time} ${typeLabel}`);
|
|
2739
|
+
inner.push(` ${th.fg("dim", ev.detail.slice(0, 80))}`);
|
|
2740
|
+
}
|
|
2741
|
+
|
|
2742
|
+
if (p.events.length > maxVisible) {
|
|
2743
|
+
inner.push("");
|
|
2744
|
+
inner.push(th.fg("dim", `${this.scrollOffset + 1}–${Math.min(this.scrollOffset + maxVisible, p.events.length)} of ${p.events.length} events`));
|
|
2745
|
+
}
|
|
2746
|
+
}
|
|
2747
|
+
|
|
2748
|
+
inner.push("");
|
|
2749
|
+
inner.push(th.fg("dim", "↑↓ scroll · tab output · q back"));
|
|
2750
|
+
|
|
2751
|
+
return this.box(inner, width);
|
|
2752
|
+
}
|
|
2753
|
+
|
|
2754
|
+
invalidate(): void {
|
|
2755
|
+
this.cachedWidth = undefined;
|
|
2756
|
+
this.cachedLines = undefined;
|
|
2757
|
+
}
|
|
2758
|
+
}
|