@hayasaka7/haya-pet 0.1.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 (131) hide show
  1. package/.gitattributes +34 -0
  2. package/.github/workflows/release.yml +61 -0
  3. package/LICENSE +21 -0
  4. package/README.md +247 -0
  5. package/apps/cli/src/haya-pet.js +395 -0
  6. package/apps/cli/test/haya-pet.test.mjs +339 -0
  7. package/apps/companion/README.md +83 -0
  8. package/apps/companion/package.json +17 -0
  9. package/apps/companion/src/main/display-manager.js +71 -0
  10. package/apps/companion/src/main/index.js +349 -0
  11. package/apps/companion/src/main/lock-file.js +52 -0
  12. package/apps/companion/src/main/panel-placement.js +45 -0
  13. package/apps/companion/src/main/pet-loader.js +2 -0
  14. package/apps/companion/src/main/position-store.js +3 -0
  15. package/apps/companion/src/main/preload.cjs +13 -0
  16. package/apps/companion/src/main/state-file.js +2 -0
  17. package/apps/companion/src/main/terminal-helper-client.js +79 -0
  18. package/apps/companion/src/main/terminal-locator.js +44 -0
  19. package/apps/companion/src/main/tray-menu.js +79 -0
  20. package/apps/companion/src/main/window-options.js +66 -0
  21. package/apps/companion/src/renderer/index.html +18 -0
  22. package/apps/companion/src/renderer/interaction-controller.js +114 -0
  23. package/apps/companion/src/renderer/pet-window.js +275 -0
  24. package/apps/companion/src/renderer/session-bubbles.js +138 -0
  25. package/apps/companion/src/renderer/styles.css +225 -0
  26. package/apps/companion/src/renderer/task-talk-window.js +141 -0
  27. package/apps/companion/test/display-manager.test.mjs +48 -0
  28. package/apps/companion/test/interaction-controller.test.mjs +107 -0
  29. package/apps/companion/test/panel-placement.test.mjs +60 -0
  30. package/apps/companion/test/position-store.test.mjs +54 -0
  31. package/apps/companion/test/state-file.test.mjs +52 -0
  32. package/apps/companion/test/terminal-helper-client.test.mjs +68 -0
  33. package/apps/companion/test/terminal-locator.test.mjs +35 -0
  34. package/apps/companion/test/tray-menu.test.mjs +45 -0
  35. package/apps/companion/test/window-options.test.mjs +62 -0
  36. package/apps/pet-preview/index.html +42 -0
  37. package/apps/pet-preview/src/preview-app.js +123 -0
  38. package/apps/pet-preview/src/preview-state.js +70 -0
  39. package/apps/pet-preview/src/preview.css +125 -0
  40. package/apps/pet-preview/test/preview-state.test.mjs +62 -0
  41. package/assets/fallback-pet/README.md +16 -0
  42. package/assets/fallback-pet/pet.json +13 -0
  43. package/docs/architecture.md +144 -0
  44. package/docs/known-issues.md +49 -0
  45. package/docs/publishing.md +48 -0
  46. package/docs/screenshots/README.md +7 -0
  47. package/docs/screenshots/folder-collapsed.png +0 -0
  48. package/docs/screenshots/hero.png +0 -0
  49. package/docs/screenshots/pet-overlay.png +0 -0
  50. package/docs/screenshots/session-bubbles.png +0 -0
  51. package/docs/screenshots/tray-menu.png +0 -0
  52. package/docs/troubleshooting.md +36 -0
  53. package/native/README.md +80 -0
  54. package/native/linux-window-helper/README.md +29 -0
  55. package/native/mac-window-helper/README.md +30 -0
  56. package/native/win-window-helper/Program.cs +312 -0
  57. package/native/win-window-helper/README.md +53 -0
  58. package/native/win-window-helper/win-window-helper.csproj +12 -0
  59. package/package.json +35 -0
  60. package/packages/adapters/src/adapter-info.js +61 -0
  61. package/packages/adapters/src/capabilities.js +39 -0
  62. package/packages/adapters/src/heuristics.js +114 -0
  63. package/packages/adapters/src/output-observer.js +164 -0
  64. package/packages/adapters/src/routing.js +86 -0
  65. package/packages/adapters/test/adapter-info.test.mjs +35 -0
  66. package/packages/adapters/test/capabilities.test.mjs +44 -0
  67. package/packages/adapters/test/heuristics.test.mjs +42 -0
  68. package/packages/adapters/test/output-observer.test.mjs +142 -0
  69. package/packages/adapters/test/routing.test.mjs +93 -0
  70. package/packages/app-state/src/state-file.js +53 -0
  71. package/packages/app-state/src/state.js +80 -0
  72. package/packages/app-state/test/state.test.mjs +36 -0
  73. package/packages/cli-core/src/companion-launcher.js +69 -0
  74. package/packages/cli-core/src/pty-runner.js +96 -0
  75. package/packages/cli-core/src/run-command.js +353 -0
  76. package/packages/cli-core/src/strip-ansi.js +16 -0
  77. package/packages/cli-core/test/companion-launcher.test.mjs +98 -0
  78. package/packages/cli-core/test/run-command.test.mjs +177 -0
  79. package/packages/cli-core/test/strip-ansi.test.mjs +27 -0
  80. package/packages/daemon-core/src/daemon-runtime.js +49 -0
  81. package/packages/daemon-core/src/ipc-server.js +180 -0
  82. package/packages/daemon-core/src/ipc-transport.js +70 -0
  83. package/packages/daemon-core/src/singleton.js +46 -0
  84. package/packages/daemon-core/test/daemon-runtime.test.mjs +65 -0
  85. package/packages/daemon-core/test/ipc-server.test.mjs +70 -0
  86. package/packages/daemon-core/test/ipc-transport.test.mjs +72 -0
  87. package/packages/daemon-core/test/singleton.test.mjs +32 -0
  88. package/packages/pet-core/src/animation-state.js +84 -0
  89. package/packages/pet-core/src/animator.js +26 -0
  90. package/packages/pet-core/src/atlas.js +81 -0
  91. package/packages/pet-core/src/discovery.js +90 -0
  92. package/packages/pet-core/src/manifest.js +112 -0
  93. package/packages/pet-core/src/validation.js +43 -0
  94. package/packages/pet-core/test/animation-state.test.mjs +47 -0
  95. package/packages/pet-core/test/animator.test.mjs +31 -0
  96. package/packages/pet-core/test/atlas.test.mjs +81 -0
  97. package/packages/pet-core/test/discovery.test.mjs +93 -0
  98. package/packages/pet-core/test/manifest.test.mjs +93 -0
  99. package/packages/pet-core/test/validation.test.mjs +69 -0
  100. package/packages/platform-core/src/capabilities.js +49 -0
  101. package/packages/platform-core/src/paths.js +75 -0
  102. package/packages/platform-core/src/platform.js +15 -0
  103. package/packages/platform-core/test/platform.test.mjs +84 -0
  104. package/packages/protocol/src/messages.js +156 -0
  105. package/packages/protocol/test/messages.test.mjs +112 -0
  106. package/packages/session-core/src/bubble-linger.js +47 -0
  107. package/packages/session-core/src/bubble-view.js +79 -0
  108. package/packages/session-core/src/pet-state.js +56 -0
  109. package/packages/session-core/src/priority.js +56 -0
  110. package/packages/session-core/src/registry.js +144 -0
  111. package/packages/session-core/src/summaries.js +54 -0
  112. package/packages/session-core/test/bubble-linger.test.mjs +96 -0
  113. package/packages/session-core/test/bubble-view.test.mjs +79 -0
  114. package/packages/session-core/test/pet-state.test.mjs +118 -0
  115. package/packages/session-core/test/priority.test.mjs +53 -0
  116. package/packages/session-core/test/registry.test.mjs +161 -0
  117. package/packages/session-core/test/summaries.test.mjs +38 -0
  118. package/packages/task-core/src/approvals.js +91 -0
  119. package/packages/task-core/src/controls.js +61 -0
  120. package/packages/task-core/src/replies.js +80 -0
  121. package/packages/task-core/src/task-events.js +101 -0
  122. package/packages/task-core/src/task-status.js +93 -0
  123. package/packages/task-core/src/task-store.js +74 -0
  124. package/packages/task-core/test/approvals.test.mjs +61 -0
  125. package/packages/task-core/test/controls.test.mjs +61 -0
  126. package/packages/task-core/test/replies.test.mjs +51 -0
  127. package/packages/task-core/test/task-events.test.mjs +67 -0
  128. package/packages/task-core/test/task-status.test.mjs +49 -0
  129. package/packages/task-core/test/task-store.test.mjs +65 -0
  130. package/test/harness.mjs +22 -0
  131. package/test/run-tests.mjs +47 -0
