@aliou/pi-processes 0.10.8 → 0.11.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 (42) hide show
  1. package/README.md +40 -0
  2. package/extensions/processes/client.ts +24 -2
  3. package/extensions/processes/commands/overview.ts +1 -1
  4. package/extensions/processes/components/overview-component.ts +121 -51
  5. package/extensions/processes/config/migrations/001-v0-9-4-to-v0-10-0-config.ts +5 -8
  6. package/extensions/processes/config/types.ts +1 -1
  7. package/extensions/processes/handlers/commands.ts +21 -41
  8. package/extensions/processes/handlers/notifications.ts +3 -37
  9. package/extensions/processes/handlers/requests.ts +1 -54
  10. package/extensions/processes/handlers/subscriptions.ts +2 -35
  11. package/extensions/processes/hooks/event-bridge.ts +1 -1
  12. package/extensions/processes/index.ts +2 -2
  13. package/extensions/processes/notifications/service.ts +4 -1
  14. package/extensions/processes/notifications/types.ts +2 -2
  15. package/extensions/processes/tools/notify.ts +37 -130
  16. package/extensions/processes/tools/schema.ts +5 -2
  17. package/extensions/processes/tools/update/index.ts +15 -40
  18. package/extensions/processes-debug/index.ts +67 -0
  19. package/extensions/processes-dock/client.ts +2 -2
  20. package/extensions/processes-dock/widget/setup.ts +12 -37
  21. package/extensions/processes-logs/client.ts +2 -2
  22. package/extensions/processes-logs/commands/logs.ts +1 -1
  23. package/extensions/processes-logs/components/log-file-viewer.ts +135 -8
  24. package/extensions/processes-logs/components/log-overlay-component.ts +156 -82
  25. package/extensions/processes-logs/logs-client.ts +2 -23
  26. package/extensions/shared/log-line.ts +49 -2
  27. package/{src → extensions/shared}/protocol/broadcasts.ts +1 -1
  28. package/{src → extensions/shared}/protocol/channels.ts +1 -0
  29. package/{src → extensions/shared}/protocol/commands.ts +12 -1
  30. package/{src → extensions/shared}/protocol/index.ts +2 -0
  31. package/{src → extensions/shared}/protocol/notifications.ts +1 -1
  32. package/{src → extensions/shared}/protocol/requests.ts +1 -1
  33. package/extensions/shared/shortcut-hints.ts +150 -0
  34. package/extensions/shared/shortcuts-overlay.ts +229 -0
  35. package/extensions/shared/truncate.ts +189 -0
  36. package/package.json +4 -4
  37. package/src/utils/command-executor.ts +2 -1
  38. package/src/utils/shell-utils.ts +44 -4
  39. package/extensions/shared/output-payload.ts +0 -28
  40. package/src/get-manager.ts +0 -15
  41. package/src/utils/is-record.ts +0 -3
  42. /package/{src → extensions/shared}/protocol/logs.ts +0 -0
@@ -1,13 +1,12 @@
1
1
  import type { EventBus } from "@earendil-works/pi-coding-agent";
2
2
 
3
3
  import type { ProcessManager } from "../../../src/manager";
4
+ import { buildDroppedOutputLine } from "../../shared/line-buffer";
4
5
  import {
5
6
  CHANNELS,
6
7
  type LogsSubscribePayload,
7
8
  type LogsUnsubscribePayload,
8
- } from "../../../src/protocol";
9
- import { isRecord } from "../../../src/utils/is-record";
10
- import { buildDroppedOutputLine } from "../../shared/line-buffer";
9
+ } from "../../shared/protocol";
11
10
 
