@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
@@ -0,0 +1,72 @@
1
+ /**
2
+ * Extension config types.
3
+ *
4
+ * User-facing schema is ProcessConfig (all fields optional).
5
+ * Runtime config uses the shared protocol shape returned by REQUEST_CONFIG.
6
+ */
7
+
8
+ import type { ProcessProtocolConfig } from "../../../src/protocol";
9
+
10
+ export interface ExecutionConfig {
11
+ /** Absolute shell executable used to run process commands. */
12
+ shellPath?: string;
13
+ }
14
+
15
+ export interface InterceptionConfig {
16
+ /** Block shell background patterns and redirect the agent to the process tool. */
17
+ blockBackgroundCommands?: boolean;
18
+ }
19
+
20
+ export interface ProcessListConfig {
21
+ /** Maximum log rows displayed in the overview preview and logs overlay. */
22
+ maxPreviewLines?: number;
23
+ /** Maximum process rows displayed in the overview before scrolling. */
24
+ maxVisibleProcesses?: number;
25
+ }
26
+
27
+ export interface OutputConfig {
28
+ /** Default number of recent lines returned by the process output action. */
29
+ defaultTailLines?: number;
30
+ /** Maximum lines retained by process log interfaces. */
31
+ maxOutputLines?: number;
32
+ /** Maximum bytes retained by each process log interface. */
33
+ maxOutputBytes?: number;
34
+ }
35
+
36
+ export interface FollowConfig {
37
+ /** Open process log interfaces in follow mode by default. */
38
+ enabledByDefault?: boolean;
39
+ /** Hide process log interfaces when all managed processes finish. */
40
+ autoHideOnFinish?: boolean;
41
+ }
42
+
43
+ export interface WidgetConfig {
44
+ /** Show a one-line process summary below the editor. */
45
+ showStatusWidget?: boolean;
46
+ /** Initial visibility of the process dock. */
47
+ dockDefaultState?: "closed" | "collapsed" | "expanded";
48
+ /** Number of log rows displayed in the expanded process dock. */
49
+ dockHeight?: number;
50
+ }
51
+
52
+ /** User-facing pi-processes settings stored on disk. */
53
+ export interface ProcessConfig {
54
+ /** JSON Schema URL used by editors for validation and autocomplete. */
55
+ $schema?: string;
56
+ /** Config migration marker written after v0.10.0 settings migration. */
57
+ version?: string;
58
+ /** Process execution settings. */
59
+ execution?: ExecutionConfig;
60
+ /** Shell-command interception settings. */
61
+ interception?: InterceptionConfig;
62
+ /** Process overview and log viewport settings. */
63
+ processList?: ProcessListConfig;
64
+ /** Process output retention settings. */
65
+ output?: OutputConfig;
66
+ /** Live log follow behavior. */
67
+ follow?: FollowConfig;
68
+ /** Dock and status widget settings. */
69
+ widget?: WidgetConfig;
70
+ }
71
+
72
+ export type { ProcessProtocolConfig };
@@ -0,0 +1 @@
1
+ export const MESSAGE_TYPE_PROCESS_NOTIFICATION = "ad-process:notification";
@@ -0,0 +1,113 @@
1
+ import type { EventBus } from "@earendil-works/pi-coding-agent";
2
+
3
+ import type { ProcessManager } from "../../../src/manager";
4
+ import {
5
+ CHANNELS,
6
+ type CommandClearPayload,
7
+ type CommandKillPayload,
8
+ } from "../../../src/protocol";
9
+ import type { KillResult } from "../../../src/types";
10
+ import { isRecord } from "../../../src/utils/is-record";
11
+ import type { NotificationRegistry } from "../notifications/registry";
12
+ import { killIntentionally } from "./kill-process";
13
+
14
+ export function registerCommandHandlers(
15
+ events: EventBus,
16
+ manager: ProcessManager,
17
+ notifications: NotificationRegistry,
18
+ ): () => void {
19
+ const disposers = [
20
+ events.on(CHANNELS.COMMAND_KILL, (payload) => {
21
+ const command = payload as CommandKillPayload;
22
+ if (!isCommandKillPayload(command)) return;
23
+
24
+ void killIntentionally(manager, notifications, command.id, {
25
+ signal: command.signal,
26
+ timeoutMs: command.timeoutMs,
27
+ }).then(
28
+ (result) => safeReply(command.reply, result),
29
+ () => safeReply(command.reply, createKillErrorResult(command.id)),
30
+ );
31
+ }),
32
+ events.on(CHANNELS.COMMAND_CLEAR, (payload) => {
33
+ const command = payload as CommandClearPayload;
34
+ if (!isCommandClearPayload(command)) return;
35
+
36
+ safeReply(command.reply, manager.clearFinished());
37
+ }),
38
+ ];
39
+
40
+ return () => {
41
+ for (const dispose of disposers) dispose();
42
+ };
43
+ }
44
+
45
+ function isCommandKillPayload(
46
+ payload: CommandKillPayload,
47
+ ): payload is CommandKillPayload {
48
+ return (
49
+ isRecord(payload) &&
50
+ typeof payload.id === "string" &&
51
+ isOptionalSignal(payload.signal) &&
52
+ isOptionalNumber(payload.timeoutMs) &&
53
+ isReply(payload)
54
+ );
55
+ }
56
+
57
+ function isCommandClearPayload(
58
+ payload: CommandClearPayload,
59
+ ): payload is CommandClearPayload {
60
+ return isRecord(payload) && isReply(payload);
61
+ }
62
+
63
+ function isReply(
64
+ payload: unknown,
65
+ ): payload is { reply: (...args: never[]) => void } {
66
+ return isRecord(payload) && typeof payload.reply === "function";
67
+ }
68
+
69
+ function isOptionalSignal(
70
+ signal: unknown,
71
+ ): signal is NodeJS.Signals | undefined {
72
+ return signal === undefined || typeof signal === "string";
73
+ }
74
+
75
+ function isOptionalNumber(value: unknown): value is number | undefined {
76
+ return (
77
+ value === undefined || (typeof value === "number" && Number.isFinite(value))
78
+ );
79
+ }
80
+
81
+ function safeReply<T>(reply: (result: T) => void, result: T): void {
82
+ try {
83
+ reply(result);
84
+ } catch {
85
+ // Reply callbacks are owned by the requester. Never let a bad requester
86
+ // break shared command listeners or create unhandled promise rejections.
87
+ return;
88
+ }
89
+ }
90
+
91
+ function createKillErrorResult(id: string): KillResult {
92
+ return {
93
+ ok: false,
94
+ reason: "error",
95
+ info: {
96
+ id,
97
+ name: "(unknown)",
98
+ pid: -1,
99
+ command: "",
100
+ cwd: "",
101
+ startTime: 0,
102
+ endTime: null,
103
+ status: "exited",
104
+ exitCode: null,
105
+ success: false,
106
+ stdoutFile: "",
107
+ stderrFile: "",
108
+ endReason: null,
109
+ signal: null,
110
+ errorMessage: "Failed to kill process",
111
+ },
112
+ };
113
+ }
@@ -0,0 +1,31 @@
1
+ import type { ProcessManager } from "../../../src/manager";
2
+ import type { KillResult } from "../../../src/types";
3
+ import { LIVE_STATUSES } from "../../../src/types";
4
+ import type { NotificationRegistry } from "../notifications/registry";
5
+
6
+ export async function killIntentionally(
7
+ manager: ProcessManager,
8
+ notifications: NotificationRegistry,
9
+ id: string,
10
+ opts?: { signal?: NodeJS.Signals; timeoutMs?: number },
11
+ ): Promise<KillResult> {
12
+ notifications.markIntentionalStop(id);
13
+
14
+ let result: KillResult;
15
+ try {
16
+ result = await manager.kill(id, opts);
17
+ } catch {
18
+ notifications.consumeIntentionalStop(id);
19
+ throw new Error(`process stop failed for ${id}`);
20
+ }
21
+
22
+ if (!result.ok) {
23
+ if (result.reason === "not_found" || result.reason === "error") {
24
+ notifications.consumeIntentionalStop(id);
25
+ }
26
+ } else if (!LIVE_STATUSES.has(result.info.status)) {
27
+ notifications.consumeIntentionalStop(id);
28
+ }
29
+
30
+ return result;
31
+ }
@@ -0,0 +1,147 @@
1
+ import type { EventBus, ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+
3
+ import {
4
+ CHANNELS,
5
+ type ProcessProtocolNotificationPayload,
6
+ } from "../../../src/protocol";
7
+ import { isRecord } from "../../../src/utils/is-record";
8
+ import {
9
+ attentionToSendOptions,
10
+ sendProcessNotificationMessage,
11
+ } from "../notification-sender";
12
+
13
+ const NOTIFICATION_WINDOW_MS = 60_000;
14
+ const MAX_LOG_MATCH_NOTIFICATIONS_PER_WINDOW = 20;
15
+
16
+ /**
17
+ * Delivers notification events emitted on {@link CHANNELS.NOTIFICATION} to Pi as
18
+ * persisted custom messages. This is the core side of the notification fanout:
19
+ * the NotificationService emits language-neutral payloads, and this listener
20
+ * converts each payload into a displayed `ad-process:notification` message with
21
+ * the attention-derived send options. UI extensions observe the same channel
22
+ * for display concerns (e.g. log-match highlighting) without importing this
23
+ * module.
24
+ *
25
+ * Returns a disposer that removes the listener; it must be called on
26
+ * `session_shutdown` before the manager is killed.
27
+ */
28
+ export function registerNotificationDelivery(
29
+ events: EventBus,
30
+ pi: ExtensionAPI,
31
+ ): () => void {
32
+ let windowStart: number | null = null;
33
+ let sentInWindow = 0;
34
+ let suppressed = 0;
35
+ let summaryTimer: ReturnType<typeof setTimeout> | null = null;
36
+
37
+ const clearSummaryTimer = () => {
38
+ if (!summaryTimer) return;
39
+ clearTimeout(summaryTimer);
40
+ summaryTimer = null;
41
+ };
42
+
43
+ const resetWindow = (): number => {
44
+ clearSummaryTimer();
45
+ const count = suppressed;
46
+ windowStart = null;
47
+ sentInWindow = 0;
48
+ suppressed = 0;
49
+ return count;
50
+ };
51
+
52
+ const sendSuppressedSummary = (count: number) => {
53
+ if (count === 0) return;
54
+ const details: ProcessProtocolNotificationPayload = {
55
+ kind: "log_match_suppressed",
56
+ processId: "*",
57
+ processName: "log watches",
58
+ command: "",
59
+ timestamp: Date.now(),
60
+ summary: `Suppressed ${count} log-match notifications because output was too fast.`,
61
+ attention: "context",
62
+ };
63
+ sendProcessNotificationMessage(pi, details, {
64
+ triggerTurn: false,
65
+ deliverAs: "steer",
66
+ });
67
+ };
68
+
69
+ const flushSuppressedSummary = () => {
70
+ const count = resetWindow();
71
+ sendSuppressedSummary(count);
72
+ };
73
+
74
+ const startWindow = (now: number) => {
75
+ windowStart = now;
76
+ sentInWindow = 0;
77
+ suppressed = 0;
78
+ clearSummaryTimer();
79
+ summaryTimer = setTimeout(flushSuppressedSummary, NOTIFICATION_WINDOW_MS);
80
+ summaryTimer.unref?.();
81
+ };
82
+
83
+ const disposeListener = events.on(
84
+ CHANNELS.NOTIFICATION,
85
+ (payload: unknown) => {
86
+ if (!isNotificationPayload(payload)) return;
87
+
88
+ if (payload.kind === "log_match") {
89
+ const now = performance.now();
90
+ if (
91
+ windowStart === null ||
92
+ now - windowStart >= NOTIFICATION_WINDOW_MS
93
+ ) {
94
+ const previousSuppressed = windowStart === null ? 0 : resetWindow();
95
+ startWindow(now);
96
+ sendSuppressedSummary(previousSuppressed);
97
+ }
98
+ if (sentInWindow >= MAX_LOG_MATCH_NOTIFICATIONS_PER_WINDOW) {
99
+ suppressed++;
100
+ return;
101
+ }
102
+ sentInWindow++;
103
+ }
104
+
105
+ const options = attentionToSendOptions(payload.attention);
106
+ sendProcessNotificationMessage(pi, payload, options);
107
+ },
108
+ );
109
+
110
+ return () => {
111
+ disposeListener();
112
+ resetWindow();
113
+ };
114
+ }
115
+
116
+ function isNotificationPayload(
117
+ payload: unknown,
118
+ ): payload is ProcessProtocolNotificationPayload {
119
+ if (!isRecord(payload)) return false;
120
+ if (!isNotificationKind(payload.kind)) return false;
121
+ if (typeof payload.processId !== "string") return false;
122
+ if (typeof payload.processName !== "string") return false;
123
+ if (typeof payload.command !== "string") return false;
124
+ if (typeof payload.timestamp !== "number") return false;
125
+ if (typeof payload.summary !== "string") return false;
126
+ if (
127
+ payload.attention !== "turn" &&
128
+ payload.attention !== "context" &&
129
+ payload.attention !== "ignore"
130
+ ) {
131
+ return false;
132
+ }
133
+ return true;
134
+ }
135
+
136
+ function isNotificationKind(
137
+ value: unknown,
138
+ ): value is ProcessProtocolNotificationPayload["kind"] {
139
+ return (
140
+ value === "success" ||
141
+ value === "failure" ||
142
+ value === "crash" ||
143
+ value === "killed" ||
144
+ value === "log_match" ||
145
+ value === "log_match_suppressed"
146
+ );
147
+ }
@@ -0,0 +1,114 @@
1
+ import type { EventBus } from "@earendil-works/pi-coding-agent";
2
+ import type { ProcessManager } from "../../../src/manager";
3
+ import {
4
+ CHANNELS,
5
+ type RequestCombinedOutputPayload,
6
+ type RequestConfigPayload,
7
+ type RequestFileSizePayload,
8
+ type RequestGetPayload,
9
+ type RequestListPayload,
10
+ type RequestLogFilesPayload,
11
+ type RequestOutputPayload,
12
+ } from "../../../src/protocol";
13
+ import { isRecord } from "../../../src/utils/is-record";
14
+ import type { ProcessProtocolConfig } from "../config";
15
+
16
+ export function registerRequestHandlers(
17
+ events: EventBus,
18
+ manager: ProcessManager,
19
+ getConfig: () => ProcessProtocolConfig,
20
+ ): () => void {
21
+ const disposers = [
22
+ events.on(CHANNELS.REQUEST_LIST, (payload) => {
23
+ const request = payload as RequestListPayload;
24
+ if (!isRequestListPayload(request)) return;
25
+
26
+ request.reply(manager.list());
27
+ }),
28
+ events.on(CHANNELS.REQUEST_GET, (payload) => {
29
+ const request = payload as RequestGetPayload;
30
+ if (!isIdRequest(request)) return;
31
+
32
+ request.reply(manager.get(request.id));
33
+ }),
34
+ events.on(CHANNELS.REQUEST_OUTPUT, (payload) => {
35
+ const request = payload as RequestOutputPayload;
36
+ if (!isIdRequest(request)) return;
37
+
38
+ request.reply(manager.getOutput(request.id, request.tailLines));
39
+ }),
40
+ events.on(CHANNELS.REQUEST_COMBINED_OUTPUT, (payload) => {
41
+ const request = payload as RequestCombinedOutputPayload;
42
+ if (!isIdRequest(request)) return;
43
+
44
+ request.reply(manager.getCombinedOutput(request.id, request.tailLines));
45
+ }),
46
+ events.on(CHANNELS.REQUEST_LOG_FILES, (payload) => {
47
+ const request = payload as RequestLogFilesPayload;
48
+ if (!isIdRequest(request)) return;
49
+
50
+ request.reply(manager.getLogFiles(request.id));
51
+ }),
52
+ events.on(CHANNELS.REQUEST_FILE_SIZE, (payload) => {
53
+ const request = payload as RequestFileSizePayload;
54
+ if (!isIdRequest(request)) return;
55
+
56
+ request.reply(manager.getFileSize(request.id));
57
+ }),
58
+ events.on(CHANNELS.REQUEST_CONFIG, (payload) => {
59
+ const request = payload as RequestConfigPayload;
60
+ if (!isRequestConfigPayload(request)) return;
61
+
62
+ request.reply(getConfig());
63
+ }),
64
+ ];
65
+
66
+ return () => {
67
+ for (const dispose of disposers) dispose();
68
+ };
69
+ }
70
+
71
+ function isRequestListPayload(
72
+ payload: RequestListPayload,
73
+ ): payload is RequestListPayload {
74
+ return isRecord(payload) && isReply(payload);
75
+ }
76
+
77
+ function isRequestConfigPayload(
78
+ payload: RequestConfigPayload,
79
+ ): payload is RequestConfigPayload {
80
+ return isRecord(payload) && isReply(payload);
81
+ }
82
+
83
+ function isIdRequest(
84
+ payload:
85
+ | RequestGetPayload
86
+ | RequestOutputPayload
87
+ | RequestCombinedOutputPayload
88
+ | RequestLogFilesPayload
89
+ | RequestFileSizePayload,
90
+ ): payload is
91
+ | RequestGetPayload
92
+ | RequestOutputPayload
93
+ | RequestCombinedOutputPayload
94
+ | RequestLogFilesPayload
95
+ | RequestFileSizePayload {
96
+ return (
97
+ isRecord(payload) &&
98
+ typeof payload.id === "string" &&
99
+ isOptionalNumber(payload.tailLines) &&
100
+ isReply(payload)
101
+ );
102
+ }
103
+
104
+ function isReply(
105
+ payload: unknown,
106
+ ): payload is { reply: (...args: never[]) => void } {
107
+ return isRecord(payload) && typeof payload.reply === "function";
108
+ }
109
+
110
+ function isOptionalNumber(value: unknown): value is number | undefined {
111
+ return (
112
+ value === undefined || (typeof value === "number" && Number.isFinite(value))
113
+ );
114
+ }
@@ -0,0 +1,144 @@
1
+ import type { EventBus } from "@earendil-works/pi-coding-agent";
2
+
3
+ import type { ProcessManager } from "../../../src/manager";
4
+ import {
5
+ CHANNELS,
6
+ type LogsSubscribePayload,
7
+ type LogsUnsubscribePayload,
8
+ } from "../../../src/protocol";
9
+ import { isRecord } from "../../../src/utils/is-record";
10
+ import { buildDroppedOutputLine } from "../../shared/line-buffer";
11
+
12
+ interface LogSubscriber {
13
+ subscriberId: string;
14
+ processId: string;
15
+ }
16
+
17
+ export function registerLogSubscriptions(
18
+ events: EventBus,
19
+ manager: ProcessManager,
20
+ ): () => void {
21
+ const subscribers = new Map<string, LogSubscriber>();
22
+
23
+ const disposeSubscribe = events.on(CHANNELS.LOGS_SUBSCRIBE, (payload) => {
24
+ const request = payload as LogsSubscribePayload;
25
+ if (!isLogsSubscribePayload(request)) return;
26
+
27
+ const processInfo = manager.get(request.processId);
28
+
29
+ if (!processInfo) {
30
+ request.reply({ ok: false, error: "Process not found" });
31
+ return;
32
+ }
33
+
34
+ const initialLines = manager.getCombinedOutput(
35
+ request.processId,
36
+ request.tailLines ?? 100,
37
+ );
38
+
39
+ if (!initialLines) {
40
+ request.reply({ ok: false, error: "Process logs not found" });
41
+ return;
42
+ }
43
+
44
+ subscribers.set(request.subscriberId, {
45
+ subscriberId: request.subscriberId,
46
+ processId: request.processId,
47
+ });
48
+
49
+ request.reply({ ok: true, initialLines });
50
+ });
51
+
52
+ const disposeUnsubscribe = events.on(CHANNELS.LOGS_UNSUBSCRIBE, (payload) => {
53
+ const request = payload as LogsUnsubscribePayload;
54
+ if (!isLogsUnsubscribePayload(request)) return;
55
+
56
+ subscribers.delete(request.subscriberId);
57
+ });
58
+
59
+ const disposeManager = manager.onEvent((event) => {
60
+ if (event.type === "process_ended") {
61
+ removeSubscribersForProcess(subscribers, event.info.id);
62
+ return;
63
+ }
64
+
65
+ if (event.type === "processes_changed") {
66
+ removeStaleSubscribers(subscribers, manager);
67
+ return;
68
+ }
69
+
70
+ if (event.type !== "process_output_changed") return;
71
+ const lines = [
72
+ ...(event.droppedLines
73
+ ? [buildDroppedOutputLine(event.droppedLines)]
74
+ : []),
75
+ ...(event.appendedText ?? []),
76
+ ];
77
+ if (lines.length === 0) return;
78
+
79
+ for (const subscriber of subscribers.values()) {
80
+ if (subscriber.processId !== event.id) continue;
81
+
82
+ events.emit(CHANNELS.LOGS_CHUNK, {
83
+ subscriberId: subscriber.subscriberId,
84
+ processId: subscriber.processId,
85
+ lines,
86
+ });
87
+ }
88
+ });
89
+
90
+ return () => {
91
+ disposeSubscribe();
92
+ disposeUnsubscribe();
93
+ disposeManager();
94
+ subscribers.clear();
95
+ };
96
+ }
97
+
98
+ function removeSubscribersForProcess(
99
+ subscribers: Map<string, LogSubscriber>,
100
+ processId: string,
101
+ ): void {
102
+ for (const [subscriberId, subscriber] of subscribers.entries()) {
103
+ if (subscriber.processId === processId) subscribers.delete(subscriberId);
104
+ }
105
+ }
106
+
107
+ function removeStaleSubscribers(
108
+ subscribers: Map<string, LogSubscriber>,
109
+ manager: ProcessManager,
110
+ ): void {
111
+ for (const [subscriberId, subscriber] of subscribers.entries()) {
112
+ if (!manager.get(subscriber.processId)) subscribers.delete(subscriberId);
113
+ }
114
+ }
115
+
116
+ function isLogsSubscribePayload(
117
+ payload: LogsSubscribePayload,
118
+ ): payload is LogsSubscribePayload {
119
+ return (
120
+ isRecord(payload) &&
121
+ typeof payload.subscriberId === "string" &&
122
+ typeof payload.processId === "string" &&
123
+ isOptionalNumber(payload.tailLines) &&
124
+ isReply(payload)
125
+ );
126
+ }
127
+
128
+ function isLogsUnsubscribePayload(
129
+ payload: LogsUnsubscribePayload,
130
+ ): payload is LogsUnsubscribePayload {
131
+ return isRecord(payload) && typeof payload.subscriberId === "string";
132
+ }
133
+
134
+ function isReply(
135
+ payload: unknown,
136
+ ): payload is { reply: (...args: never[]) => void } {
137
+ return isRecord(payload) && typeof payload.reply === "function";
138
+ }
139
+
140
+ function isOptionalNumber(value: unknown): value is number | undefined {
141
+ return (
142
+ value === undefined || (typeof value === "number" && Number.isFinite(value))
143
+ );
144
+ }