@aliou/pi-processes 0.9.4 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (171) hide show
  1. package/CONTRIBUTING.md +40 -42
  2. package/README.md +54 -84
  3. package/extensions/process-tabs.ts +26 -0
  4. package/extensions/processes/client.ts +194 -0
  5. package/extensions/processes/commands/clear.ts +29 -0
  6. package/extensions/processes/commands/kill.ts +152 -0
  7. package/extensions/processes/commands/overview.ts +102 -0
  8. package/extensions/processes/components/overview-component.ts +729 -0
  9. package/extensions/processes/components/overview-panel.ts +177 -0
  10. package/extensions/processes/config/defaults.ts +28 -0
  11. package/extensions/processes/config/index.ts +13 -0
  12. package/extensions/processes/config/loader.ts +62 -0
  13. package/extensions/processes/config/migrations/000-import-legacy-process-config.ts +113 -0
  14. package/extensions/processes/config/migrations/001-v0-9-4-to-v0-10-0-config.ts +184 -0
  15. package/extensions/processes/config/migrations/002-stamp-v0-10-0-config-version.ts +21 -0
  16. package/extensions/processes/config/migrations/index.ts +25 -0
  17. package/extensions/processes/config/schema.ts +9 -0
  18. package/extensions/processes/config/types.ts +72 -0
  19. package/extensions/processes/constants.ts +1 -0
  20. package/extensions/processes/handlers/commands.ts +113 -0
  21. package/extensions/processes/handlers/kill-process.ts +31 -0
  22. package/extensions/processes/handlers/notifications.ts +147 -0
  23. package/extensions/processes/handlers/requests.ts +114 -0
  24. package/extensions/processes/handlers/subscriptions.ts +144 -0
  25. package/extensions/processes/hooks/background-blocker.ts +201 -0
  26. package/extensions/processes/hooks/cleanup.ts +43 -0
  27. package/extensions/processes/hooks/event-bridge.ts +32 -0
  28. package/extensions/processes/i18n/index.ts +3 -0
  29. package/extensions/processes/i18n/messages.ts +27 -0
  30. package/extensions/processes/i18n/translator.ts +49 -0
  31. package/extensions/processes/index.ts +109 -0
  32. package/extensions/processes/message-renderer.ts +117 -0
  33. package/extensions/processes/notification-sender.ts +43 -0
  34. package/extensions/processes/notifications/classify.ts +27 -0
  35. package/extensions/processes/notifications/log-matchers.ts +146 -0
  36. package/extensions/processes/notifications/registry.ts +225 -0
  37. package/extensions/processes/notifications/render-content.ts +122 -0
  38. package/extensions/processes/notifications/service.ts +336 -0
  39. package/extensions/processes/notifications/types.ts +11 -0
  40. package/extensions/processes/settings/apply-setting-change.ts +30 -0
  41. package/extensions/processes/settings/build-sections.ts +420 -0
  42. package/extensions/processes/settings/index.ts +42 -0
  43. package/extensions/processes/tools/clear/index.ts +21 -0
  44. package/extensions/processes/tools/clear/render.ts +40 -0
  45. package/extensions/processes/tools/components/index.ts +4 -0
  46. package/extensions/processes/tools/components/process-action-header.ts +31 -0
  47. package/extensions/processes/tools/components/process-action-title.ts +17 -0
  48. package/extensions/processes/tools/components/tool-layout.ts +70 -0
  49. package/extensions/processes/tools/components/watch.ts +53 -0
  50. package/extensions/processes/tools/index.ts +265 -0
  51. package/extensions/processes/tools/list/index.ts +157 -0
  52. package/extensions/processes/tools/list/render.ts +211 -0
  53. package/extensions/processes/tools/notify.ts +191 -0
  54. package/extensions/processes/tools/output/index.ts +307 -0
  55. package/extensions/processes/tools/output/render.ts +229 -0
  56. package/extensions/processes/tools/schema.ts +263 -0
  57. package/extensions/processes/tools/start/index.ts +64 -0
  58. package/extensions/processes/tools/start/render.ts +67 -0
  59. package/extensions/processes/tools/stop/index.ts +35 -0
  60. package/extensions/processes/tools/stop/render.ts +43 -0
  61. package/extensions/processes/tools/update/index.ts +320 -0
  62. package/extensions/processes/tools/update/render.ts +251 -0
  63. package/extensions/processes/tools/utils.ts +112 -0
  64. package/extensions/processes/tools/watch-format.ts +14 -0
  65. package/extensions/processes/tools/write/index.ts +75 -0
  66. package/extensions/processes/tools/write/render.ts +68 -0
  67. package/extensions/processes/utils/truncate.ts +285 -0
  68. package/extensions/processes-dock/client.ts +60 -0
  69. package/extensions/processes-dock/commands/dock.ts +70 -0
  70. package/extensions/processes-dock/commands/pin.ts +166 -0
  71. package/extensions/processes-dock/components/log-dock-component.ts +375 -0
  72. package/extensions/processes-dock/dock-state.ts +33 -0
  73. package/extensions/processes-dock/index.ts +28 -0
  74. package/extensions/processes-dock/logs-client.ts +5 -0
  75. package/extensions/processes-dock/widget/setup.ts +520 -0
  76. package/extensions/processes-dock/widget/status.ts +149 -0
  77. package/extensions/processes-dock/widget/types.ts +16 -0
  78. package/extensions/processes-logs/client.ts +51 -0
  79. package/extensions/processes-logs/commands/logs.ts +108 -0
  80. package/extensions/processes-logs/completions.ts +31 -0
  81. package/extensions/processes-logs/components/log-file-viewer.ts +274 -0
  82. package/extensions/processes-logs/components/log-overlay-component.ts +685 -0
  83. package/extensions/processes-logs/index.ts +27 -0
  84. package/extensions/processes-logs/logs-client.ts +101 -0
  85. package/extensions/shared/line-buffer.ts +35 -0
  86. package/extensions/shared/output-payload.ts +28 -0
  87. package/extensions/shared/ui.ts +146 -0
  88. package/package.json +47 -20
  89. package/schema.json +134 -0
  90. package/skills/pi-processes/SKILL.md +496 -25
  91. package/src/get-manager.ts +15 -0
  92. package/src/manager/index.ts +182 -0
  93. package/src/manager/internal-types.ts +130 -0
  94. package/src/manager/limits.ts +15 -0
  95. package/src/manager/process-log-store.ts +422 -0
  96. package/src/manager/process-output.ts +345 -0
  97. package/src/manager/process-registry.ts +78 -0
  98. package/src/manager/process-runtime-controller.ts +499 -0
  99. package/src/protocol/broadcasts.ts +14 -0
  100. package/src/protocol/channels.ts +30 -0
  101. package/src/protocol/commands.ts +24 -0
  102. package/src/protocol/index.ts +34 -0
  103. package/src/protocol/logs.ts +23 -0
  104. package/src/protocol/notifications.ts +48 -0
  105. package/src/protocol/requests.ts +76 -0
  106. package/src/types.ts +66 -0
  107. package/src/utils/ansi.ts +2 -4
  108. package/src/utils/format.ts +24 -32
  109. package/src/utils/index.ts +5 -1
  110. package/src/utils/is-record.ts +3 -0
  111. package/src/utils/match-line.ts +32 -0
  112. package/src/utils/platform.ts +13 -0
  113. package/src/utils/shorten-path.ts +11 -0
  114. package/src/utils/signals.ts +22 -0
  115. package/src/commands/clear/command.ts +0 -14
  116. package/src/commands/clear/index.ts +0 -1
  117. package/src/commands/completions.ts +0 -38
  118. package/src/commands/dock/command.ts +0 -29
  119. package/src/commands/dock/index.ts +0 -1
  120. package/src/commands/index.ts +0 -33
  121. package/src/commands/kill/command.ts +0 -69
  122. package/src/commands/kill/index.ts +0 -1
  123. package/src/commands/logs/command.ts +0 -47
  124. package/src/commands/logs/index.ts +0 -1
  125. package/src/commands/pick-process.ts +0 -34
  126. package/src/commands/pin/command.ts +0 -34
  127. package/src/commands/pin/index.ts +0 -1
  128. package/src/commands/processes/command.ts +0 -39
  129. package/src/commands/processes/index.ts +0 -1
  130. package/src/commands/settings/apply-setting-change.ts +0 -72
  131. package/src/commands/settings/build-sections.ts +0 -160
  132. package/src/commands/settings/command.ts +0 -20
  133. package/src/commands/settings/index.ts +0 -1
  134. package/src/components/log-dock-component.test.ts +0 -29
  135. package/src/components/log-dock-component.ts +0 -236
  136. package/src/components/log-file-viewer.ts +0 -317
  137. package/src/components/log-overlay-component.ts +0 -546
  138. package/src/components/panel-helpers.ts +0 -37
  139. package/src/components/process-picker-component.ts +0 -166
  140. package/src/components/processes-component.ts +0 -485
  141. package/src/components/status-format.ts +0 -38
  142. package/src/config.ts +0 -107
  143. package/src/constants/index.ts +0 -16
  144. package/src/constants/types.ts +0 -106
  145. package/src/hooks/background-blocker.ts +0 -66
  146. package/src/hooks/cleanup.ts +0 -10
  147. package/src/hooks/index.ts +0 -32
  148. package/src/hooks/message-renderer.ts +0 -115
  149. package/src/hooks/process-end.ts +0 -69
  150. package/src/hooks/process-watch.ts +0 -85
  151. package/src/hooks/utils.ts +0 -23
  152. package/src/hooks/widget/index.ts +0 -2
  153. package/src/hooks/widget/setup.ts +0 -167
  154. package/src/hooks/widget/status-widget.ts +0 -105
  155. package/src/hooks/widget/types.ts +0 -20
  156. package/src/index.ts +0 -35
  157. package/src/manager.test.ts +0 -331
  158. package/src/manager.ts +0 -758
  159. package/src/tools/actions/clear.ts +0 -20
  160. package/src/tools/actions/debug.ts +0 -148
  161. package/src/tools/actions/index.ts +0 -171
  162. package/src/tools/actions/kill.ts +0 -89
  163. package/src/tools/actions/list.ts +0 -188
  164. package/src/tools/actions/logs.ts +0 -117
  165. package/src/tools/actions/output.ts +0 -269
  166. package/src/tools/actions/start.ts +0 -245
  167. package/src/tools/actions/write.ts +0 -113
  168. package/src/tools/index.ts +0 -207
  169. package/src/utils/ansi.test.ts +0 -22
  170. package/src/utils/command-executor.test.ts +0 -48
  171. package/src/utils/keybindings.ts +0 -71