@@ -0,0 +1,80 @@
1
+ // Pure runtime state shape shared by the companion app and the CLI: global pet
2
+ // position/selection, per-session bubble positions, and settings. All updates
3
+ // return new objects (no mutation).
4
+
5
+ export function createDefaultPositionState() {
6
+ return {
7
+ globalPet: {
8
+ open: true,
9
+ selectedPetId: undefined,
10
+ manual: false
11
+ },
12
+ sessions: {},
13
+ settings: {
14
+ displayMode: "hybrid",
15
+ attachBubblesToTerminals: true
16
+ }
17
+ };
18
+ }
19
+
20
+ export function updateGlobalPetPosition(state, position) {
21
+ return {
22
+ ...state,
23
+ globalPet: {
24
+ ...state.globalPet,
25
+ x: position.x,
26
+ y: position.y,
27
+ width: position.width,
28
+ height: position.height,
29
+ displayId: position.displayId,
30
+ manual: true
31
+ }
32
+ };
33
+ }
34
+
35
+ export function setSelectedPet(state, petId) {
36
+ return {
37
+ ...state,
38
+ globalPet: {
39
+ ...state.globalPet,
40
+ selectedPetId: petId
41
+ }
42
+ };
43
+ }
44
+
45
+ export function getSelectedPetId(state) {
46
+ return state?.globalPet?.selectedPetId;
47
+ }
48
+
49
+ export function serializePositionState(state) {
50
+ return `${JSON.stringify(state, null, 2)}\n`;
51
+ }
52
+
53
+ export function parsePositionState(text) {
54
+ const defaults = createDefaultPositionState();
55
+
56
+ try {
57
+ const parsed = JSON.parse(text);
58
+ if (!isPlainObject(parsed) || !isPlainObject(parsed.globalPet)) {
59
+ return defaults;
60
+ }
61
+
62
+ return {
63
+ globalPet: {
64
+ ...defaults.globalPet,
65
+ ...parsed.globalPet
66
+ },
67
+ sessions: isPlainObject(parsed.sessions) ? parsed.sessions : defaults.sessions,
68
+ settings: {
69
+ ...defaults.settings,
70
+ ...(isPlainObject(parsed.settings) ? parsed.settings : {})
71
+ }
72
+ };
73
+ } catch {
74
+ return defaults;
75
+ }
76
+ }
77
+
78
+ function isPlainObject(value) {
79
+ return typeof value === "object" && value !== null && !Array.isArray(value);
80
+ }
@@ -0,0 +1,36 @@
1
+ import assert from "node:assert/strict";
2
+ import { test } from "../../../test/harness.mjs";
3
+ import {
4
+ createDefaultPositionState,
5
+ getSelectedPetId,
6
+ setSelectedPet
7
+ } from "../src/state.js";
8
+
9
+ test("default state has no selected pet", () => {
10
+ assert.equal(getSelectedPetId(createDefaultPositionState()), undefined);
11
+ });
12
+
13
+ test("setSelectedPet stores the id immutably", () => {
14
+ const state = createDefaultPositionState();
15
+ const next = setSelectedPet(state, "cat");
16
+
17
+ assert.equal(getSelectedPetId(next), "cat");
18
+ // original is untouched
19
+ assert.equal(getSelectedPetId(state), undefined);
20
+ assert.notEqual(next.globalPet, state.globalPet);
21
+ });
22
+
23
+ test("setSelectedPet preserves other globalPet fields", () => {
24
+ const state = { ...createDefaultPositionState(), globalPet: { open: true, x: 10, y: 20, manual: true } };
25
+ const next = setSelectedPet(state, "dog");
26
+
27
+ assert.equal(next.globalPet.x, 10);
28
+ assert.equal(next.globalPet.y, 20);
29
+ assert.equal(next.globalPet.manual, true);
30
+ assert.equal(next.globalPet.selectedPetId, "dog");
31
+ });
32
+
33
+ test("getSelectedPetId tolerates missing state", () => {
34
+ assert.equal(getSelectedPetId(undefined), undefined);
35
+ assert.equal(getSelectedPetId({}), undefined);
36
+ });
@@ -0,0 +1,69 @@
1
+ // Orchestrates "connect to the companion, auto-starting it if it isn't running"
2
+ // so `haya-pet run` works without anyone manually launching the overlay first.
3
+ //
4
+ // Pure and dependency-injected: the caller supplies how to `connect` (open an
5
+ // IPC client, throwing if nothing is listening) and how to `launch` (spawn the
6
+ // companion). This keeps the real Electron/IPC wiring out of the tested logic.
7
+
8
+ const DEFAULT_ATTEMPTS = 25; // attempts * intervalMs ≈ how long we wait for boot
9
+ const DEFAULT_INTERVAL_MS = 200;
10
+
11
+ export async function ensureCompanionConnection({
12
+ connect,
13
+ launch,
14
+ autoStart = true,
15
+ attempts = DEFAULT_ATTEMPTS,
16
+ intervalMs = DEFAULT_INTERVAL_MS,
17
+ sleep = defaultSleep
18
+ } = {}) {
19
+ if (typeof connect !== "function") {
20
+ throw new TypeError("connect must be a function");
21
+ }
22
+
23
+ // 1) Already running? Use it as-is.
24
+ const existing = await tryConnect(connect);
25
+ if (existing) {
26
+ return { client: existing, started: false };
27
+ }
28
+
29
+ if (!autoStart || typeof launch !== "function") {
30
+ return { client: null, started: false };
31
+ }
32
+
33
+ // 2) Launch the companion. A launch failure (e.g. Electron not installed)
34
+ // must not break the wrapped command — degrade to "no pet".
35
+ try {
36
+ await launch();
37
+ } catch (error) {
38
+ return { client: null, started: false, error };
39
+ }
40
+
41
+ // 3) Poll until it's listening, or give up (still letting the command run).
42
+ for (let i = 0; i < attempts; i += 1) {
43
+ await sleep(intervalMs);
44
+ const client = await tryConnect(connect);
45
+ if (client) {
46
+ return { client, started: true };
47
+ }
48
+ }
49
+
50
+ return { client: null, started: false, timedOut: true };
51
+ }
52
+
53
+ async function tryConnect(connect) {
54
+ try {
55
+ return await connect();
56
+ } catch {
57
+ return null;
58
+ }
59
+ }
60
+
61
+ function defaultSleep(ms) {
62
+ // Deliberately NOT unref'd: during the connect-retry poll this timer is often
63
+ // the only pending handle, and an unref'd timer would let Node exit the
64
+ // process mid-poll (before the wrapped command ever runs). The timer is
65
+ // short-lived and always settles, so it can't hang the process.
66
+ return new Promise((resolve) => {
67
+ setTimeout(resolve, ms);
68
+ });
69
+ }
@@ -0,0 +1,96 @@
1
+ // Thin wrapper around the optional `node-pty` native module. Runs a command in
2
+ // a real pseudo-terminal so interactive CLIs keep their TTY (colors, prompts,
3
+ // TUIs) while we also observe a copy of the output. node-pty is loaded lazily
4
+ // and treated as optional — callers fall back to a plain spawn if it is absent.
5
+
6
+ let ptyModulePromise;
7
+
8
+ async function loadPty() {
9
+ if (!ptyModulePromise) {
10
+ ptyModulePromise = import("node-pty")
11
+ .then((mod) => mod.default ?? mod)
12
+ .catch(() => null);
13
+ }
14
+ return ptyModulePromise;
15
+ }
16
+
17
+ export async function isPtyAvailable() {
18
+ return Boolean(await loadPty());
19
+ }
20
+
21
+ export async function spawnPty({ command, args = [], cwd, env = process.env, onData, onInput, cols, rows } = {}) {
22
+ const pty = await loadPty();
23
+ if (!pty) {
24
+ throw new Error("node-pty is not available");
25
+ }
26
+
27
+ const child = pty.spawn(command, args, {
28
+ name: "xterm-color",
29
+ cols: cols ?? process.stdout.columns ?? 80,
30
+ rows: rows ?? process.stdout.rows ?? 30,
31
+ cwd,
32
+ env
33
+ });
34
+
35
+ // Tee PTY output: write to the terminal and hand a copy to the observer.
36
+ child.onData((data) => {
37
+ process.stdout.write(data);
38
+ if (onData) {
39
+ onData(data);
40
+ }
41
+ });
42
+
43
+ // Forward the user's keystrokes into the PTY.
44
+ const stdin = process.stdin;
45
+ const hadRawMode = stdin.isRaw;
46
+ if (stdin.isTTY && typeof stdin.setRawMode === "function") {
47
+ stdin.setRawMode(true);
48
+ }
49
+ stdin.resume();
50
+ // Don't let an open stdin keep the process alive after the child exits — the
51
+ // PTY handle keeps the event loop alive while the command is actually running.
52
+ if (typeof stdin.unref === "function") {
53
+ stdin.unref();
54
+ }
55
+ const forwardInput = (chunk) => {
56
+ if (onInput) {
57
+ // Let the observer know the user is typing, so it doesn't mistake the
58
+ // PTY's echo of these keystrokes for AI activity.
59
+ onInput(chunk);
60
+ }
61
+ child.write(chunk.toString("utf8"));
62
+ };
63
+ stdin.on("data", forwardInput);
64
+
65
+ const handleResize = () => {
66
+ try {
67
+ child.resize(process.stdout.columns ?? 80, process.stdout.rows ?? 30);
68
+ } catch {
69
+ // ignore transient resize errors
70
+ }
71
+ };
72
+ process.stdout.on("resize", handleResize);
73
+
74
+ const exit = new Promise((resolve) => {
75
+ child.onExit(({ exitCode, signal }) => {
76
+ stdin.off("data", forwardInput);
77
+ if (stdin.isTTY && typeof stdin.setRawMode === "function") {
78
+ try {
79
+ stdin.setRawMode(Boolean(hadRawMode));
80
+ } catch {
81
+ // ignore
82
+ }
83
+ }
84
+ stdin.pause();
85
+ process.stdout.off("resize", handleResize);
86
+ resolve({ exitCode, signal });
87
+ });
88
+ });
89
+
90
+ return {
91
+ pid: child.pid,
92
+ exit,
93
+ write: (data) => child.write(data),
94
+ kill: () => child.kill()
95
+ };
96
+ }
@@ -0,0 +1,353 @@
1
+ import { spawn } from "node:child_process";
2
+ import { randomUUID } from "node:crypto";
3
+ import { basename } from "node:path";
4
+ import { assertProtocolMessage } from "../../protocol/src/messages.js";
5
+ import { createOutputObserver } from "../../adapters/src/output-observer.js";
6
+ import { stripAnsi } from "./strip-ansi.js";
7
+ import { isPtyAvailable, spawnPty as defaultSpawnPty } from "./pty-runner.js";
8
+
9
+ const DEFAULT_HEARTBEAT_INTERVAL_MS = 5_000;
10
+
11
+ export async function runGenericCommand(options) {
12
+ const {
13
+ command,
14
+ args = [],
15
+ cwd = process.cwd(),
16
+ clientId = "generic",
17
+ clientDisplayName = "Generic",
18
+ sessionId = `sess_${randomUUID()}`,
19
+ heartbeatIntervalMs = DEFAULT_HEARTBEAT_INTERVAL_MS,
20
+ now = Date.now,
21
+ send,
22
+ stdio = "inherit",
23
+ shell: shellOption,
24
+ observe = false,
25
+ spawnPty
26
+ } = options ?? {};
27
+
28
+ if (typeof command !== "string" || command.trim() === "") {
29
+ throw new Error("command must be a non-empty string");
30
+ }
31
+
32
+ if (!Array.isArray(args)) {
33
+ throw new TypeError("args must be an array");
34
+ }
35
+
36
+ if (typeof send !== "function") {
37
+ throw new TypeError("send must be a function");
38
+ }
39
+
40
+ // On Windows, AI CLIs are usually .cmd/.ps1 shims (e.g. `claude`, `codex`),
41
+ // which neither Node's spawn nor node-pty can launch directly — run them
42
+ // through the shell so PATHEXT resolution works. Direct .exe paths (incl.
43
+ // process.execPath in tests) keep shell off so argument quoting is untouched.
44
+ const isWindows = process.platform === "win32";
45
+ const useShell = shellOption ?? (isWindows && !/\.(exe|com)$/i.test(command));
46
+
47
+ // Level 2: observe the CLI's output through a PTY to infer live state
48
+ // (thinking / running_tool / waiting_approval / ...). Only when a PTY launcher
49
+ // is available; otherwise we fall back to the plain wrapper.
50
+ if (observe) {
51
+ const ptyLauncher = spawnPty ?? ((await isPtyAvailable()) ? defaultSpawnPty : undefined);
52
+ if (ptyLauncher) {
53
+ return runObservedCommand({
54
+ command,
55
+ args,
56
+ cwd,
57
+ useShell,
58
+ clientId,
59
+ clientDisplayName,
60
+ sessionId,
61
+ heartbeatIntervalMs,
62
+ now,
63
+ send,
64
+ spawnPty: ptyLauncher
65
+ });
66
+ }
67
+ process.stderr.write(
68
+ "haya-pet: live observation requested but node-pty is unavailable; running without live state.\n"
69
+ );
70
+ }
71
+
72
+ // With shell:true Node concatenates an args array without escaping (DEP0190),
73
+ // so when we need a shell we pass a single, pre-quoted command string instead.
74
+ const child = useShell
75
+ ? spawn(buildShellCommand(command, args), { cwd, stdio, shell: true })
76
+ : spawn(command, args, { cwd, stdio });
77
+ const closePromise = waitForClose(child);
78
+
79
+ if (!child.pid) {
80
+ // The command could not be launched at all (e.g. not found). Don't send an
81
+ // invalid register message; report the failure and preserve the exit code.
82
+ const closeResult = await closePromise;
83
+ return {
84
+ sessionId,
85
+ pid: undefined,
86
+ exitCode: normalizeExitCode(closeResult),
87
+ signal: closeResult.signal
88
+ };
89
+ }
90
+
91
+ const startedAt = now();
92
+ const projectName = basename(cwd) || cwd;
93
+
94
+ await sendProtocolMessage(send, {
95
+ type: "register",
96
+ sessionId,
97
+ clientId,
98
+ clientDisplayName,
99
+ pid: child.pid,
100
+ cwd,
101
+ projectName,
102
+ startedAt
103
+ });
104
+
105
+ // A Level 1 wrapper cannot observe what the CLI is actually doing, so it must
106
+ // not claim "running". "idle" = session exists but no active work detected.
107
+ // PTY/log adapters refine this later.
108
+ await sendProtocolMessage(send, {
109
+ type: "state",
110
+ sessionId,
111
+ state: "idle",
112
+ summary: "session started",
113
+ confidence: 0.4,
114
+ source: "wrapper",
115
+ updatedAt: now()
116
+ });
117
+
118
+ await sendProtocolMessage(send, {
119
+ type: "heartbeat",
120
+ sessionId,
121
+ updatedAt: now()
122
+ });
123
+
124
+ const timer = setInterval(() => {
125
+ sendProtocolMessage(send, {
126
+ type: "heartbeat",
127
+ sessionId,
128
+ updatedAt: now()
129
+ }).catch(() => {
130
+ clearInterval(timer);
131
+ });
132
+ }, heartbeatIntervalMs);
133
+
134
+ if (typeof timer.unref === "function") {
135
+ timer.unref();
136
+ }
137
+
138
+ const closeResult = await closePromise;
139
+ clearInterval(timer);
140
+
141
+ const exitCode = normalizeExitCode(closeResult);
142
+ const finalState = exitCode === 0 ? "success" : "failed";
143
+ const summary = buildExitSummary(closeResult, exitCode);
144
+
145
+ await sendProtocolMessage(send, {
146
+ type: "state",
147
+ sessionId,
148
+ state: finalState,
149
+ summary,
150
+ confidence: 1,
151
+ source: "wrapper",
152
+ updatedAt: now()
153
+ });
154
+
155
+ await sendProtocolMessage(send, {
156
+ type: "unregister",
157
+ sessionId,
158
+ exitCode,
159
+ finishedAt: now()
160
+ });
161
+
162
+ return {
163
+ sessionId,
164
+ pid: child.pid,
165
+ exitCode,
166
+ signal: closeResult.signal
167
+ };
168
+ }
169
+
170
+ async function runObservedCommand({
171
+ command,
172
+ args,
173
+ cwd,
174
+ useShell,
175
+ clientId,
176
+ clientDisplayName,
177
+ sessionId,
178
+ heartbeatIntervalMs,
179
+ now,
180
+ send,
181
+ spawnPty
182
+ }) {
183
+ const startedAt = now();
184
+ const projectName = basename(cwd) || cwd;
185
+
186
+ // node-pty cannot resolve .cmd/.ps1 shims either, so wrap them in the shell.
187
+ let ptyCommand = command;
188
+ let ptyArgs = args;
189
+ if (useShell) {
190
+ ptyCommand = process.env.ComSpec || "cmd.exe";
191
+ ptyArgs = ["/d", "/s", "/c", buildShellCommand(command, args)];
192
+ }
193
+
194
+ // Buffer observed state until the session is registered, so the daemon never
195
+ // receives a state for an unknown session (output can arrive before register).
196
+ let registered = false;
197
+ let pendingState;
198
+
199
+ const emitState = (event) => {
200
+ sendProtocolMessage(send, {
201
+ type: "state",
202
+ sessionId,
203
+ state: event.state,
204
+ summary: event.summary,
205
+ confidence: event.confidence,
206
+ source: "pty_output",
207
+ updatedAt: event.updatedAt
208
+ }).catch(() => {});
209
+ };
210
+
211
+ const observer = createOutputObserver({
212
+ clientId,
213
+ now,
214
+ onState: (event) => {
215
+ if (registered) {
216
+ emitState(event);
217
+ } else {
218
+ pendingState = event;
219
+ }
220
+ }
221
+ });
222
+
223
+ const handle = await spawnPty({
224
+ command: ptyCommand,
225
+ args: ptyArgs,
226
+ cwd,
227
+ onData: (data) => observer.push(stripAnsi(data.toString())),
228
+ onInput: () => observer.noteInput()
229
+ });
230
+
231
+ await sendProtocolMessage(send, {
232
+ type: "register",
233
+ sessionId,
234
+ clientId,
235
+ clientDisplayName,
236
+ pid: handle.pid,
237
+ cwd,
238
+ projectName,
239
+ startedAt
240
+ });
241
+
242
+ await sendProtocolMessage(send, {
243
+ type: "state",
244
+ sessionId,
245
+ state: "idle",
246
+ summary: "session started",
247
+ confidence: 0.4,
248
+ source: "wrapper",
249
+ updatedAt: now()
250
+ });
251
+
252
+ registered = true;
253
+ if (pendingState) {
254
+ emitState(pendingState);
255
+ pendingState = undefined;
256
+ }
257
+
258
+ await sendProtocolMessage(send, { type: "heartbeat", sessionId, updatedAt: now() });
259
+
260
+ const timer = setInterval(() => {
261
+ sendProtocolMessage(send, { type: "heartbeat", sessionId, updatedAt: now() }).catch(() => {
262
+ clearInterval(timer);
263
+ });
264
+ }, heartbeatIntervalMs);
265
+ if (typeof timer.unref === "function") {
266
+ timer.unref();
267
+ }
268
+
269
+ const { exitCode, signal } = await handle.exit;
270
+ clearInterval(timer);
271
+ observer.stop();
272
+
273
+ const finalExitCode = Number.isInteger(exitCode) ? exitCode : 1;
274
+ const finalState = finalExitCode === 0 ? "success" : "failed";
275
+
276
+ await sendProtocolMessage(send, {
277
+ type: "state",
278
+ sessionId,
279
+ state: finalState,
280
+ summary: finalExitCode === 0 ? "process exited successfully" : `process exited with code ${finalExitCode}`,
281
+ confidence: 1,
282
+ source: "wrapper",
283
+ updatedAt: now()
284
+ });
285
+
286
+ await sendProtocolMessage(send, {
287
+ type: "unregister",
288
+ sessionId,
289
+ exitCode: finalExitCode,
290
+ finishedAt: now()
291
+ });
292
+
293
+ return { sessionId, pid: handle.pid, exitCode: finalExitCode, signal };
294
+ }
295
+
296
+ async function sendProtocolMessage(send, message) {
297
+ await send(assertProtocolMessage(message));
298
+ }
299
+
300
+ function waitForClose(child) {
301
+ return new Promise((resolve) => {
302
+ let spawnError;
303
+
304
+ child.once("error", (error) => {
305
+ spawnError = error;
306
+ });
307
+
308
+ child.once("close", (code, signal) => {
309
+ resolve({ code, signal, error: spawnError });
310
+ });
311
+ });
312
+ }
313
+
314
+ // Builds a single shell command line with each token quoted for the Windows
315
+ // command processor (the only platform we default the shell on).
316
+ function buildShellCommand(command, args) {
317
+ return [command, ...args].map(quoteShellArg).join(" ");
318
+ }
319
+
320
+ function quoteShellArg(arg) {
321
+ const value = String(arg);
322
+ if (value === "") {
323
+ return '""';
324
+ }
325
+ if (!/[\s"&|<>^()%!]/.test(value)) {
326
+ return value;
327
+ }
328
+ return `"${value.replace(/"/g, '""')}"`;
329
+ }
330
+
331
+ function normalizeExitCode(closeResult) {
332
+ if (Number.isInteger(closeResult.code)) {
333
+ return closeResult.code;
334
+ }
335
+
336
+ return 1;
337
+ }
338
+
339
+ function buildExitSummary(closeResult, exitCode) {
340
+ if (closeResult.error) {
341
+ return `process failed to start: ${closeResult.error.message}`;
342
+ }
343
+
344
+ if (closeResult.signal) {
345
+ return `process terminated by signal ${closeResult.signal}`;
346
+ }
347
+
348
+ if (exitCode === 0) {
349
+ return "process exited successfully";
350
+ }
351
+
352
+ return `process exited with code ${exitCode}`;
353
+ }
@@ -0,0 +1,16 @@
1
+ // Removes ANSI/VT escape sequences (CSI, OSC, and single-char escapes) that a
2
+ // PTY emits, so the output observer matches against plain text. Newlines are
3
+ // preserved for line splitting.
4
+ const ANSI_PATTERN = new RegExp(
5
+ [
6
+ // OSC sequences: ESC ] ... BEL or ESC ] ... ESC \
7
+ "[\\u001B\\u009B]\\][^\\u0007\\u001B]*(?:\\u0007|\\u001B\\\\)",
8
+ // CSI and other escape sequences
9
+ "[\\u001B\\u009B][[\\]()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-PR-TZcf-ntqry=><~]"
10
+ ].join("|"),
11
+ "g"
12
+ );
13
+
14
+ export function stripAnsi(input) {
15
+ return String(input).replace(ANSI_PATTERN, "");
16
+ }