@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.
- package/.gitattributes +34 -0
- package/.github/workflows/release.yml +61 -0
- package/LICENSE +21 -0
- package/README.md +247 -0
- package/apps/cli/src/haya-pet.js +395 -0
- package/apps/cli/test/haya-pet.test.mjs +339 -0
- package/apps/companion/README.md +83 -0
- package/apps/companion/package.json +17 -0
- package/apps/companion/src/main/display-manager.js +71 -0
- package/apps/companion/src/main/index.js +349 -0
- package/apps/companion/src/main/lock-file.js +52 -0
- package/apps/companion/src/main/panel-placement.js +45 -0
- package/apps/companion/src/main/pet-loader.js +2 -0
- package/apps/companion/src/main/position-store.js +3 -0
- package/apps/companion/src/main/preload.cjs +13 -0
- package/apps/companion/src/main/state-file.js +2 -0
- package/apps/companion/src/main/terminal-helper-client.js +79 -0
- package/apps/companion/src/main/terminal-locator.js +44 -0
- package/apps/companion/src/main/tray-menu.js +79 -0
- package/apps/companion/src/main/window-options.js +66 -0
- package/apps/companion/src/renderer/index.html +18 -0
- package/apps/companion/src/renderer/interaction-controller.js +114 -0
- package/apps/companion/src/renderer/pet-window.js +275 -0
- package/apps/companion/src/renderer/session-bubbles.js +138 -0
- package/apps/companion/src/renderer/styles.css +225 -0
- package/apps/companion/src/renderer/task-talk-window.js +141 -0
- package/apps/companion/test/display-manager.test.mjs +48 -0
- package/apps/companion/test/interaction-controller.test.mjs +107 -0
- package/apps/companion/test/panel-placement.test.mjs +60 -0
- package/apps/companion/test/position-store.test.mjs +54 -0
- package/apps/companion/test/state-file.test.mjs +52 -0
- package/apps/companion/test/terminal-helper-client.test.mjs +68 -0
- package/apps/companion/test/terminal-locator.test.mjs +35 -0
- package/apps/companion/test/tray-menu.test.mjs +45 -0
- package/apps/companion/test/window-options.test.mjs +62 -0
- package/apps/pet-preview/index.html +42 -0
- package/apps/pet-preview/src/preview-app.js +123 -0
- package/apps/pet-preview/src/preview-state.js +70 -0
- package/apps/pet-preview/src/preview.css +125 -0
- package/apps/pet-preview/test/preview-state.test.mjs +62 -0
- package/assets/fallback-pet/README.md +16 -0
- package/assets/fallback-pet/pet.json +13 -0
- package/docs/architecture.md +144 -0
- package/docs/known-issues.md +49 -0
- package/docs/publishing.md +48 -0
- package/docs/screenshots/README.md +7 -0
- package/docs/screenshots/folder-collapsed.png +0 -0
- package/docs/screenshots/hero.png +0 -0
- package/docs/screenshots/pet-overlay.png +0 -0
- package/docs/screenshots/session-bubbles.png +0 -0
- package/docs/screenshots/tray-menu.png +0 -0
- package/docs/troubleshooting.md +36 -0
- package/native/README.md +80 -0
- package/native/linux-window-helper/README.md +29 -0
- package/native/mac-window-helper/README.md +30 -0
- package/native/win-window-helper/Program.cs +312 -0
- package/native/win-window-helper/README.md +53 -0
- package/native/win-window-helper/win-window-helper.csproj +12 -0
- package/package.json +35 -0
- package/packages/adapters/src/adapter-info.js +61 -0
- package/packages/adapters/src/capabilities.js +39 -0
- package/packages/adapters/src/heuristics.js +114 -0
- package/packages/adapters/src/output-observer.js +164 -0
- package/packages/adapters/src/routing.js +86 -0
- package/packages/adapters/test/adapter-info.test.mjs +35 -0
- package/packages/adapters/test/capabilities.test.mjs +44 -0
- package/packages/adapters/test/heuristics.test.mjs +42 -0
- package/packages/adapters/test/output-observer.test.mjs +142 -0
- package/packages/adapters/test/routing.test.mjs +93 -0
- package/packages/app-state/src/state-file.js +53 -0
- package/packages/app-state/src/state.js +80 -0
- package/packages/app-state/test/state.test.mjs +36 -0
- package/packages/cli-core/src/companion-launcher.js +69 -0
- package/packages/cli-core/src/pty-runner.js +96 -0
- package/packages/cli-core/src/run-command.js +353 -0
- package/packages/cli-core/src/strip-ansi.js +16 -0
- package/packages/cli-core/test/companion-launcher.test.mjs +98 -0
- package/packages/cli-core/test/run-command.test.mjs +177 -0
- package/packages/cli-core/test/strip-ansi.test.mjs +27 -0
- package/packages/daemon-core/src/daemon-runtime.js +49 -0
- package/packages/daemon-core/src/ipc-server.js +180 -0
- package/packages/daemon-core/src/ipc-transport.js +70 -0
- package/packages/daemon-core/src/singleton.js +46 -0
- package/packages/daemon-core/test/daemon-runtime.test.mjs +65 -0
- package/packages/daemon-core/test/ipc-server.test.mjs +70 -0
- package/packages/daemon-core/test/ipc-transport.test.mjs +72 -0
- package/packages/daemon-core/test/singleton.test.mjs +32 -0
- package/packages/pet-core/src/animation-state.js +84 -0
- package/packages/pet-core/src/animator.js +26 -0
- package/packages/pet-core/src/atlas.js +81 -0
- package/packages/pet-core/src/discovery.js +90 -0
- package/packages/pet-core/src/manifest.js +112 -0
- package/packages/pet-core/src/validation.js +43 -0
- package/packages/pet-core/test/animation-state.test.mjs +47 -0
- package/packages/pet-core/test/animator.test.mjs +31 -0
- package/packages/pet-core/test/atlas.test.mjs +81 -0
- package/packages/pet-core/test/discovery.test.mjs +93 -0
- package/packages/pet-core/test/manifest.test.mjs +93 -0
- package/packages/pet-core/test/validation.test.mjs +69 -0
- package/packages/platform-core/src/capabilities.js +49 -0
- package/packages/platform-core/src/paths.js +75 -0
- package/packages/platform-core/src/platform.js +15 -0
- package/packages/platform-core/test/platform.test.mjs +84 -0
- package/packages/protocol/src/messages.js +156 -0
- package/packages/protocol/test/messages.test.mjs +112 -0
- package/packages/session-core/src/bubble-linger.js +47 -0
- package/packages/session-core/src/bubble-view.js +79 -0
- package/packages/session-core/src/pet-state.js +56 -0
- package/packages/session-core/src/priority.js +56 -0
- package/packages/session-core/src/registry.js +144 -0
- package/packages/session-core/src/summaries.js +54 -0
- package/packages/session-core/test/bubble-linger.test.mjs +96 -0
- package/packages/session-core/test/bubble-view.test.mjs +79 -0
- package/packages/session-core/test/pet-state.test.mjs +118 -0
- package/packages/session-core/test/priority.test.mjs +53 -0
- package/packages/session-core/test/registry.test.mjs +161 -0
- package/packages/session-core/test/summaries.test.mjs +38 -0
- package/packages/task-core/src/approvals.js +91 -0
- package/packages/task-core/src/controls.js +61 -0
- package/packages/task-core/src/replies.js +80 -0
- package/packages/task-core/src/task-events.js +101 -0
- package/packages/task-core/src/task-status.js +93 -0
- package/packages/task-core/src/task-store.js +74 -0
- package/packages/task-core/test/approvals.test.mjs +61 -0
- package/packages/task-core/test/controls.test.mjs +61 -0
- package/packages/task-core/test/replies.test.mjs +51 -0
- package/packages/task-core/test/task-events.test.mjs +67 -0
- package/packages/task-core/test/task-status.test.mjs +49 -0
- package/packages/task-core/test/task-store.test.mjs +65 -0
- package/test/harness.mjs +22 -0
- package/test/run-tests.mjs +47 -0
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
const STATUS_LABELS = Object.freeze({
|
|
2
|
+
idle: "Idle",
|
|
3
|
+
thinking: "Thinking",
|
|
4
|
+
running_tool: "Running tools",
|
|
5
|
+
editing_files: "Editing files",
|
|
6
|
+
waiting_user: "Waiting for you",
|
|
7
|
+
waiting_approval: "Waiting for approval",
|
|
8
|
+
reviewing: "Reviewing",
|
|
9
|
+
compacting: "Compacting context",
|
|
10
|
+
failed: "Failed",
|
|
11
|
+
success: "Done",
|
|
12
|
+
stale: "Stale",
|
|
13
|
+
exited: "Exited"
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
export function buildStatusLabel(state) {
|
|
17
|
+
return STATUS_LABELS[state] ?? humanize(state);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function buildSessionSummary(session) {
|
|
21
|
+
if (session && typeof session.summary === "string" && session.summary.trim() !== "") {
|
|
22
|
+
return session.summary;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
return buildStatusLabel(session?.state);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function formatElapsed(ms) {
|
|
29
|
+
const totalSeconds = Math.max(0, Math.floor((Number.isFinite(ms) ? ms : 0) / 1000));
|
|
30
|
+
|
|
31
|
+
if (totalSeconds < 60) {
|
|
32
|
+
return `${totalSeconds}s`;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const minutes = Math.floor(totalSeconds / 60);
|
|
36
|
+
const seconds = totalSeconds % 60;
|
|
37
|
+
|
|
38
|
+
if (minutes < 60) {
|
|
39
|
+
return `${minutes}m ${seconds}s`;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const hours = Math.floor(minutes / 60);
|
|
43
|
+
const remainderMinutes = minutes % 60;
|
|
44
|
+
return `${hours}h ${remainderMinutes}m`;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function humanize(value) {
|
|
48
|
+
if (typeof value !== "string" || value === "") {
|
|
49
|
+
return "Unknown";
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const text = value.replace(/_/g, " ");
|
|
53
|
+
return text.charAt(0).toUpperCase() + text.slice(1);
|
|
54
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { test } from "../../../test/harness.mjs";
|
|
3
|
+
import { resolveVisibleBubbles, ENDED_STATES } from "../src/bubble-linger.js";
|
|
4
|
+
|
|
5
|
+
function bubble(sessionId, state) {
|
|
6
|
+
return { sessionId, state };
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
test("ENDED_STATES covers the process-finished states", () => {
|
|
10
|
+
assert.ok(ENDED_STATES.has("exited"));
|
|
11
|
+
assert.ok(ENDED_STATES.has("success"));
|
|
12
|
+
assert.ok(ENDED_STATES.has("failed"));
|
|
13
|
+
assert.ok(!ENDED_STATES.has("idle"));
|
|
14
|
+
assert.ok(!ENDED_STATES.has("running_tool"));
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
test("keeps active (non-ended) bubbles visible with no linger tracking", () => {
|
|
18
|
+
const result = resolveVisibleBubbles({
|
|
19
|
+
bubbles: [bubble("a", "running_tool"), bubble("b", "idle")],
|
|
20
|
+
now: 1000
|
|
21
|
+
});
|
|
22
|
+
assert.deepEqual(result.visible.map((b) => b.sessionId), ["a", "b"]);
|
|
23
|
+
assert.deepEqual(result.lingerState, {});
|
|
24
|
+
assert.equal(result.nextWakeMs, undefined);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test("shows a freshly ended bubble and schedules its removal", () => {
|
|
28
|
+
const result = resolveVisibleBubbles({
|
|
29
|
+
bubbles: [bubble("a", "success")],
|
|
30
|
+
now: 1000,
|
|
31
|
+
lingerMs: 2000
|
|
32
|
+
});
|
|
33
|
+
assert.deepEqual(result.visible.map((b) => b.sessionId), ["a"]);
|
|
34
|
+
assert.equal(result.lingerState.a, 1000); // first seen ended at now
|
|
35
|
+
assert.equal(result.nextWakeMs, 2000);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("keeps the ended bubble visible until the linger window elapses", () => {
|
|
39
|
+
const result = resolveVisibleBubbles({
|
|
40
|
+
bubbles: [bubble("a", "success")],
|
|
41
|
+
now: 2500,
|
|
42
|
+
lingerState: { a: 1000 },
|
|
43
|
+
lingerMs: 2000
|
|
44
|
+
});
|
|
45
|
+
assert.deepEqual(result.visible.map((b) => b.sessionId), ["a"]);
|
|
46
|
+
assert.equal(result.nextWakeMs, 500); // 2000 - (2500 - 1000)
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("hides the ended bubble once the linger window has passed", () => {
|
|
50
|
+
const result = resolveVisibleBubbles({
|
|
51
|
+
bubbles: [bubble("a", "failed")],
|
|
52
|
+
now: 3001,
|
|
53
|
+
lingerState: { a: 1000 },
|
|
54
|
+
lingerMs: 2000
|
|
55
|
+
});
|
|
56
|
+
assert.deepEqual(result.visible, []);
|
|
57
|
+
// still tracked (so it stays hidden if it reappears in later payloads)...
|
|
58
|
+
assert.equal(result.lingerState.a, 1000);
|
|
59
|
+
// ...but nothing left to wake up for.
|
|
60
|
+
assert.equal(result.nextWakeMs, undefined);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("anchors the linger clock to the FIRST ended state, not later ones (success -> exited)", () => {
|
|
64
|
+
// success first seen at 1000; the later 'exited' message must not reset it.
|
|
65
|
+
const afterSuccess = resolveVisibleBubbles({ bubbles: [bubble("a", "success")], now: 1000, lingerMs: 2000 });
|
|
66
|
+
const afterExited = resolveVisibleBubbles({
|
|
67
|
+
bubbles: [bubble("a", "exited")],
|
|
68
|
+
now: 1500,
|
|
69
|
+
lingerState: afterSuccess.lingerState,
|
|
70
|
+
lingerMs: 2000
|
|
71
|
+
});
|
|
72
|
+
assert.equal(afterExited.lingerState.a, 1000);
|
|
73
|
+
assert.equal(afterExited.nextWakeMs, 1500); // 2000 - (1500 - 1000)
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("drops linger tracking for sessions no longer present", () => {
|
|
77
|
+
const result = resolveVisibleBubbles({
|
|
78
|
+
bubbles: [bubble("b", "running_tool")],
|
|
79
|
+
now: 5000,
|
|
80
|
+
lingerState: { a: 1000 },
|
|
81
|
+
lingerMs: 2000
|
|
82
|
+
});
|
|
83
|
+
assert.deepEqual(result.visible.map((x) => x.sessionId), ["b"]);
|
|
84
|
+
assert.deepEqual(result.lingerState, {}); // 'a' is gone -> forgotten
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test("reports the soonest wake among several ended bubbles", () => {
|
|
88
|
+
const result = resolveVisibleBubbles({
|
|
89
|
+
bubbles: [bubble("a", "success"), bubble("b", "failed")],
|
|
90
|
+
now: 1800,
|
|
91
|
+
lingerState: { a: 1000, b: 1500 },
|
|
92
|
+
lingerMs: 2000
|
|
93
|
+
});
|
|
94
|
+
// a: 2000-800=1200 remaining; b: 2000-300=1700 remaining -> soonest is 1200
|
|
95
|
+
assert.equal(result.nextWakeMs, 1200);
|
|
96
|
+
});
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { test } from "../../../test/harness.mjs";
|
|
3
|
+
import { buildBubbleView, buildBubbleViews, resolveBubbleStatusKind } from "../src/bubble-view.js";
|
|
4
|
+
|
|
5
|
+
const baseSession = {
|
|
6
|
+
sessionId: "sess_a",
|
|
7
|
+
clientId: "codex",
|
|
8
|
+
clientDisplayName: "Codex",
|
|
9
|
+
projectName: "netdisk-server",
|
|
10
|
+
state: "waiting_approval",
|
|
11
|
+
summary: "waiting for command approval",
|
|
12
|
+
startedAt: 1_000,
|
|
13
|
+
updatedAt: 5_000
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
test("builds a bubble view model with label, summary, action, and elapsed", () => {
|
|
17
|
+
const view = buildBubbleView(baseSession, 65_000);
|
|
18
|
+
|
|
19
|
+
assert.equal(view.sessionId, "sess_a");
|
|
20
|
+
assert.equal(view.clientId, "codex");
|
|
21
|
+
assert.equal(view.clientName, "Codex");
|
|
22
|
+
assert.equal(view.projectName, "netdisk-server");
|
|
23
|
+
assert.equal(view.state, "waiting_approval");
|
|
24
|
+
assert.equal(view.statusLabel, "Waiting for approval");
|
|
25
|
+
assert.equal(view.summary, "waiting for command approval");
|
|
26
|
+
assert.equal(view.petAction, "waiting");
|
|
27
|
+
assert.equal(view.elapsedMs, 64_000);
|
|
28
|
+
assert.equal(view.elapsedLabel, "1m 4s");
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("orders bubbles by session priority then recency", () => {
|
|
32
|
+
const sessions = [
|
|
33
|
+
{ ...baseSession, sessionId: "sess_idle", state: "idle", updatedAt: 9_000 },
|
|
34
|
+
{ ...baseSession, sessionId: "sess_wait", state: "waiting_approval", updatedAt: 4_000 },
|
|
35
|
+
{ ...baseSession, sessionId: "sess_run", state: "running_tool", updatedAt: 8_000 }
|
|
36
|
+
];
|
|
37
|
+
|
|
38
|
+
const views = buildBubbleViews(sessions, 10_000);
|
|
39
|
+
assert.deepEqual(views.map((view) => view.sessionId), ["sess_wait", "sess_run", "sess_idle"]);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("marks the selected/pinned session", () => {
|
|
43
|
+
const views = buildBubbleViews([baseSession], 6_000, { selectedSessionId: "sess_a" });
|
|
44
|
+
assert.equal(views[0].selected, true);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("resolves working states to the 'working' status kind (spinner)", () => {
|
|
48
|
+
for (const state of ["thinking", "running_tool", "editing_files", "reviewing", "compacting"]) {
|
|
49
|
+
assert.equal(resolveBubbleStatusKind(state), "working", state);
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("resolves attention states to the 'attention' status kind (yellow)", () => {
|
|
54
|
+
for (const state of ["waiting_user", "waiting_approval", "stale"]) {
|
|
55
|
+
assert.equal(resolveBubbleStatusKind(state), "attention", state);
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("resolves failure to the 'failed' status kind (red cross)", () => {
|
|
60
|
+
assert.equal(resolveBubbleStatusKind("failed"), "failed");
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("resolves idle/finished states to the 'done' status kind (check mark)", () => {
|
|
64
|
+
for (const state of ["idle", "success", "exited"]) {
|
|
65
|
+
assert.equal(resolveBubbleStatusKind(state), "done", state);
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("falls back to a neutral 'idle' kind for unknown states", () => {
|
|
70
|
+
assert.equal(resolveBubbleStatusKind("totally-unknown"), "idle");
|
|
71
|
+
assert.equal(resolveBubbleStatusKind(undefined), "idle");
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("exposes statusKind on the bubble view model", () => {
|
|
75
|
+
assert.equal(buildBubbleView({ ...baseSession, state: "running_tool" }, 6_000).statusKind, "working");
|
|
76
|
+
assert.equal(buildBubbleView({ ...baseSession, state: "waiting_approval" }, 6_000).statusKind, "attention");
|
|
77
|
+
assert.equal(buildBubbleView({ ...baseSession, state: "failed" }, 6_000).statusKind, "failed");
|
|
78
|
+
assert.equal(buildBubbleView({ ...baseSession, state: "idle" }, 6_000).statusKind, "done");
|
|
79
|
+
});
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { test } from "../../../test/harness.mjs";
|
|
3
|
+
import { resolveCompanionPetState } from "../src/pet-state.js";
|
|
4
|
+
|
|
5
|
+
function bubble(sessionId, state) {
|
|
6
|
+
return { sessionId, state };
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
test("drives the stable action from the highest-priority active session", () => {
|
|
10
|
+
const result = resolveCompanionPetState({
|
|
11
|
+
bubbles: [bubble("a", "waiting_approval"), bubble("b", "idle")],
|
|
12
|
+
prioritySessionId: "a",
|
|
13
|
+
previousStates: {}
|
|
14
|
+
});
|
|
15
|
+
assert.equal(result.stableAction, "waiting");
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
test("returns idle when there are no active sessions", () => {
|
|
19
|
+
const result = resolveCompanionPetState({
|
|
20
|
+
bubbles: [bubble("a", "exited")],
|
|
21
|
+
prioritySessionId: "a",
|
|
22
|
+
previousStates: { a: "success" }
|
|
23
|
+
});
|
|
24
|
+
assert.equal(result.stableAction, "idle");
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test("excludes finished sessions from the active bubble list", () => {
|
|
28
|
+
const result = resolveCompanionPetState({
|
|
29
|
+
bubbles: [bubble("a", "idle"), bubble("b", "exited"), bubble("c", "success")],
|
|
30
|
+
prioritySessionId: "a",
|
|
31
|
+
previousStates: {}
|
|
32
|
+
});
|
|
33
|
+
assert.deepEqual(result.activeBubbles.map((b) => b.sessionId), ["a"]);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("fires a one-shot jump when a session transitions into success", () => {
|
|
37
|
+
const result = resolveCompanionPetState({
|
|
38
|
+
bubbles: [bubble("a", "success")],
|
|
39
|
+
prioritySessionId: "a",
|
|
40
|
+
previousStates: { a: "running_tool" }
|
|
41
|
+
});
|
|
42
|
+
assert.deepEqual(result.oneShots, ["jumping"]);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("does not repeat the success one-shot once already seen", () => {
|
|
46
|
+
const result = resolveCompanionPetState({
|
|
47
|
+
bubbles: [bubble("a", "success")],
|
|
48
|
+
prioritySessionId: "a",
|
|
49
|
+
previousStates: { a: "success" }
|
|
50
|
+
});
|
|
51
|
+
assert.deepEqual(result.oneShots, []);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("failure is a one-shot reaction, not a sticky stable action", () => {
|
|
55
|
+
const result = resolveCompanionPetState({
|
|
56
|
+
bubbles: [bubble("a", "failed")],
|
|
57
|
+
prioritySessionId: "a",
|
|
58
|
+
previousStates: { a: "running_tool" }
|
|
59
|
+
});
|
|
60
|
+
// plays the failed reaction once...
|
|
61
|
+
assert.deepEqual(result.oneShots, ["failed"]);
|
|
62
|
+
// ...but does not pin the pet on a looping "failed" stable action
|
|
63
|
+
assert.equal(result.stableAction, "idle");
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("does not repeat the failed reaction while it stays failed", () => {
|
|
67
|
+
const result = resolveCompanionPetState({
|
|
68
|
+
bubbles: [bubble("a", "failed")],
|
|
69
|
+
prioritySessionId: "a",
|
|
70
|
+
previousStates: { a: "failed" }
|
|
71
|
+
});
|
|
72
|
+
assert.deepEqual(result.oneShots, []);
|
|
73
|
+
assert.equal(result.stableAction, "idle");
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("a failed session still yields to active work for the stable action", () => {
|
|
77
|
+
const result = resolveCompanionPetState({
|
|
78
|
+
bubbles: [bubble("a", "failed"), bubble("b", "running_tool")],
|
|
79
|
+
prioritySessionId: "a",
|
|
80
|
+
previousStates: { a: "failed", b: "running_tool" }
|
|
81
|
+
});
|
|
82
|
+
assert.equal(result.stableAction, "running");
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("celebrates with a one-shot jump when a working turn finishes (working -> idle)", () => {
|
|
86
|
+
const result = resolveCompanionPetState({
|
|
87
|
+
bubbles: [bubble("a", "idle")],
|
|
88
|
+
prioritySessionId: "a",
|
|
89
|
+
previousStates: { a: "running_tool" }
|
|
90
|
+
});
|
|
91
|
+
assert.deepEqual(result.oneShots, ["jumping"]);
|
|
92
|
+
assert.equal(result.stableAction, "idle");
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test("no finished reaction when idle was not preceded by active work", () => {
|
|
96
|
+
assert.deepEqual(
|
|
97
|
+
resolveCompanionPetState({ bubbles: [bubble("a", "idle")], prioritySessionId: "a", previousStates: { a: "idle" } }).oneShots,
|
|
98
|
+
[]
|
|
99
|
+
);
|
|
100
|
+
assert.deepEqual(
|
|
101
|
+
resolveCompanionPetState({ bubbles: [bubble("a", "idle")], prioritySessionId: "a", previousStates: {} }).oneShots,
|
|
102
|
+
[]
|
|
103
|
+
);
|
|
104
|
+
// waiting -> idle is not "finished work"
|
|
105
|
+
assert.deepEqual(
|
|
106
|
+
resolveCompanionPetState({ bubbles: [bubble("a", "idle")], prioritySessionId: "a", previousStates: { a: "waiting_user" } }).oneShots,
|
|
107
|
+
[]
|
|
108
|
+
);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test("returns the next state map for the following update", () => {
|
|
112
|
+
const result = resolveCompanionPetState({
|
|
113
|
+
bubbles: [bubble("a", "idle"), bubble("b", "exited")],
|
|
114
|
+
prioritySessionId: "a",
|
|
115
|
+
previousStates: {}
|
|
116
|
+
});
|
|
117
|
+
assert.deepEqual(result.nextStates, { a: "idle", b: "exited" });
|
|
118
|
+
});
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { test } from "../../../test/harness.mjs";
|
|
3
|
+
import { selectPrioritySession } from "../src/priority.js";
|
|
4
|
+
|
|
5
|
+
function session(sessionId, state, updatedAt) {
|
|
6
|
+
return {
|
|
7
|
+
sessionId,
|
|
8
|
+
clientId: "generic",
|
|
9
|
+
clientDisplayName: "Generic",
|
|
10
|
+
pid: 1000 + updatedAt,
|
|
11
|
+
cwd: "D:\\Work\\project",
|
|
12
|
+
projectName: "project",
|
|
13
|
+
state,
|
|
14
|
+
source: "wrapper",
|
|
15
|
+
startedAt: 1,
|
|
16
|
+
updatedAt
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
test("returns undefined when there are no sessions", () => {
|
|
21
|
+
assert.equal(selectPrioritySession([]), undefined);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test("uses the pinned session before automatic urgency", () => {
|
|
25
|
+
const selected = selectPrioritySession([
|
|
26
|
+
session("codex", "running_tool", 30),
|
|
27
|
+
session("claude", "waiting_approval", 20)
|
|
28
|
+
], { pinnedSessionId: "codex" });
|
|
29
|
+
|
|
30
|
+
assert.equal(selected.sessionId, "codex");
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("prioritizes urgent waiting states before active work and recency", () => {
|
|
34
|
+
const selected = selectPrioritySession([
|
|
35
|
+
session("idle", "idle", 100),
|
|
36
|
+
session("thinking", "thinking", 90),
|
|
37
|
+
session("running", "running_tool", 80),
|
|
38
|
+
session("failed", "failed", 70),
|
|
39
|
+
session("waiting-user", "waiting_user", 60),
|
|
40
|
+
session("approval", "waiting_approval", 50)
|
|
41
|
+
]);
|
|
42
|
+
|
|
43
|
+
assert.equal(selected.sessionId, "approval");
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test("breaks ties within a priority group by most recent update", () => {
|
|
47
|
+
const selected = selectPrioritySession([
|
|
48
|
+
session("older", "running_tool", 20),
|
|
49
|
+
session("newer", "editing_files", 40)
|
|
50
|
+
]);
|
|
51
|
+
|
|
52
|
+
assert.equal(selected.sessionId, "newer");
|
|
53
|
+
});
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { test } from "../../../test/harness.mjs";
|
|
3
|
+
import { createSessionRegistry } from "../src/registry.js";
|
|
4
|
+
|
|
5
|
+
function registerMessage(sessionId, overrides = {}) {
|
|
6
|
+
return {
|
|
7
|
+
type: "register",
|
|
8
|
+
sessionId,
|
|
9
|
+
clientId: "generic",
|
|
10
|
+
clientDisplayName: "Generic",
|
|
11
|
+
pid: 12345,
|
|
12
|
+
cwd: "D:\\Work\\project",
|
|
13
|
+
projectName: "project",
|
|
14
|
+
startedAt: 1000,
|
|
15
|
+
...overrides
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
test("registers sessions from protocol messages", () => {
|
|
20
|
+
const registry = createSessionRegistry();
|
|
21
|
+
|
|
22
|
+
registry.applyMessage(registerMessage("sess_a"));
|
|
23
|
+
|
|
24
|
+
assert.deepEqual(registry.listSessions().map((session) => session.sessionId), ["sess_a"]);
|
|
25
|
+
assert.deepEqual(registry.getSession("sess_a"), {
|
|
26
|
+
sessionId: "sess_a",
|
|
27
|
+
clientId: "generic",
|
|
28
|
+
clientDisplayName: "Generic",
|
|
29
|
+
pid: 12345,
|
|
30
|
+
terminalPid: undefined,
|
|
31
|
+
cwd: "D:\\Work\\project",
|
|
32
|
+
projectName: "project",
|
|
33
|
+
state: "idle",
|
|
34
|
+
source: "wrapper",
|
|
35
|
+
startedAt: 1000,
|
|
36
|
+
updatedAt: 1000
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test("applies state and heartbeat messages without losing session metadata", () => {
|
|
41
|
+
const registry = createSessionRegistry();
|
|
42
|
+
|
|
43
|
+
registry.applyMessage(registerMessage("sess_a"));
|
|
44
|
+
registry.applyMessage({
|
|
45
|
+
type: "state",
|
|
46
|
+
sessionId: "sess_a",
|
|
47
|
+
state: "waiting_approval",
|
|
48
|
+
summary: "waiting for command approval",
|
|
49
|
+
confidence: 0.9,
|
|
50
|
+
source: "pty_output",
|
|
51
|
+
updatedAt: 1200
|
|
52
|
+
});
|
|
53
|
+
registry.applyMessage({
|
|
54
|
+
type: "heartbeat",
|
|
55
|
+
sessionId: "sess_a",
|
|
56
|
+
updatedAt: 1300
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
const session = registry.getSession("sess_a");
|
|
60
|
+
assert.equal(session.state, "waiting_approval");
|
|
61
|
+
assert.equal(session.summary, "waiting for command approval");
|
|
62
|
+
assert.equal(session.confidence, 0.9);
|
|
63
|
+
assert.equal(session.source, "pty_output");
|
|
64
|
+
assert.equal(session.updatedAt, 1300);
|
|
65
|
+
assert.equal(session.projectName, "project");
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("unregister marks sessions as exited and preserves exit details", () => {
|
|
69
|
+
const registry = createSessionRegistry();
|
|
70
|
+
|
|
71
|
+
registry.applyMessage(registerMessage("sess_a"));
|
|
72
|
+
registry.applyMessage({
|
|
73
|
+
type: "unregister",
|
|
74
|
+
sessionId: "sess_a",
|
|
75
|
+
exitCode: 7,
|
|
76
|
+
finishedAt: 1400
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
assert.equal(registry.getSession("sess_a").state, "exited");
|
|
80
|
+
assert.equal(registry.getSession("sess_a").exitCode, 7);
|
|
81
|
+
assert.equal(registry.getSession("sess_a").finishedAt, 1400);
|
|
82
|
+
assert.equal(registry.getSession("sess_a").updatedAt, 1400);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("selects the priority session from registered sessions", () => {
|
|
86
|
+
const registry = createSessionRegistry();
|
|
87
|
+
|
|
88
|
+
registry.applyMessage(registerMessage("codex", { startedAt: 1000 }));
|
|
89
|
+
registry.applyMessage(registerMessage("claude", { startedAt: 1100 }));
|
|
90
|
+
registry.applyMessage({
|
|
91
|
+
type: "state",
|
|
92
|
+
sessionId: "codex",
|
|
93
|
+
state: "running_tool",
|
|
94
|
+
confidence: 0.7,
|
|
95
|
+
source: "wrapper",
|
|
96
|
+
updatedAt: 1200
|
|
97
|
+
});
|
|
98
|
+
registry.applyMessage({
|
|
99
|
+
type: "state",
|
|
100
|
+
sessionId: "claude",
|
|
101
|
+
state: "waiting_approval",
|
|
102
|
+
confidence: 0.8,
|
|
103
|
+
source: "pty_output",
|
|
104
|
+
updatedAt: 1150
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
assert.equal(registry.getPrioritySession().sessionId, "claude");
|
|
108
|
+
assert.equal(registry.getPrioritySession({ pinnedSessionId: "codex" }).sessionId, "codex");
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test("marks stale non-exited sessions after heartbeat timeout", () => {
|
|
112
|
+
const registry = createSessionRegistry({ staleAfterMs: 100 });
|
|
113
|
+
|
|
114
|
+
registry.applyMessage(registerMessage("active", { startedAt: 1000 }));
|
|
115
|
+
registry.applyMessage(registerMessage("finished", { startedAt: 1000 }));
|
|
116
|
+
registry.applyMessage({
|
|
117
|
+
type: "unregister",
|
|
118
|
+
sessionId: "finished",
|
|
119
|
+
exitCode: 0,
|
|
120
|
+
finishedAt: 1050
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
const staleSessions = registry.markStaleSessions(1201);
|
|
124
|
+
|
|
125
|
+
assert.deepEqual(staleSessions.map((session) => session.sessionId), ["active"]);
|
|
126
|
+
assert.equal(registry.getSession("active").state, "stale");
|
|
127
|
+
assert.equal(registry.getSession("active").source, "wrapper");
|
|
128
|
+
assert.equal(registry.getSession("finished").state, "exited");
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test("drops sessions with no activity past the drop timeout", () => {
|
|
132
|
+
const registry = createSessionRegistry({ staleAfterMs: 100, dropAfterMs: 500 });
|
|
133
|
+
registry.applyMessage(registerMessage("dead", { startedAt: 1000 }));
|
|
134
|
+
|
|
135
|
+
// Past staleAfterMs but within dropAfterMs -> marked stale, still present.
|
|
136
|
+
registry.markStaleSessions(1200);
|
|
137
|
+
assert.equal(registry.getSession("dead").state, "stale");
|
|
138
|
+
|
|
139
|
+
// Marking stale must not refresh updatedAt, so the drop clock still elapses.
|
|
140
|
+
registry.markStaleSessions(1600);
|
|
141
|
+
assert.equal(registry.getSession("dead"), undefined);
|
|
142
|
+
assert.deepEqual(registry.listSessions(), []);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
test("does not re-mark an already-stale session every sweep", () => {
|
|
146
|
+
const registry = createSessionRegistry({ staleAfterMs: 100, dropAfterMs: 100000 });
|
|
147
|
+
registry.applyMessage(registerMessage("idle1", { startedAt: 1000 }));
|
|
148
|
+
|
|
149
|
+
assert.deepEqual(registry.markStaleSessions(1200).map((s) => s.sessionId), ["idle1"]);
|
|
150
|
+
// already stale -> not reported again
|
|
151
|
+
assert.deepEqual(registry.markStaleSessions(1300), []);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
test("rejects updates for unknown sessions", () => {
|
|
155
|
+
const registry = createSessionRegistry();
|
|
156
|
+
|
|
157
|
+
assert.throws(
|
|
158
|
+
() => registry.applyMessage({ type: "heartbeat", sessionId: "missing", updatedAt: 1 }),
|
|
159
|
+
/Unknown session: missing/
|
|
160
|
+
);
|
|
161
|
+
});
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { test } from "../../../test/harness.mjs";
|
|
3
|
+
import {
|
|
4
|
+
buildStatusLabel,
|
|
5
|
+
buildSessionSummary,
|
|
6
|
+
formatElapsed
|
|
7
|
+
} from "../src/summaries.js";
|
|
8
|
+
|
|
9
|
+
test("maps every normalized state to a human status label", () => {
|
|
10
|
+
assert.equal(buildStatusLabel("waiting_approval"), "Waiting for approval");
|
|
11
|
+
assert.equal(buildStatusLabel("waiting_user"), "Waiting for you");
|
|
12
|
+
assert.equal(buildStatusLabel("running_tool"), "Running tools");
|
|
13
|
+
assert.equal(buildStatusLabel("editing_files"), "Editing files");
|
|
14
|
+
assert.equal(buildStatusLabel("success"), "Done");
|
|
15
|
+
assert.equal(buildStatusLabel("idle"), "Idle");
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
test("falls back to a readable label for unknown states", () => {
|
|
19
|
+
assert.equal(buildStatusLabel("totally_unknown"), "Totally unknown");
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test("prefers an explicit summary but falls back to the status label", () => {
|
|
23
|
+
assert.equal(
|
|
24
|
+
buildSessionSummary({ state: "running_tool", summary: "npm test" }),
|
|
25
|
+
"npm test"
|
|
26
|
+
);
|
|
27
|
+
assert.equal(
|
|
28
|
+
buildSessionSummary({ state: "waiting_approval" }),
|
|
29
|
+
"Waiting for approval"
|
|
30
|
+
);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("formats elapsed durations compactly", () => {
|
|
34
|
+
assert.equal(formatElapsed(0), "0s");
|
|
35
|
+
assert.equal(formatElapsed(5_000), "5s");
|
|
36
|
+
assert.equal(formatElapsed(65_000), "1m 5s");
|
|
37
|
+
assert.equal(formatElapsed(3_725_000), "1h 2m");
|
|
38
|
+
});
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
export const APPROVAL_KINDS = Object.freeze([
|
|
2
|
+
"command",
|
|
3
|
+
"file_write",
|
|
4
|
+
"network",
|
|
5
|
+
"sandbox",
|
|
6
|
+
"plugin",
|
|
7
|
+
"permission",
|
|
8
|
+
"other"
|
|
9
|
+
]);
|
|
10
|
+
|
|
11
|
+
export const APPROVAL_RISKS = Object.freeze(["low", "medium", "high"]);
|
|
12
|
+
|
|
13
|
+
const APPROVAL_KIND_SET = new Set(APPROVAL_KINDS);
|
|
14
|
+
const APPROVAL_RISK_SET = new Set(APPROVAL_RISKS);
|
|
15
|
+
|
|
16
|
+
export function validateApprovalRequest(input) {
|
|
17
|
+
if (!isPlainObject(input)) {
|
|
18
|
+
return { ok: false, errors: ["approval request must be an object"] };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const errors = [];
|
|
22
|
+
|
|
23
|
+
requireNonEmptyString(errors, input.approvalId, "approvalId");
|
|
24
|
+
requireNonEmptyString(errors, input.sessionId, "sessionId");
|
|
25
|
+
requireNonEmptyString(errors, input.title, "title");
|
|
26
|
+
|
|
27
|
+
if (!APPROVAL_KIND_SET.has(input.kind)) {
|
|
28
|
+
errors.push(`kind must be one of: ${APPROVAL_KINDS.join(", ")}`);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
if (input.risk !== undefined && !APPROVAL_RISK_SET.has(input.risk)) {
|
|
32
|
+
errors.push(`risk must be one of: ${APPROVAL_RISKS.join(", ")}`);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if (!Number.isFinite(input.requestedAt)) {
|
|
36
|
+
errors.push("requestedAt must be a finite number");
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return { ok: errors.length === 0, errors };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function buildApprovalDecision({ sessionId, approvalId, decision, note, decidedAt }) {
|
|
43
|
+
const result = {
|
|
44
|
+
type: "approval_decision",
|
|
45
|
+
sessionId,
|
|
46
|
+
approvalId,
|
|
47
|
+
decision,
|
|
48
|
+
decidedAt
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
if (note !== undefined) {
|
|
52
|
+
result.note = note;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return result;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function validateApprovalDecision(input) {
|
|
59
|
+
if (!isPlainObject(input)) {
|
|
60
|
+
return { ok: false, errors: ["approval decision must be an object"] };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const errors = [];
|
|
64
|
+
|
|
65
|
+
if (input.type !== "approval_decision") {
|
|
66
|
+
errors.push('type must be "approval_decision"');
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
requireNonEmptyString(errors, input.sessionId, "sessionId");
|
|
70
|
+
requireNonEmptyString(errors, input.approvalId, "approvalId");
|
|
71
|
+
|
|
72
|
+
if (input.decision !== "approve" && input.decision !== "deny") {
|
|
73
|
+
errors.push('decision must be "approve" or "deny"');
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (!Number.isFinite(input.decidedAt)) {
|
|
77
|
+
errors.push("decidedAt must be a finite number");
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return { ok: errors.length === 0, errors };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function requireNonEmptyString(errors, value, fieldName) {
|
|
84
|
+
if (typeof value !== "string" || value.trim() === "") {
|
|
85
|
+
errors.push(`${fieldName} must be a non-empty string`);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function isPlainObject(value) {
|
|
90
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
91
|
+
}
|