@@ -1,167 +0,0 @@
1
- import type {
2
- ExtensionAPI,
3
- ExtensionContext,
4
- } from "@earendil-works/pi-coding-agent";
5
- import { LogDockComponent } from "../../components/log-dock-component";
6
- import { configLoader, type ResolvedProcessesConfig } from "../../config";
7
- import { LIVE_STATUSES } from "../../constants";
8
- import type { ProcessManager } from "../../manager";
9
- import { renderStatusWidget } from "./status-widget";
10
- import {
11
- type DockActions,
12
- type DockState,
13
- LOG_DOCK_WIDGET_ID,
14
- STATUS_WIDGET_ID,
15
- } from "./types";
16
-
17
- export function setupProcessWidget(
18
- pi: ExtensionAPI,
19
- manager: ProcessManager,
20
- config: ResolvedProcessesConfig,
21
- ) {
22
- let activeCtx: ExtensionContext | null = null;
23
- let logDockComponent: LogDockComponent | null = null;
24
- let logDockComponentTui: { requestRender(): void } | null = null;
25
-
26
- const dockState: DockState = {
27
- visibility: "hidden",
28
- followEnabled: config.follow.enabledByDefault,
29
- focusedProcessId: null,
30
- };
31
-
32
- function updateWidget() {
33
- if (!activeCtx?.hasUI) return;
34
-
35
- if (!configLoader.getConfig().widget.showStatusWidget) {
36
- activeCtx.ui.setWidget(STATUS_WIDGET_ID, undefined);
37
- } else {
38
- const processes = manager.list();
39
- const maxWidth = process.stdout.columns || 120;
40
- const lines = renderStatusWidget(processes, activeCtx.ui.theme, maxWidth);
41
-
42
- if (lines.length === 0) {
43
- activeCtx.ui.setWidget(STATUS_WIDGET_ID, undefined);
44
- } else {
45
- activeCtx.ui.setWidget(STATUS_WIDGET_ID, lines, {
46
- placement: "belowEditor",
47
- });
48
- }
49
- }
50
-
51
- if (dockState.visibility === "hidden") {
52
- activeCtx.ui.setWidget(LOG_DOCK_WIDGET_ID, undefined);
53
- if (logDockComponent) {
54
- logDockComponent.dispose();
55
- logDockComponent = null;
56
- logDockComponentTui = null;
57
- }
58
- return;
59
- }
60
-
61
- const mode = dockState.visibility as "collapsed" | "open";
62
- const height = mode === "collapsed" ? 3 : config.widget.dockHeight;
63
-
64
- if (logDockComponent && logDockComponentTui) {
65
- logDockComponent.update({
66
- mode,
67
- focusedProcessId: dockState.focusedProcessId,
68
- dockHeight: height,
69
- });
70
- } else {
71
- const ctx = activeCtx;
72
- ctx.ui.setWidget(
73
- LOG_DOCK_WIDGET_ID,
74
- (tui: { requestRender(): void }, theme: typeof ctx.ui.theme) => {
75
- logDockComponent = new LogDockComponent({
76
- manager,
77
- tui,
78
- theme,
79
- mode,
80
- focusedProcessId: dockState.focusedProcessId,
81
- dockHeight: height,
82
- });
83
- logDockComponentTui = tui;
84
- return logDockComponent;
85
- },
86
- { placement: "aboveEditor" },
87
- );
88
- }
89
- }
90
-
91
- const dockActions: DockActions = {
92
- getFocusedProcessId: () => dockState.focusedProcessId,
93
- isFollowEnabled: () => dockState.followEnabled,
94
- setFocus(id) {
95
- dockState.focusedProcessId = id;
96
- if (id && dockState.visibility === "hidden")
97
- dockState.visibility = "open";
98
- updateWidget();
99
- },
100
- expand() {
101
- dockState.visibility = "open";
102
- updateWidget();
103
- },
104
- collapse() {
105
- dockState.visibility = "collapsed";
106
- updateWidget();
107
- },
108
- hide() {
109
- dockState.visibility = "hidden";
110
- updateWidget();
111
- },
112
- toggle() {
113
- if (dockState.visibility === "hidden") dockState.visibility = "collapsed";
114
- else if (dockState.visibility === "collapsed")
115
- dockState.visibility = "open";
116
- else dockState.visibility = "collapsed";
117
- updateWidget();
118
- },
119
- };
120
-
121
- manager.onEvent((event) => {
122
- if (event.type === "process_started") {
123
- if (dockState.followEnabled && dockState.visibility === "hidden") {
124
- dockState.visibility = "collapsed";
125
- }
126
- }
127
-
128
- if (event.type === "process_ended") {
129
- if (dockState.focusedProcessId === event.info.id) {
130
- dockState.focusedProcessId = null;
131
- }
132
- const running = manager.list().filter((p) => LIVE_STATUSES.has(p.status));
133
- if (
134
- running.length === 0 &&
135
- config.follow.autoHideOnFinish &&
136
- dockState.followEnabled
137
- ) {
138
- dockState.visibility = "hidden";
139
- }
140
- }
141
-
142
- updateWidget();
143
- });
144
-
145
- pi.on("session_start", async (_event, ctx) => {
146
- if (logDockComponent) {
147
- logDockComponent.dispose();
148
- logDockComponent = null;
149
- logDockComponentTui = null;
150
- }
151
- activeCtx = ctx;
152
- updateWidget();
153
- });
154
-
155
- pi.on("session_shutdown", async (_event, ctx) => {
156
- activeCtx = null;
157
- if (logDockComponent) {
158
- logDockComponent.dispose();
159
- logDockComponent = null;
160
- logDockComponentTui = null;
161
- }
162
- ctx.ui.setWidget(STATUS_WIDGET_ID, undefined);
163
- ctx.ui.setWidget(LOG_DOCK_WIDGET_ID, undefined);
164
- });
165
-
166
- return { update: updateWidget, dockActions };
167
- }
@@ -1,105 +0,0 @@
1
- import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
- import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
3
- import type { ProcessInfo } from "../../constants";
4
-
5
- function formatProcessStatus(
6
- proc: ProcessInfo,
7
- theme: ExtensionContext["ui"]["theme"],
8
- ): string {
9
- const name =
10
- proc.name.length > 20 ? `${proc.name.slice(0, 17)}...` : proc.name;
11
-
12
- switch (proc.status) {
13
- case "running":
14
- return `${theme.fg("accent", name)} ${theme.fg("dim", "running")}`;
15
- case "terminating":
16
- return `${theme.fg("warning", name)} ${theme.fg("dim", "terminating")}`;
17
- case "terminate_timeout":
18
- return `${theme.fg("error", name)} ${theme.fg("error", "terminate_timeout")}`;
19
- case "killed":
20
- return `${theme.fg("warning", name)} ${theme.fg("dim", "killed")}`;
21
- case "exited":
22
- if (proc.success) {
23
- return `${theme.fg("dim", name)} ${theme.fg("success", "done")}`;
24
- }
25
- return `${theme.fg("error", name)} ${theme.fg("error", `exit(${proc.exitCode ?? "?"})`)}`;
26
- default:
27
- return `${theme.fg("dim", name)} ${theme.fg("dim", proc.status)}`;
28
- }
29
- }
30
-
31
- export function renderStatusWidget(
32
- processes: ProcessInfo[],
33
- theme: ExtensionContext["ui"]["theme"],
34
- maxWidth?: number,
35
- ): string[] {
36
- if (processes.length === 0) return [];
37
-
38
- const aliveish = processes.filter(
39
- (p) =>
40
- p.status === "running" ||
41
- p.status === "terminating" ||
42
- p.status === "terminate_timeout",
43
- );
44
- const finished = processes.filter(
45
- (p) =>
46
- p.status !== "running" &&
47
- p.status !== "terminating" &&
48
- p.status !== "terminate_timeout",
49
- );
50
-
51
- const allProcs: ProcessInfo[] = [
52
- ...aliveish,
53
- ...finished.sort((a, b) => (b.endTime ?? 0) - (a.endTime ?? 0)),
54
- ];
55
-
56
- const prefix = theme.fg("dim", "processes: ");
57
- const prefixLen = visibleWidth(prefix);
58
- const separator = theme.fg("dim", " | ");
59
- const separatorLen = visibleWidth(separator);
60
- const effectiveMax = maxWidth ?? 200;
61
-
62
- const parts: string[] = [];
63
- let currentLen = prefixLen;
64
- let includedCount = 0;
65
-
66
- for (const proc of allProcs) {
67
- const formatted = formatProcessStatus(proc, theme);
68
- const formattedLen = visibleWidth(formatted);
69
- const remaining = allProcs.length - includedCount - 1;
70
- const needed =
71
- includedCount > 0 ? separatorLen + formattedLen : formattedLen;
72
-
73
- let reservedForSuffix = 0;
74
- if (remaining > 0) {
75
- const suffixText = `+${remaining} more`;
76
- reservedForSuffix = separatorLen + visibleWidth(suffixText);
77
- }
78
-
79
- if (
80
- currentLen + needed + reservedForSuffix > effectiveMax &&
81
- includedCount > 0
82
- ) {
83
- const hiddenCount = allProcs.length - includedCount;
84
- if (hiddenCount > 0) parts.push(theme.fg("dim", `+${hiddenCount} more`));
85
- break;
86
- }
87
-
88
- parts.push(formatted);
89
- currentLen += needed;
90
- includedCount++;
91
- }
92
-
93
- if (includedCount === 0 && allProcs.length > 0) {
94
- parts.push(formatProcessStatus(allProcs[0], theme));
95
- }
96
-
97
- if (parts.length === 0) return [];
98
-
99
- const line = prefix + parts.join(separator);
100
- return [
101
- visibleWidth(line) > effectiveMax
102
- ? truncateToWidth(line, effectiveMax)
103
- : line,
104
- ];
105
- }
@@ -1,20 +0,0 @@
1
- export type DockVisibility = "hidden" | "collapsed" | "open";
2
-
3
- export interface DockState {
4
- visibility: DockVisibility;
5
- followEnabled: boolean;
6
- focusedProcessId: string | null;
7
- }
8
-
9
- export interface DockActions {
10
- getFocusedProcessId(): string | null;
11
- isFollowEnabled(): boolean;
12
- setFocus(id: string | null): void;
13
- expand(): void;
14
- collapse(): void;
15
- hide(): void;
16
- toggle(): void;
17
- }
18
-
19
- export const STATUS_WIDGET_ID = "processes-status";
20
- export const LOG_DOCK_WIDGET_ID = "processes-dock";
package/src/index.ts DELETED
@@ -1,35 +0,0 @@
1
- import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
- import { setupProcessesCommands } from "./commands";
3
- import { registerProcessesSettings } from "./commands/settings";
4
- import { configLoader } from "./config";
5
- import { setupProcessesHooks } from "./hooks";
6
- import { ProcessManager } from "./manager";
7
- import { setupProcessesTools } from "./tools";
8
-
9
- export default async function (pi: ExtensionAPI) {
10
- if (process.platform === "win32") {
11
- pi.on("session_start", async (_event, ctx) => {
12
- if (!ctx.hasUI) return;
13
- ctx.ui.notify("processes extension not available on Windows", "warning");
14
- });
15
- return;
16
- }
17
-
18
- await configLoader.load();
19
- const manager = new ProcessManager({
20
- getConfiguredShellPath: () => configLoader.getConfig().execution.shellPath,
21
- });
22
-
23
- const config = configLoader.getConfig();
24
-
25
- const { update: updateWidget, dockActions } = setupProcessesHooks(
26
- pi,
27
- manager,
28
- config,
29
- );
30
- setupProcessesCommands(pi, manager, dockActions);
31
- setupProcessesTools(pi, manager);
32
- registerProcessesSettings(pi, () => {
33
- updateWidget();
34
- });
35
- }
@@ -1,331 +0,0 @@
1
- import { afterEach, describe, expect, it } from "vitest";
2
- import type { ManagerEvent } from "./constants";
3
- import { ProcessManager } from "./manager";
4
-
5
- function waitForEnd(manager: ProcessManager, id: string): Promise<void> {
6
- return new Promise((resolve) => {
7
- const unsub = manager.onEvent((e) => {
8
- if (e.type === "process_ended" && e.info.id === id) {
9
- unsub();
10
- resolve();
11
- }
12
- });
13
- });
14
- }
15
-
16
- function collectEvents(manager: ProcessManager): ManagerEvent[] {
17
- const events: ManagerEvent[] = [];
18
- // Unsubscribe not stored; manager.cleanup() in afterEach clears all listeners.
19
- manager.onEvent((e) => events.push(e));
20
- return events;
21
- }
22
-
23
- describe("process_output_changed", () => {
24
- let manager: ProcessManager;
25
-
26
- afterEach(() => {
27
- manager.cleanup();
28
- });
29
-
30
- it("emits process_output_changed on stdout", async () => {
31
- manager = new ProcessManager();
32
- const events = collectEvents(manager);
33
- const info = manager.start("test", "echo hello", "/tmp");
34
- await waitForEnd(manager, info.id);
35
-
36
- const outputEvents = events.filter(
37
- (e) => e.type === "process_output_changed",
38
- );
39
- expect(outputEvents.length).toBeGreaterThanOrEqual(1);
40
- expect(outputEvents[0]).toEqual({
41
- type: "process_output_changed",
42
- id: info.id,
43
- });
44
- });
45
-
46
- it("emits process_output_changed on stderr", async () => {
47
- manager = new ProcessManager();
48
- const events = collectEvents(manager);
49
- const info = manager.start("test", "echo err >&2", "/tmp");
50
- await waitForEnd(manager, info.id);
51
-
52
- const outputEvents = events.filter(
53
- (e) => e.type === "process_output_changed",
54
- );
55
- expect(outputEvents.length).toBeGreaterThanOrEqual(1);
56
- expect(outputEvents[0]).toEqual({
57
- type: "process_output_changed",
58
- id: info.id,
59
- });
60
- });
61
-
62
- it("throttles rapid output", async () => {
63
- manager = new ProcessManager();
64
- const events = collectEvents(manager);
65
- const info = manager.start("test", "seq 1 200", "/tmp");
66
- await waitForEnd(manager, info.id);
67
-
68
- const outputEvents = events.filter(
69
- (e) => e.type === "process_output_changed",
70
- );
71
- // Should be significantly fewer than 200 due to throttling
72
- expect(outputEvents.length).toBeGreaterThanOrEqual(1);
73
- expect(outputEvents.length).toBeLessThan(50);
74
- });
75
-
76
- it("stdout and stderr share one throttle bucket", async () => {
77
- manager = new ProcessManager();
78
-
79
- // Dual-stream burst: writes to both stdout and stderr rapidly
80
- const events2 = collectEvents(manager);
81
- const info2 = manager.start(
82
- "dual",
83
- "bash -c 'for i in $(seq 1 50); do echo out$i; echo err$i >&2; done'",
84
- "/tmp",
85
- );
86
- await waitForEnd(manager, info2.id);
87
- const dualCount = events2.filter(
88
- (e) => e.type === "process_output_changed",
89
- ).length;
90
-
91
- // Both streams share one throttle bucket, so total events should be low
92
- expect(dualCount).toBeLessThan(30);
93
- });
94
-
95
- it("trailing emit fires after burst ends", async () => {
96
- manager = new ProcessManager();
97
- const events = collectEvents(manager);
98
- const info = manager.start("test", "seq 1 100", "/tmp");
99
- await waitForEnd(manager, info.id);
100
-
101
- // There should be at least one output event, and a process_ended event
102
- const outputEvents = events.filter(
103
- (e) => e.type === "process_output_changed",
104
- );
105
- const endEvents = events.filter((e) => e.type === "process_ended");
106
- expect(outputEvents.length).toBeGreaterThanOrEqual(1);
107
- expect(endEvents.length).toBe(1);
108
- });
109
-
110
- it("final output event before process_ended", async () => {
111
- manager = new ProcessManager();
112
- const events = collectEvents(manager);
113
- const info = manager.start("test", "echo hello", "/tmp");
114
- await waitForEnd(manager, info.id);
115
-
116
- let lastOutputIdx = -1;
117
- for (let i = events.length - 1; i >= 0; i--) {
118
- if (events[i].type === "process_output_changed") {
119
- lastOutputIdx = i;
120
- break;
121
- }
122
- }
123
- const endIdx = events.findIndex((e) => e.type === "process_ended");
124
-
125
- expect(lastOutputIdx).toBeGreaterThanOrEqual(0);
126
- expect(endIdx).toBeGreaterThanOrEqual(0);
127
- expect(lastOutputIdx).toBeLessThan(endIdx);
128
- });
129
-
130
- it("no output events for silent process", async () => {
131
- manager = new ProcessManager();
132
- const events = collectEvents(manager);
133
- const info = manager.start("test", "true", "/tmp");
134
- await waitForEnd(manager, info.id);
135
-
136
- // Wait a bit for any stale trailing emits
137
- await new Promise((r) => setTimeout(r, 200));
138
-
139
- const outputEvents = events.filter(
140
- (e) => e.type === "process_output_changed",
141
- );
142
- expect(outputEvents.length).toBe(0);
143
- });
144
-
145
- it("no stale events after clearFinished", async () => {
146
- manager = new ProcessManager();
147
- const info = manager.start("test", "seq 1 50", "/tmp");
148
- await waitForEnd(manager, info.id);
149
-
150
- manager.clearFinished();
151
-
152
- const lateEvents: ManagerEvent[] = [];
153
- manager.onEvent((e) => lateEvents.push(e));
154
-
155
- await new Promise((r) => setTimeout(r, 200));
156
-
157
- const staleOutput = lateEvents.filter(
158
- (e) => e.type === "process_output_changed",
159
- );
160
- expect(staleOutput.length).toBe(0);
161
- });
162
-
163
- it("events carry correct process id with multiple processes", async () => {
164
- manager = new ProcessManager();
165
- const events = collectEvents(manager);
166
-
167
- const info1 = manager.start("proc1", "echo one", "/tmp");
168
- const info2 = manager.start("proc2", "echo two", "/tmp");
169
-
170
- await Promise.all([
171
- waitForEnd(manager, info1.id),
172
- waitForEnd(manager, info2.id),
173
- ]);
174
-
175
- const outputEvents = events.filter(
176
- (e) => e.type === "process_output_changed",
177
- );
178
-
179
- for (const e of outputEvents) {
180
- if (e.type === "process_output_changed") {
181
- expect([info1.id, info2.id]).toContain(e.id);
182
- }
183
- }
184
-
185
- // Both processes should have at least one output event
186
- const ids = new Set(
187
- outputEvents
188
- .filter(
189
- (e): e is Extract<ManagerEvent, { type: "process_output_changed" }> =>
190
- e.type === "process_output_changed",
191
- )
192
- .map((e) => e.id),
193
- );
194
- expect(ids.has(info1.id)).toBe(true);
195
- expect(ids.has(info2.id)).toBe(true);
196
- });
197
- });
198
-
199
- describe("process_watch_matched", () => {
200
- let manager: ProcessManager;
201
-
202
- afterEach(() => {
203
- manager.cleanup();
204
- });
205
-
206
- it("fires once by default on first matching line", async () => {
207
- manager = new ProcessManager();
208
- const events = collectEvents(manager);
209
-
210
- const info = manager.start(
211
- "watch-once",
212
- "bash -c 'echo ready; echo ready; echo ready'",
213
- "/tmp",
214
- {
215
- logWatches: [{ pattern: "ready" }],
216
- },
217
- );
218
-
219
- await waitForEnd(manager, info.id);
220
-
221
- const matches = events.filter((e) => e.type === "process_watch_matched");
222
- expect(matches).toHaveLength(1);
223
-
224
- const first = matches[0];
225
- if (first.type === "process_watch_matched") {
226
- expect(first.match.processId).toBe(info.id);
227
- expect(first.match.source).toBe("stdout");
228
- expect(first.match.watch.repeat).toBe(false);
229
- expect(first.match.line).toBe("ready");
230
- }
231
- });
232
-
233
- it("supports repeat watches", async () => {
234
- manager = new ProcessManager();
235
- const events = collectEvents(manager);
236
-
237
- const info = manager.start(
238
- "watch-repeat",
239
- "bash -c 'echo done; echo done; echo done'",
240
- "/tmp",
241
- {
242
- logWatches: [{ pattern: "done", repeat: true }],
243
- },
244
- );
245
-
246
- await waitForEnd(manager, info.id);
247
-
248
- const matches = events.filter((e) => e.type === "process_watch_matched");
249
- expect(matches).toHaveLength(3);
250
- });
251
-
252
- it("respects stream scoping", async () => {
253
- manager = new ProcessManager();
254
- const events = collectEvents(manager);
255
-
256
- const info = manager.start(
257
- "watch-stream",
258
- "bash -c 'echo out; echo err >&2'",
259
- "/tmp",
260
- {
261
- logWatches: [{ pattern: "err", stream: "stderr" }],
262
- },
263
- );
264
-
265
- await waitForEnd(manager, info.id);
266
-
267
- const matches = events.filter((e) => e.type === "process_watch_matched");
268
- expect(matches).toHaveLength(1);
269
-
270
- const match = matches[0];
271
- if (match.type === "process_watch_matched") {
272
- expect(match.match.source).toBe("stderr");
273
- expect(match.match.line).toBe("err");
274
- }
275
- });
276
-
277
- it("stream both matches stdout and stderr", async () => {
278
- manager = new ProcessManager();
279
- const events = collectEvents(manager);
280
-
281
- const info = manager.start(
282
- "watch-both",
283
- "bash -c 'echo marker; echo marker >&2'",
284
- "/tmp",
285
- {
286
- logWatches: [{ pattern: "marker", stream: "both", repeat: true }],
287
- },
288
- );
289
-
290
- await waitForEnd(manager, info.id);
291
-
292
- const matches = events.filter((e) => e.type === "process_watch_matched");
293
- expect(matches).toHaveLength(2);
294
-
295
- const sources = new Set(
296
- matches
297
- .filter(
298
- (e): e is Extract<ManagerEvent, { type: "process_watch_matched" }> =>
299
- e.type === "process_watch_matched",
300
- )
301
- .map((e) => e.match.source),
302
- );
303
-
304
- expect(sources.has("stdout")).toBe(true);
305
- expect(sources.has("stderr")).toBe(true);
306
- });
307
-
308
- it("matches trailing partial line at process end", async () => {
309
- manager = new ProcessManager();
310
- const events = collectEvents(manager);
311
-
312
- const info = manager.start("watch-trailing", "printf ready", "/tmp", {
313
- logWatches: [{ pattern: "ready" }],
314
- });
315
-
316
- await waitForEnd(manager, info.id);
317
-
318
- const matches = events.filter((e) => e.type === "process_watch_matched");
319
- expect(matches).toHaveLength(1);
320
- });
321
-
322
- it("throws for invalid watch regex", () => {
323
- manager = new ProcessManager();
324
-
325
- expect(() =>
326
- manager.start("bad-watch", "echo ok", "/tmp", {
327
- logWatches: [{ pattern: "(" }],
328
- }),
329
- ).toThrowError(/Invalid log watch pattern/);
330
- });
331
- });