12
11
  interface LogSubscriber {
13
12
  subscriberId: string;
@@ -22,7 +21,6 @@ export function registerLogSubscriptions(
22
21
 
23
22
  const disposeSubscribe = events.on(CHANNELS.LOGS_SUBSCRIBE, (payload) => {
24
23
  const request = payload as LogsSubscribePayload;
25
- if (!isLogsSubscribePayload(request)) return;
26
24
 
27
25
  const processInfo = manager.get(request.processId);
28
26
 
@@ -51,7 +49,6 @@ export function registerLogSubscriptions(
51
49
 
52
50
  const disposeUnsubscribe = events.on(CHANNELS.LOGS_UNSUBSCRIBE, (payload) => {
53
51
  const request = payload as LogsUnsubscribePayload;
54
- if (!isLogsUnsubscribePayload(request)) return;
55
52
 
56
53
  subscribers.delete(request.subscriberId);
57
54
  });
@@ -112,33 +109,3 @@ function removeStaleSubscribers(
112
109
  if (!manager.get(subscriber.processId)) subscribers.delete(subscriberId);
113
110
  }
114
111
  }
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
- }
@@ -1,7 +1,7 @@
1
1
  import type { EventBus } from "@earendil-works/pi-coding-agent";
2
2
 
3
3
  import type { ProcessManager } from "../../../src/manager";
4
- import { CHANNELS } from "../../../src/protocol";
4
+ import { CHANNELS } from "../../shared/protocol";
5
5
 
6
6
  export function registerEventBridge(
7
7
  events: EventBus,
@@ -1,5 +1,5 @@
1
1
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
- import { getManager } from "../../src/get-manager";
2
+ import { ProcessManager } from "../../src/manager";
3
3
  import { isWindowsPlatform } from "../../src/utils/platform";
4
4
  import { registerClearCommand } from "./commands/clear";
5
5
  import { registerKillCommand } from "./commands/kill";
@@ -40,7 +40,7 @@ export default async function processesExtension(
40
40
  await loadProcessConfig();
41
41
  registerMigrationMessageNotifications(pi);
42
42
 
43
- const manager = getManager({
43
+ const manager = new ProcessManager({
44
44
  getConfiguredShellPath: () => configLoader.getConfig().execution.shellPath,
45
45
  });
46
46
  const notifications = createNotificationRegistry();
@@ -1,8 +1,8 @@
1
1
  import type { EventBus } from "@earendil-works/pi-coding-agent";
2
2
 
3
3
  import type { ProcessManager } from "../../../src/manager";
4
- import { CHANNELS } from "../../../src/protocol";
5
4
  import type { ManagerEvent, ProcessInfo } from "../../../src/types";
5
+ import { CHANNELS } from "../../shared/protocol";
6
6
  import { classifyProcessEnd } from "./classify";
7
7
  import {
8
8
  type CompiledLogMatcher,
@@ -90,6 +90,9 @@ export function createNotificationService(deps: NotificationServiceDeps): {
90
90
  const kind = classifyProcessEnd(info);
91
91
 
92
92
  if (isIntentionalStop) {
93
+ const details = buildLifecycleDetails(info, kind, "context");
94
+ events.emit(CHANNELS.NOTIFICATION, details);
95
+
93
96
  cleanupMatcherState(info.id);
94
97
  registry.unregister(info.id);
95
98
  return;
@@ -1,6 +1,6 @@
1
1
  // Re-export the protocol-safe notification types so the core extension and the
2
2
  // protocol layer cannot drift apart. The canonical shape lives in
3
- // `src/protocol/notifications.ts`; these names keep existing import sites
3
+ // `extensions/shared/protocol/notifications.ts`; these names keep existing import sites
4
4
  // stable while guaranteeing structural compatibility with the events emitted on
5
5
  // CHANNELS.NOTIFICATION.
6
6
  export type {
@@ -8,4 +8,4 @@ export type {
8
8
  ProcessProtocolNotificationKind as ProcessNotificationKind,
9
9
  ProcessProtocolNotificationLogMatch as ProcessNotificationLogMatchDetails,
10
10
  ProcessProtocolNotificationPayload as ProcessNotificationDetails,
11
- } from "../../../src/protocol";
11
+ } from "../../shared/protocol";
@@ -1,17 +1,9 @@
1
- import { isRecord } from "../../../src/utils/is-record";
2
1
  import {
3
2
  MAX_LOG_MATCH_PATTERN_LENGTH,
4
3
  MAX_LOG_MATCHERS_PER_PROCESS,
5
4
  } from "../notifications/log-matchers";
6
5
  import type { LogMatcherConfig, NotifyConfig } from "../notifications/registry";
7
- import type { Attention } from "../notifications/types";
8
-
9
- const ATTENTIONS = ["turn", "context", "ignore"] as const;
10
- const LOG_MATCH_MODES = ["literal", "regex"] as const;
11
- const LOG_MATCH_STREAMS = ["stdout", "stderr", "both"] as const;
12
-
13
- export const MAX_NOTIFY_LOG_MATCHERS = MAX_LOG_MATCHERS_PER_PROCESS;
14
- export const MAX_NOTIFY_PATTERN_LENGTH = MAX_LOG_MATCH_PATTERN_LENGTH;
6
+ import type { NotifyLogMatchParamsType, NotifyParamsType } from "./schema";
15
7
 
16
8
  const DEFAULT_NOTIFY_CONFIG = {
17
9
  // A backgrounded process usually outlives the turn that started it, and
@@ -29,120 +21,66 @@ const DEFAULT_NOTIFY_CONFIG = {
29
21
  onKilled: "context",
30
22
  } as const satisfies Pick<NotifyConfig, "onSuccess" | "onFailure" | "onKilled">;
31
23
 
32
- export function normalizeNotifyConfig(input: unknown): NotifyConfig {
33
- if (input === undefined || input === null) {
34
- return { ...DEFAULT_NOTIFY_CONFIG, logMatches: [] };
35
- }
24
+ export const MAX_NOTIFY_LOG_MATCHERS = MAX_LOG_MATCHERS_PER_PROCESS;
25
+ export const MAX_NOTIFY_PATTERN_LENGTH = MAX_LOG_MATCH_PATTERN_LENGTH;
36
26
 
37
- if (!isRecord(input)) {
38
- throw new Error("process start notify must be an object");
39
- }
27
+ // Input arrives already validated against the TypeBox schema in ./schema.ts
28
+ // (Pi validates tool call arguments before execute). Only semantic checks the
29
+ // schema cannot express remain here: whitespace-only patterns (would match
30
+ // every line) and regex validity.
40
31
 
41
- const logMatches = input.logMatches;
32
+ export function normalizeNotifyConfig(
33
+ input: NotifyParamsType | undefined,
34
+ ): NotifyConfig {
35
+ if (!input) {
36
+ return { ...DEFAULT_NOTIFY_CONFIG, logMatches: [] };
37
+ }
42
38
 
43
39
  return {
44
- onSuccess:
45
- normalizeAttention(input.onSuccess, "notify.onSuccess") ??
46
- DEFAULT_NOTIFY_CONFIG.onSuccess,
47
- onFailure:
48
- normalizeAttention(input.onFailure, "notify.onFailure") ??
49
- DEFAULT_NOTIFY_CONFIG.onFailure,
50
- onKilled:
51
- normalizeAttention(input.onKilled, "notify.onKilled") ??
52
- DEFAULT_NOTIFY_CONFIG.onKilled,
53
- logMatches: normalizeLogMatches(logMatches),
40
+ onSuccess: input.onSuccess ?? DEFAULT_NOTIFY_CONFIG.onSuccess,
41
+ onFailure: input.onFailure ?? DEFAULT_NOTIFY_CONFIG.onFailure,
42
+ onKilled: input.onKilled ?? DEFAULT_NOTIFY_CONFIG.onKilled,
43
+ logMatches: normalizeLogMatches(input.logMatches ?? [], {
44
+ actionLabel: "process start",
45
+ pathPrefix: "notify.logMatches",
46
+ }),
54
47
  };
55
48
  }
56
49
 
57
- function normalizeLogMatches(input: unknown): LogMatcherConfig[] {
58
- if (input === undefined || input === null) return [];
59
-
60
- if (!Array.isArray(input)) {
61
- throw new Error("process start notify.logMatches must be an array");
62
- }
63
-
64
- if (input.length > MAX_NOTIFY_LOG_MATCHERS) {
65
- throw new Error(
66
- `process start notify.logMatches supports at most ${MAX_NOTIFY_LOG_MATCHERS} matchers`,
67
- );
68
- }
69
-
70
- return input.map((entry, index) => normalizeLogMatch(entry, index));
71
- }
72
-
73
50
  export function normalizeLogMatchItems(
74
- input: unknown,
51
+ input: NotifyLogMatchParamsType[],
75
52
  options: {
76
53
  actionLabel: string;
77
54
  pathPrefix: string;
78
- maxItems?: number;
79
55
  },
80
56
  ): LogMatcherConfig[] {
81
- const { actionLabel, pathPrefix } = options;
82
- const maxItems = options.maxItems ?? MAX_NOTIFY_LOG_MATCHERS;
83
-
84
- if (input === undefined || input === null) {
85
- throw new Error(`${actionLabel} ${pathPrefix} is required`);
86
- }
87
-
88
- if (!Array.isArray(input)) {
89
- throw new Error(`${actionLabel} ${pathPrefix} must be an array`);
90
- }
91
-
92
- if (input.length === 0) {
93
- throw new Error(`${actionLabel} ${pathPrefix} must not be empty`);
94
- }
95
-
96
- if (input.length > maxItems) {
97
- throw new Error(
98
- `${actionLabel} ${pathPrefix} supports at most ${maxItems} items`,
99
- );
100
- }
57
+ return input.map((entry, index) => normalizeLogMatch(entry, index, options));
58
+ }
101
59
 
102
- return input.map((entry, index) =>
103
- normalizeLogMatch(entry, index, { actionLabel, pathPrefix }),
104
- );
60
+ function normalizeLogMatches(
61
+ input: NotifyLogMatchParamsType[],
62
+ options: { actionLabel: string; pathPrefix: string },
63
+ ): LogMatcherConfig[] {
64
+ return input.map((entry, index) => normalizeLogMatch(entry, index, options));
105
65
  }
106
66
 
107
- export function normalizeLogMatch(
108
- input: unknown,
67
+ function normalizeLogMatch(
68
+ input: NotifyLogMatchParamsType,
109
69
  index: number,
110
- options?: { actionLabel?: string; pathPrefix?: string },
70
+ options: { actionLabel: string; pathPrefix: string },
111
71
  ): LogMatcherConfig {
112
- const actionLabel = options?.actionLabel ?? "process start";
113
- const path = `${options?.pathPrefix ?? "notify.logMatches"}[${index}]`;
114
-
115
- if (!isRecord(input)) {
116
- throw new Error(`${actionLabel} ${path} must be an object`);
117
- }
118
-
119
- if (typeof input.pattern !== "string") {
120
- throw new Error(`${actionLabel} ${path}.pattern must be a string`);
121
- }
72
+ const path = `${options.pathPrefix}[${index}]`;
122
73
 
123
74
  // An empty or whitespace-only literal pattern matches every line
124
75
  // (String#includes("")), and an empty regex matches every line too. Reject
125
76
  // early so a stray "" from the model does not fire a notification per line.
126
77
  if (input.pattern.trim().length === 0) {
127
78
  throw new Error(
128
- `${actionLabel} ${path}.pattern must not be empty or whitespace-only`,
129
- );
130
- }
131
-
132
- if (input.pattern.length > MAX_NOTIFY_PATTERN_LENGTH) {
133
- throw new Error(
134
- `${actionLabel} ${path}.pattern must be at most ${MAX_NOTIFY_PATTERN_LENGTH} characters`,
79
+ `${options.actionLabel} ${path}.pattern must not be empty or whitespace-only`,
135
80
  );
136
81
  }
137
82
 
138
- const mode =
139
- normalizeStringEnum(input.mode, LOG_MATCH_MODES, `${path}.mode`) ??
140
- "literal";
141
- const stream =
142
- normalizeStringEnum(input.stream, LOG_MATCH_STREAMS, `${path}.stream`) ??
143
- "both";
144
- const repeat = normalizeBoolean(input.repeat, `${path}.repeat`) ?? false;
145
- const on = normalizeAttention(input.on, `${path}.on`) ?? "turn";
83
+ const mode = input.mode ?? "literal";
146
84
 
147
85
  if (mode === "regex") {
148
86
  validateRegex(input.pattern, path);
@@ -151,43 +89,12 @@ export function normalizeLogMatch(
151
89
  return {
152
90
  pattern: input.pattern,
153
91
  mode,
154
- stream,
155
- repeat,
156
- on,
92
+ stream: input.stream ?? "both",
93
+ repeat: input.repeat ?? false,
94
+ on: input.on ?? "turn",
157
95
  };
158
96
  }
159
97
 
160
- function normalizeAttention(
161
- input: unknown,
162
- path: string,
163
- ): Attention | undefined {
164
- return normalizeStringEnum(input, ATTENTIONS, path);
165
- }
166
-
167
- function normalizeStringEnum<const T extends readonly string[]>(
168
- input: unknown,
169
- allowed: T,
170
- path: string,
171
- ): T[number] | undefined {
172
- if (input === undefined || input === null) return undefined;
173
-
174
- if (typeof input !== "string" || !allowed.includes(input)) {
175
- throw new Error(`${path} must be one of: ${allowed.join(", ")}`);
176
- }
177
-
178
- return input;
179
- }
180
-
181
- function normalizeBoolean(input: unknown, path: string): boolean | undefined {
182
- if (input === undefined || input === null) return undefined;
183
-
184
- if (typeof input !== "boolean") {
185
- throw new Error(`${path} must be a boolean`);
186
- }
187
-
188
- return input;
189
- }
190
-
191
98
  function validateRegex(pattern: string, path: string): void {
192
99
  try {
193
100
  new RegExp(pattern);
@@ -48,7 +48,7 @@ export const PROCESS_NOTIFY_LOG_MATCH_STREAMS = [
48
48
  "both",
49
49
  ] as const;
50
50
 
51
- const NotifyLogMatchParams = Type.Object({
51
+ export const NotifyLogMatchParams = Type.Object({
52
52
  pattern: Type.String({
53
53
  maxLength: MAX_NOTIFY_PATTERN_LENGTH,
54
54
  description:
@@ -140,7 +140,7 @@ const NotifyProperties = {
140
140
  ),
141
141
  };
142
142
 
143
- const NotifyParams = Type.Object(NotifyProperties, {
143
+ export const NotifyParams = Type.Object(NotifyProperties, {
144
144
  description:
145
145
  "Notify settings. Attention: turn wakes an idle agent, context only reaches an agent still working, ignore never notifies.",
146
146
  });
@@ -248,6 +248,9 @@ export const ProcessesParams = Type.Object({
248
248
 
249
249
  export type ProcessesParamsType = Static<typeof ProcessesParams>;
250
250
 
251
+ export type NotifyParamsType = Static<typeof NotifyParams>;
252
+ export type NotifyLogMatchParamsType = Static<typeof NotifyLogMatchParams>;
253
+
251
254
  export type ProcessAction = ProcessesParamsType["action"];
252
255
  export type ProcessListStatusFilter =
253
256
  (typeof PROCESS_LIST_STATUS_FILTERS)[number];
@@ -1,7 +1,6 @@
1
1
  import type { ProcessManager } from "../../../../src/manager";
2
2
  import type { ProcessInfo } from "../../../../src/types";
3
3
  import { LIVE_STATUSES } from "../../../../src/types";
4
- import { isRecord } from "../../../../src/utils/is-record";
5
4
  import type {
6
5
  LogMatcherConfig,
7
6
  NotificationRegistry,
@@ -187,7 +186,7 @@ function applyWatchUpdate(
187
186
  }
188
187
 
189
188
  if (mode === "append" || mode === "replace") {
190
- const normalized = normalizeLogMatchItems(items, {
189
+ const normalized = normalizeLogMatchItems(items as never, {
191
190
  actionLabel: "process update",
192
191
  pathPrefix: "watches.items",
193
192
  });
@@ -205,46 +204,22 @@ function applyWatchUpdate(
205
204
  throw new Error(`unsupported watch update mode: ${String(mode)}`);
206
205
  }
207
206
 
208
- interface RawRemoveItem {
209
- index?: number;
210
- pattern?: string;
211
- mode?: string;
212
- stream?: string;
213
- repeat?: boolean;
214
- on?: string;
215
- }
216
-
217
- function normalizeWatchRemoveSpecs(items: unknown[]): WatchRemoveSpec[] {
218
- return items.map((raw, index) => {
219
- if (!isRecord(raw)) {
220
- throw new Error(
221
- `process update watches.items[${index}] must be an object`,
222
- );
223
- }
224
-
225
- const item = raw as RawRemoveItem;
226
-
207
+ // Watch items arrive already validated against the WatchUpdateItemParams
208
+ // schema (Pi validates tool call arguments before execute). Only semantic
209
+ // checks the schema cannot express remain here.
210
+ type WatchUpdateItem = NonNullable<
211
+ NonNullable<ProcessesParamsType["watches"]>["items"]
212
+ >[number];
213
+
214
+ function normalizeWatchRemoveSpecs(
215
+ items: WatchUpdateItem[],
216
+ ): WatchRemoveSpec[] {
217
+ return items.map((item, index) => {
227
218
  if (item.index !== undefined) {
228
- if (
229
- typeof item.index !== "number" ||
230
- !Number.isInteger(item.index) ||
231
- item.index < 0
232
- ) {
233
- throw new Error(
234
- `process update watches.items[${index}].index must be a non-negative integer`,
235
- );
236
- }
237
-
238
219
  return { index: item.index };
239
220
  }
240
221
 
241
222
  if (item.pattern !== undefined) {
242
- if (typeof item.pattern !== "string") {
243
- throw new Error(
244
- `process update watches.items[${index}].pattern must be a string`,
245
- );
246
- }
247
-
248
223
  if (item.pattern.trim().length === 0) {
249
224
  throw new Error(
250
225
  `process update watches.items[${index}].pattern must not be empty`,
@@ -265,10 +240,10 @@ function normalizeWatchRemoveSpecs(items: unknown[]): WatchRemoveSpec[] {
265
240
 
266
241
  return {
267
242
  pattern: item.pattern,
268
- mode: item.mode as WatchRemoveSpec["mode"],
269
- stream: item.stream as WatchRemoveSpec["stream"],
243
+ mode: item.mode,
244
+ stream: item.stream,
270
245
  repeat: item.repeat,
271
- on: item.on as WatchRemoveSpec["on"],
246
+ on: item.on,
272
247
  };
273
248
  }
274
249
 
@@ -0,0 +1,67 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+
3
+ import { requestStart } from "../processes/client";
4
+
5
+ /**
6
+ * Debug extension for pi-processes.
7
+ *
8
+ * This extension is NOT listed in `package.json` under `pi.extensions`.
9
+ * Load it explicitly via `pi -ne -e ~/pi-processes-debug/`.
10
+ *
11
+ * It provides "programmatic" control over process management by exposing
12
+ * slash commands that go through the core extension's protocol channels,
13
+ * so processes started here are fully visible to the agent's `process` tool.
14
+ * This lets you start a background process during debugging without needing
15
+ * to prompt the agent.
16
+ */
17
+ export default async function processesDebugExtension(
18
+ pi: ExtensionAPI,
19
+ ): Promise<void> {
20
+ const events = pi.events;
21
+
22
+ pi.registerCommand("debug:ps:start", {
23
+ description:
24
+ "Start a background process (debug). Usage: /debug:ps:start <name> <command>",
25
+ handler: async (args: string, ctx) => {
26
+ const parsed = parseStartArgs(args);
27
+ if (!parsed) {
28
+ ctx.ui.notify("Usage: /debug:ps:start <name> <command>", "warning");
29
+ return;
30
+ }
31
+
32
+ const result = await requestStart(events, {
33
+ name: parsed.name,
34
+ command: parsed.command,
35
+ cwd: ctx.cwd,
36
+ });
37
+
38
+ if (result.ok) {
39
+ ctx.ui.notify(
40
+ `Started ${result.process.name} (${result.process.id}) — pid ${result.process.pid}`,
41
+ "info",
42
+ );
43
+ } else {
44
+ ctx.ui.notify(`Failed to start process: ${result.error}`, "warning");
45
+ }
46
+ },
47
+ });
48
+ }
49
+
50
+ interface ParsedStartArgs {
51
+ name: string;
52
+ command: string;
53
+ }
54
+
55
+ function parseStartArgs(args: string): ParsedStartArgs | null {
56
+ const trimmed = args.trim();
57
+ if (!trimmed) return null;
58
+
59
+ const firstSpace = trimmed.indexOf(" ");
60
+ if (firstSpace === -1) return null;
61
+
62
+ const name = trimmed.slice(0, firstSpace);
63
+ const command = trimmed.slice(firstSpace + 1).trim();
64
+ if (!command) return null;
65
+
66
+ return { name, command };
67
+ }
@@ -1,4 +1,5 @@
1
1
  import type { EventBus } from "@earendil-works/pi-coding-agent";
2
+ import type { ProcessInfo } from "../../src/types";
2
3
  import {
3
4
  CHANNELS,
4
5
  type ProcessProtocolConfig,
@@ -6,8 +7,7 @@ import {
6
7
  type RequestConfigPayload,
7
8
  type RequestGetPayload,
8
9
  type RequestListPayload,
9
- } from "../../src/protocol";
10
- import type { ProcessInfo } from "../../src/types";
10
+ } from "../shared/protocol";
11
11
 
12
12
  export type ProcessLogLine = { type: "stdout" | "stderr"; text: string };
13
13
 
@@ -3,15 +3,14 @@ import type {
3
3
  ExtensionContext,
4
4
  Theme,
5
5
  } from "@earendil-works/pi-coding-agent";
6
+ import { LIVE_STATUSES, type ProcessInfo } from "../../../src/types";
7
+ import { buildDroppedOutputLine, trimToBudget } from "../../shared/line-buffer";
6
8
  import {
7
9
  CHANNELS,
8
10
  type CommandPinPayload,
11
+ type ProcessesOutputChangedPayload,
9
12
  type ProcessProtocolNotificationPayload,
10
- } from "../../../src/protocol";
11
- import { LIVE_STATUSES, type ProcessInfo } from "../../../src/types";
12
- import { isRecord } from "../../../src/utils/is-record";
13
- import { buildDroppedOutputLine, trimToBudget } from "../../shared/line-buffer";
14
- import { isOutputChangedPayload } from "../../shared/output-payload";
13
+ } from "../../shared/protocol";
15
14
  import {
16
15
  type ProcessLogLine,
17
16
  requestCombinedOutput,
@@ -342,6 +341,9 @@ export function setupDockWidgets(
342
341
  };
343
342
 
344
343
  const handleStarted = () => {
344
+ // Set synchronously so a process that exits within the scheduleRefresh
345
+ // throttle window still counts as "seen running" for auto-close.
346
+ hasSeenRunningProcess = true;
345
347
  if (config.widget.dockDefaultState === "expanded") state.actions.expand();
346
348
  else if (config.widget.dockDefaultState === "collapsed") {
347
349
  state.actions.collapse();
@@ -349,11 +351,8 @@ export function setupDockWidgets(
349
351
  scheduleRefresh();
350
352
  };
351
353
 
352
- const handleOutputChanged = (payload: unknown) => {
353
- if (!isOutputChangedPayload(payload)) {
354
- scheduleRefresh();
355
- return;
356
- }
354
+ const handleOutputChanged = (rawPayload: unknown) => {
355
+ const payload = rawPayload as ProcessesOutputChangedPayload;
357
356
  if (
358
357
  (!payload.appendedText || payload.appendedText.length === 0) &&
359
358
  !payload.droppedLines
@@ -383,7 +382,6 @@ export function setupDockWidgets(
383
382
 
384
383
  const handlePin = (payload: unknown) => {
385
384
  const command = payload as CommandPinPayload;
386
- if (!isCommandPinPayload(command)) return;
387
385
  // COMMAND_PIN can arrive before the dock's throttled CHANGED refresh has
388
386
  // run. Refresh the local snapshot first so expand/pin renders immediately
389
387
  // against the current process list.
@@ -427,8 +425,9 @@ export function setupDockWidgets(
427
425
  }),
428
426
  );
429
427
  disposers.push(
430
- events.on(CHANNELS.NOTIFICATION, (payload) => {
431
- if (!isLogMatchNotification(payload)) return;
428
+ events.on(CHANNELS.NOTIFICATION, (rawPayload) => {
429
+ const payload = rawPayload as ProcessProtocolNotificationPayload;
430
+ if (payload.kind !== "log_match" || !payload.logMatch) return;
432
431
  const list = notifyMarkers.get(payload.processId) ?? [];
433
432
  list.push({ line: payload.logMatch.line, timestamp: payload.timestamp });
434
433
  if (list.length > MAX_NOTIFY_MARKERS_PER_PROCESS) {
@@ -486,30 +485,6 @@ function isLogsConnectionError(
486
485
  return "ok" in connection && connection.ok === false;
487
486
  }
488
487
 
489
- function isLogMatchNotification(
490
- payload: unknown,
491
- ): payload is ProcessProtocolNotificationPayload & {
492
- kind: "log_match";
493
- logMatch: NonNullable<ProcessProtocolNotificationPayload["logMatch"]>;
494
- } {
495
- return (
496
- isRecord(payload) &&
497
- payload.kind === "log_match" &&
498
- typeof payload.processId === "string" &&
499
- typeof payload.timestamp === "number" &&
500
- isRecord(payload.logMatch) &&
501
- typeof payload.logMatch.line === "string"
502
- );
503
- }
504
-
505
- function isCommandPinPayload(payload: unknown): payload is CommandPinPayload {
506
- return (
507
- isRecord(payload) &&
508
- (typeof payload.id === "string" || payload.id === null) &&
509
- typeof payload.reply === "function"
510
- );
511
- }
512
-
513
488
  function safeReply<T>(reply: (result: T) => void, result: T): void {
514
489
  try {
515
490
  reply(result);
@@ -1,12 +1,12 @@
1
1
  import type { EventBus } from "@earendil-works/pi-coding-agent";
2
+ import type { ProcessInfo } from "../../src/types";
2
3
  import {
3
4
  CHANNELS,
4
5
  type ProcessProtocolConfig,
5
6
  type RequestConfigPayload,
6
7
  type RequestGetPayload,
7
8
  type RequestListPayload,
8
- } from "../../src/protocol";
9
- import type { ProcessInfo } from "../../src/types";
9
+ } from "../shared/protocol";
10
10
 
11
11
  export function requestProcessList(events: EventBus): ProcessInfo[] {
12
12
  let processes: ProcessInfo[] = [];
@@ -4,9 +4,9 @@ import type {
4
4
  ExtensionCommandContext,
5
5
  Theme,
6
6
  } from "@earendil-works/pi-coding-agent";
7
- import type { ProcessProtocolConfig } from "../../../src/protocol";
8
7
  import type { ProcessInfo } from "../../../src/types";
9
8
  import { sanitizeForDisplay } from "../../shared/display-text";
9
+ import type { ProcessProtocolConfig } from "../../shared/protocol";
10
10
  import { requestConfig, requestProcess, requestProcessList } from "../client";
11
11
  import { allProcessCompletions } from "../completions";
12
12
  import { LogOverlayComponent } from "../components/log-overlay-component";