@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,156 @@
|
|
|
1
|
+
export const AI_CLIENT_STATES = Object.freeze([
|
|
2
|
+
"idle",
|
|
3
|
+
"thinking",
|
|
4
|
+
"running_tool",
|
|
5
|
+
"editing_files",
|
|
6
|
+
"waiting_user",
|
|
7
|
+
"waiting_approval",
|
|
8
|
+
"reviewing",
|
|
9
|
+
"compacting",
|
|
10
|
+
"failed",
|
|
11
|
+
"success",
|
|
12
|
+
"stale",
|
|
13
|
+
"exited"
|
|
14
|
+
]);
|
|
15
|
+
|
|
16
|
+
export const STATE_SOURCES = Object.freeze([
|
|
17
|
+
"wrapper",
|
|
18
|
+
"pty_output",
|
|
19
|
+
"client_log",
|
|
20
|
+
"client_state",
|
|
21
|
+
"official_plugin",
|
|
22
|
+
"manual"
|
|
23
|
+
]);
|
|
24
|
+
|
|
25
|
+
export const PROTOCOL_MESSAGE_TYPES = Object.freeze([
|
|
26
|
+
"register",
|
|
27
|
+
"heartbeat",
|
|
28
|
+
"state",
|
|
29
|
+
"unregister",
|
|
30
|
+
// Control message (not session-scoped): asks the daemon to shut down.
|
|
31
|
+
"shutdown"
|
|
32
|
+
]);
|
|
33
|
+
|
|
34
|
+
const AI_CLIENT_STATE_SET = new Set(AI_CLIENT_STATES);
|
|
35
|
+
const STATE_SOURCE_SET = new Set(STATE_SOURCES);
|
|
36
|
+
const MESSAGE_TYPE_SET = new Set(PROTOCOL_MESSAGE_TYPES);
|
|
37
|
+
|
|
38
|
+
export function isAiClientState(value) {
|
|
39
|
+
return AI_CLIENT_STATE_SET.has(value);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function isStateSource(value) {
|
|
43
|
+
return STATE_SOURCE_SET.has(value);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function validateProtocolMessage(message) {
|
|
47
|
+
const errors = [];
|
|
48
|
+
|
|
49
|
+
if (!isPlainObject(message)) {
|
|
50
|
+
return { ok: false, errors: ["message must be an object"] };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (!MESSAGE_TYPE_SET.has(message.type)) {
|
|
54
|
+
return {
|
|
55
|
+
ok: false,
|
|
56
|
+
errors: [`Unknown protocol message type: ${String(message.type)}`]
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Control messages are daemon-wide, not tied to a session.
|
|
61
|
+
if (message.type === "shutdown") {
|
|
62
|
+
return { ok: true, errors: [] };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
requireNonEmptyString(errors, message.sessionId, "sessionId");
|
|
66
|
+
|
|
67
|
+
switch (message.type) {
|
|
68
|
+
case "register":
|
|
69
|
+
validateRegisterMessage(errors, message);
|
|
70
|
+
break;
|
|
71
|
+
case "heartbeat":
|
|
72
|
+
requireFiniteNumber(errors, message.updatedAt, "updatedAt");
|
|
73
|
+
break;
|
|
74
|
+
case "state":
|
|
75
|
+
validateStateMessage(errors, message);
|
|
76
|
+
break;
|
|
77
|
+
case "unregister":
|
|
78
|
+
validateUnregisterMessage(errors, message);
|
|
79
|
+
break;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return { ok: errors.length === 0, errors };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function assertProtocolMessage(message) {
|
|
86
|
+
const result = validateProtocolMessage(message);
|
|
87
|
+
if (!result.ok) {
|
|
88
|
+
throw new Error(result.errors.join("; "));
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return message;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function validateRegisterMessage(errors, message) {
|
|
95
|
+
requireNonEmptyString(errors, message.clientId, "clientId");
|
|
96
|
+
requireNonEmptyString(errors, message.clientDisplayName, "clientDisplayName");
|
|
97
|
+
requirePositiveInteger(errors, message.pid, "pid");
|
|
98
|
+
|
|
99
|
+
if (message.terminalPid !== undefined) {
|
|
100
|
+
requirePositiveInteger(errors, message.terminalPid, "terminalPid");
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
requireNonEmptyString(errors, message.cwd, "cwd");
|
|
104
|
+
requireNonEmptyString(errors, message.projectName, "projectName");
|
|
105
|
+
requireFiniteNumber(errors, message.startedAt, "startedAt");
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function validateStateMessage(errors, message) {
|
|
109
|
+
if (!isAiClientState(message.state)) {
|
|
110
|
+
errors.push(`state must be one of: ${AI_CLIENT_STATES.join(", ")}`);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (!isStateSource(message.source)) {
|
|
114
|
+
errors.push(`source must be one of: ${STATE_SOURCES.join(", ")}`);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (typeof message.summary !== "undefined" && typeof message.summary !== "string") {
|
|
118
|
+
errors.push("summary must be a string when provided");
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (!Number.isFinite(message.confidence) || message.confidence < 0 || message.confidence > 1) {
|
|
122
|
+
errors.push("confidence must be a number from 0 to 1");
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
requireFiniteNumber(errors, message.updatedAt, "updatedAt");
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function validateUnregisterMessage(errors, message) {
|
|
129
|
+
if (message.exitCode !== undefined && !Number.isInteger(message.exitCode)) {
|
|
130
|
+
errors.push("exitCode must be an integer when provided");
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
requireFiniteNumber(errors, message.finishedAt, "finishedAt");
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function requireNonEmptyString(errors, value, fieldName) {
|
|
137
|
+
if (typeof value !== "string" || value.trim() === "") {
|
|
138
|
+
errors.push(`${fieldName} must be a non-empty string`);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function requireFiniteNumber(errors, value, fieldName) {
|
|
143
|
+
if (!Number.isFinite(value)) {
|
|
144
|
+
errors.push(`${fieldName} must be a finite number`);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function requirePositiveInteger(errors, value, fieldName) {
|
|
149
|
+
if (!Number.isInteger(value) || value <= 0) {
|
|
150
|
+
errors.push(`${fieldName} must be a positive integer`);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function isPlainObject(value) {
|
|
155
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
156
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { test } from "../../../test/harness.mjs";
|
|
3
|
+
import {
|
|
4
|
+
AI_CLIENT_STATES,
|
|
5
|
+
STATE_SOURCES,
|
|
6
|
+
assertProtocolMessage,
|
|
7
|
+
isAiClientState,
|
|
8
|
+
isStateSource,
|
|
9
|
+
validateProtocolMessage
|
|
10
|
+
} from "../src/messages.js";
|
|
11
|
+
|
|
12
|
+
test("declares normalized AI states and state sources from the plan", () => {
|
|
13
|
+
assert.deepEqual(AI_CLIENT_STATES, [
|
|
14
|
+
"idle",
|
|
15
|
+
"thinking",
|
|
16
|
+
"running_tool",
|
|
17
|
+
"editing_files",
|
|
18
|
+
"waiting_user",
|
|
19
|
+
"waiting_approval",
|
|
20
|
+
"reviewing",
|
|
21
|
+
"compacting",
|
|
22
|
+
"failed",
|
|
23
|
+
"success",
|
|
24
|
+
"stale",
|
|
25
|
+
"exited"
|
|
26
|
+
]);
|
|
27
|
+
|
|
28
|
+
assert.deepEqual(STATE_SOURCES, [
|
|
29
|
+
"wrapper",
|
|
30
|
+
"pty_output",
|
|
31
|
+
"client_log",
|
|
32
|
+
"client_state",
|
|
33
|
+
"official_plugin",
|
|
34
|
+
"manual"
|
|
35
|
+
]);
|
|
36
|
+
|
|
37
|
+
assert.equal(isAiClientState("waiting_approval"), true);
|
|
38
|
+
assert.equal(isAiClientState("busy"), false);
|
|
39
|
+
assert.equal(isStateSource("wrapper"), true);
|
|
40
|
+
assert.equal(isStateSource("network"), false);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test("accepts valid lifecycle protocol messages", () => {
|
|
44
|
+
const messages = [
|
|
45
|
+
{
|
|
46
|
+
type: "register",
|
|
47
|
+
sessionId: "sess_abc123",
|
|
48
|
+
clientId: "generic",
|
|
49
|
+
clientDisplayName: "Generic",
|
|
50
|
+
pid: 12345,
|
|
51
|
+
terminalPid: 6789,
|
|
52
|
+
cwd: "D:\\Work\\project",
|
|
53
|
+
projectName: "project",
|
|
54
|
+
startedAt: 1780000000
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
type: "heartbeat",
|
|
58
|
+
sessionId: "sess_abc123",
|
|
59
|
+
updatedAt: 1780000010
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
type: "state",
|
|
63
|
+
sessionId: "sess_abc123",
|
|
64
|
+
state: "waiting_approval",
|
|
65
|
+
summary: "waiting for command approval",
|
|
66
|
+
confidence: 0.86,
|
|
67
|
+
source: "pty_output",
|
|
68
|
+
updatedAt: 1780000010
|
|
69
|
+
},
|
|
70
|
+
{
|
|
71
|
+
type: "unregister",
|
|
72
|
+
sessionId: "sess_abc123",
|
|
73
|
+
exitCode: 0,
|
|
74
|
+
finishedAt: 1780000200
|
|
75
|
+
}
|
|
76
|
+
];
|
|
77
|
+
|
|
78
|
+
for (const message of messages) {
|
|
79
|
+
assert.deepEqual(validateProtocolMessage(message), { ok: true, errors: [] });
|
|
80
|
+
assert.equal(assertProtocolMessage(message), message);
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("reports all useful validation errors for invalid state messages", () => {
|
|
85
|
+
const result = validateProtocolMessage({
|
|
86
|
+
type: "state",
|
|
87
|
+
sessionId: "",
|
|
88
|
+
state: "busy",
|
|
89
|
+
confidence: 2,
|
|
90
|
+
source: "network",
|
|
91
|
+
updatedAt: "now"
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
assert.equal(result.ok, false);
|
|
95
|
+
assert.match(result.errors.join("\n"), /sessionId must be a non-empty string/);
|
|
96
|
+
assert.match(result.errors.join("\n"), /state must be one of/);
|
|
97
|
+
assert.match(result.errors.join("\n"), /confidence must be a number from 0 to 1/);
|
|
98
|
+
assert.match(result.errors.join("\n"), /source must be one of/);
|
|
99
|
+
assert.match(result.errors.join("\n"), /updatedAt must be a finite number/);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test("rejects unknown message types", () => {
|
|
103
|
+
assert.throws(
|
|
104
|
+
() => assertProtocolMessage({ type: "unknown", sessionId: "sess_abc123" }),
|
|
105
|
+
/Unknown protocol message type: unknown/
|
|
106
|
+
);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test("accepts a shutdown control message without a sessionId", () => {
|
|
110
|
+
assert.deepEqual(validateProtocolMessage({ type: "shutdown" }), { ok: true, errors: [] });
|
|
111
|
+
assert.deepEqual(assertProtocolMessage({ type: "shutdown" }), { type: "shutdown" });
|
|
112
|
+
});
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// When an AI session's process finishes, its bubble should not vanish instantly
|
|
2
|
+
// — the user wants to glimpse the final outcome (a green check or a red cross).
|
|
3
|
+
// This pure resolver keeps an ended bubble visible for a short linger window,
|
|
4
|
+
// then hides it, while the registry keeps the session around a little longer.
|
|
5
|
+
//
|
|
6
|
+
// It is stateless across calls: the caller threads `lingerState` (a map of
|
|
7
|
+
// sessionId -> the timestamp the session was FIRST seen ended) back in each
|
|
8
|
+
// time, and re-renders once at `nextWakeMs` so the bubble disappears on schedule
|
|
9
|
+
// even if no new session event arrives.
|
|
10
|
+
|
|
11
|
+
// Process-finished states. A turn merely going quiet is "idle" (still alive) and
|
|
12
|
+
// is NOT included here, so live sessions never linger-out.
|
|
13
|
+
export const ENDED_STATES = new Set(["exited", "success", "failed"]);
|
|
14
|
+
|
|
15
|
+
const DEFAULT_LINGER_MS = 2000;
|
|
16
|
+
|
|
17
|
+
export function resolveVisibleBubbles({ bubbles = [], now = Date.now(), lingerState = {}, lingerMs = DEFAULT_LINGER_MS } = {}) {
|
|
18
|
+
const visible = [];
|
|
19
|
+
const nextLingerState = {};
|
|
20
|
+
let nextWakeMs = Infinity;
|
|
21
|
+
|
|
22
|
+
for (const bubble of bubbles) {
|
|
23
|
+
if (!ENDED_STATES.has(bubble.state)) {
|
|
24
|
+
visible.push(bubble);
|
|
25
|
+
continue;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Anchor the clock to the first ended state we saw, so a follow-up message
|
|
29
|
+
// (success -> exited) does not restart the countdown.
|
|
30
|
+
const since = lingerState[bubble.sessionId] ?? now;
|
|
31
|
+
nextLingerState[bubble.sessionId] = since;
|
|
32
|
+
|
|
33
|
+
const remaining = lingerMs - (now - since);
|
|
34
|
+
if (remaining > 0) {
|
|
35
|
+
visible.push(bubble);
|
|
36
|
+
nextWakeMs = Math.min(nextWakeMs, remaining);
|
|
37
|
+
}
|
|
38
|
+
// remaining <= 0 -> hidden, but kept in nextLingerState so a re-appearing
|
|
39
|
+
// payload (registry still holds the exited session) does not flash it back.
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return {
|
|
43
|
+
visible,
|
|
44
|
+
lingerState: nextLingerState,
|
|
45
|
+
nextWakeMs: Number.isFinite(nextWakeMs) ? nextWakeMs : undefined
|
|
46
|
+
};
|
|
47
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { mapAiStateToPetAction } from "../../pet-core/src/atlas.js";
|
|
2
|
+
import { getSessionPriorityRank } from "./priority.js";
|
|
3
|
+
import { buildSessionSummary, buildStatusLabel, formatElapsed } from "./summaries.js";
|
|
4
|
+
|
|
5
|
+
// Collapses the full AI-state vocabulary into the four progress kinds the
|
|
6
|
+
// bubble panel renders: a spinning "working" circle, a "done" check mark (held
|
|
7
|
+
// until the next turn refreshes it), a yellow "attention" cue when the session
|
|
8
|
+
// needs the user, and a red "failed" cross. Unknown states fall back to a
|
|
9
|
+
// neutral "idle" dot rather than guessing.
|
|
10
|
+
const STATUS_KIND_BY_STATE = Object.freeze({
|
|
11
|
+
thinking: "working",
|
|
12
|
+
running_tool: "working",
|
|
13
|
+
editing_files: "working",
|
|
14
|
+
reviewing: "working",
|
|
15
|
+
compacting: "working",
|
|
16
|
+
waiting_user: "attention",
|
|
17
|
+
waiting_approval: "attention",
|
|
18
|
+
stale: "attention",
|
|
19
|
+
failed: "failed",
|
|
20
|
+
idle: "done",
|
|
21
|
+
success: "done",
|
|
22
|
+
exited: "done"
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
export function resolveBubbleStatusKind(state) {
|
|
26
|
+
return STATUS_KIND_BY_STATE[state] ?? "idle";
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function buildBubbleView(session, now = Date.now(), options = {}) {
|
|
30
|
+
const elapsedMs = Math.max(0, numeric(now) - numeric(session.startedAt));
|
|
31
|
+
|
|
32
|
+
return {
|
|
33
|
+
sessionId: session.sessionId,
|
|
34
|
+
clientId: session.clientId,
|
|
35
|
+
clientName: session.clientDisplayName ?? session.clientId,
|
|
36
|
+
projectName: session.projectName,
|
|
37
|
+
state: session.state,
|
|
38
|
+
statusLabel: buildStatusLabel(session.state),
|
|
39
|
+
statusKind: resolveBubbleStatusKind(session.state),
|
|
40
|
+
summary: buildSessionSummary(session),
|
|
41
|
+
petAction: safePetAction(session.state),
|
|
42
|
+
elapsedMs,
|
|
43
|
+
elapsedLabel: formatElapsed(elapsedMs),
|
|
44
|
+
selected: options.selectedSessionId === session.sessionId
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function buildBubbleViews(sessions, now = Date.now(), options = {}) {
|
|
49
|
+
if (!Array.isArray(sessions)) {
|
|
50
|
+
throw new TypeError("sessions must be an array");
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return sessions
|
|
54
|
+
.filter(Boolean)
|
|
55
|
+
.slice()
|
|
56
|
+
.sort(compareByPriority)
|
|
57
|
+
.map((session) => buildBubbleView(session, now, options));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function compareByPriority(left, right) {
|
|
61
|
+
const rankDelta = getSessionPriorityRank(left) - getSessionPriorityRank(right);
|
|
62
|
+
if (rankDelta !== 0) {
|
|
63
|
+
return rankDelta;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return numeric(right.updatedAt) - numeric(left.updatedAt);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function safePetAction(state) {
|
|
70
|
+
try {
|
|
71
|
+
return mapAiStateToPetAction(state);
|
|
72
|
+
} catch {
|
|
73
|
+
return "idle";
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function numeric(value) {
|
|
78
|
+
return Number.isFinite(value) ? value : 0;
|
|
79
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { mapAiStateToPetAction } from "../../pet-core/src/atlas.js";
|
|
2
|
+
|
|
3
|
+
// Sessions that are done — removed from the active bubble list.
|
|
4
|
+
const TERMINAL_STATES = new Set(["exited", "success"]);
|
|
5
|
+
|
|
6
|
+
// States that should NOT drive a looping stable action. success/exited are
|
|
7
|
+
// terminal; failed is a timed reaction (a stray error must not pin the pet on
|
|
8
|
+
// the "failed" animation forever).
|
|
9
|
+
const NON_STABLE_STATES = new Set(["exited", "success", "failed"]);
|
|
10
|
+
|
|
11
|
+
// States that play a one-shot reaction exactly once when first entered.
|
|
12
|
+
const ONE_SHOT_BY_STATE = Object.freeze({
|
|
13
|
+
success: "jumping",
|
|
14
|
+
failed: "failed"
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
// Active-work states. Transitioning from one of these to idle means a turn
|
|
18
|
+
// finished, which plays a "done" reaction (the session stays alive/idle).
|
|
19
|
+
const WORKING_STATES = new Set(["thinking", "running_tool", "editing_files", "reviewing", "compacting"]);
|
|
20
|
+
|
|
21
|
+
// Pure resolver for the global pet's stable action and one-shot reactions from
|
|
22
|
+
// the current session bubbles. Tracks the previous per-session state so success
|
|
23
|
+
// jumps / failure reactions fire once, then the pet settles to the most urgent
|
|
24
|
+
// ongoing work, or idle when nothing is active.
|
|
25
|
+
export function resolveCompanionPetState({ bubbles = [], prioritySessionId, previousStates = {} } = {}) {
|
|
26
|
+
const oneShots = [];
|
|
27
|
+
const nextStates = {};
|
|
28
|
+
|
|
29
|
+
for (const bubble of bubbles) {
|
|
30
|
+
nextStates[bubble.sessionId] = bubble.state;
|
|
31
|
+
const previous = previousStates[bubble.sessionId];
|
|
32
|
+
const reaction = ONE_SHOT_BY_STATE[bubble.state];
|
|
33
|
+
|
|
34
|
+
if (reaction && previous !== bubble.state) {
|
|
35
|
+
oneShots.push(reaction);
|
|
36
|
+
} else if (bubble.state === "idle" && WORKING_STATES.has(previous)) {
|
|
37
|
+
// A turn just finished (work went quiet) — celebrate, then settle to idle.
|
|
38
|
+
oneShots.push("jumping");
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const activeBubbles = bubbles.filter((bubble) => !TERMINAL_STATES.has(bubble.state));
|
|
43
|
+
const drivable = activeBubbles.filter((bubble) => !NON_STABLE_STATES.has(bubble.state));
|
|
44
|
+
const priority = drivable.find((bubble) => bubble.sessionId === prioritySessionId) ?? drivable[0];
|
|
45
|
+
const stableAction = priority ? safeAction(priority.state) : "idle";
|
|
46
|
+
|
|
47
|
+
return { stableAction, oneShots, activeBubbles, nextStates };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function safeAction(state) {
|
|
51
|
+
try {
|
|
52
|
+
return mapAiStateToPetAction(state);
|
|
53
|
+
} catch {
|
|
54
|
+
return "idle";
|
|
55
|
+
}
|
|
56
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
const PRIORITY_RANKS = Object.freeze({
|
|
2
|
+
waiting_approval: 1,
|
|
3
|
+
waiting_user: 2,
|
|
4
|
+
failed: 3,
|
|
5
|
+
running_tool: 4,
|
|
6
|
+
editing_files: 4,
|
|
7
|
+
thinking: 5,
|
|
8
|
+
reviewing: 5,
|
|
9
|
+
compacting: 5,
|
|
10
|
+
success: 6,
|
|
11
|
+
stale: 6,
|
|
12
|
+
idle: 7,
|
|
13
|
+
exited: 8
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
export function getSessionPriorityRank(session) {
|
|
17
|
+
return PRIORITY_RANKS[session.state] ?? 6;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function selectPrioritySession(sessions, options = {}) {
|
|
21
|
+
if (!Array.isArray(sessions)) {
|
|
22
|
+
throw new TypeError("sessions must be an array");
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const candidates = sessions.filter(Boolean);
|
|
26
|
+
if (candidates.length === 0) {
|
|
27
|
+
return undefined;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
if (options.pinnedSessionId) {
|
|
31
|
+
const pinnedSession = candidates.find((session) => session.sessionId === options.pinnedSessionId);
|
|
32
|
+
if (pinnedSession) {
|
|
33
|
+
return pinnedSession;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return candidates.slice().sort(compareSessionsByPriority)[0];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function compareSessionsByPriority(left, right) {
|
|
41
|
+
const rankDelta = getSessionPriorityRank(left) - getSessionPriorityRank(right);
|
|
42
|
+
if (rankDelta !== 0) {
|
|
43
|
+
return rankDelta;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const updatedDelta = numericTimestamp(right.updatedAt) - numericTimestamp(left.updatedAt);
|
|
47
|
+
if (updatedDelta !== 0) {
|
|
48
|
+
return updatedDelta;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return String(left.sessionId).localeCompare(String(right.sessionId));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function numericTimestamp(value) {
|
|
55
|
+
return Number.isFinite(value) ? value : 0;
|
|
56
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { assertProtocolMessage } from "../../protocol/src/messages.js";
|
|
2
|
+
import { selectPrioritySession } from "./priority.js";
|
|
3
|
+
|
|
4
|
+
const DEFAULT_STALE_AFTER_MS = 30_000;
|
|
5
|
+
const DEFAULT_DROP_AFTER_MS = 120_000;
|
|
6
|
+
|
|
7
|
+
export function createSessionRegistry(options = {}) {
|
|
8
|
+
return new SessionRegistry(options);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
class SessionRegistry {
|
|
12
|
+
constructor(options) {
|
|
13
|
+
this.sessions = new Map();
|
|
14
|
+
this.staleAfterMs = options.staleAfterMs ?? DEFAULT_STALE_AFTER_MS;
|
|
15
|
+
this.dropAfterMs = options.dropAfterMs ?? DEFAULT_DROP_AFTER_MS;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
applyMessage(message) {
|
|
19
|
+
assertProtocolMessage(message);
|
|
20
|
+
|
|
21
|
+
switch (message.type) {
|
|
22
|
+
case "register":
|
|
23
|
+
return this.register(message);
|
|
24
|
+
case "heartbeat":
|
|
25
|
+
return this.applyHeartbeat(message);
|
|
26
|
+
case "state":
|
|
27
|
+
return this.applyState(message);
|
|
28
|
+
case "unregister":
|
|
29
|
+
return this.unregister(message);
|
|
30
|
+
default:
|
|
31
|
+
throw new Error(`Unsupported protocol message type: ${message.type}`);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
getSession(sessionId) {
|
|
36
|
+
return snapshotSession(this.sessions.get(sessionId));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
listSessions() {
|
|
40
|
+
return Array.from(this.sessions.values(), snapshotSession);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
getPrioritySession(options = {}) {
|
|
44
|
+
return selectPrioritySession(this.listSessions(), options);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
markStaleSessions(now) {
|
|
48
|
+
if (!Number.isFinite(now)) {
|
|
49
|
+
throw new TypeError("now must be a finite timestamp");
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const staleSessions = [];
|
|
53
|
+
|
|
54
|
+
for (const [sessionId, session] of this.sessions) {
|
|
55
|
+
// Drop sessions with no real activity for dropAfterMs (dead wrappers that
|
|
56
|
+
// never sent unregister, or long-finished sessions) so the pet does not
|
|
57
|
+
// sit on a phantom session forever. The drop clock is based on the last
|
|
58
|
+
// real update — marking stale must NOT bump updatedAt, or it never elapses.
|
|
59
|
+
if (now - session.updatedAt > this.dropAfterMs) {
|
|
60
|
+
this.sessions.delete(sessionId);
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (session.state === "exited" || session.state === "stale") {
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (now - session.updatedAt > this.staleAfterMs) {
|
|
69
|
+
session.state = "stale";
|
|
70
|
+
session.source = "wrapper";
|
|
71
|
+
session.confidence = 0.3;
|
|
72
|
+
session.summary = "heartbeat stale";
|
|
73
|
+
staleSessions.push(snapshotSession(session));
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return staleSessions;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
register(message) {
|
|
81
|
+
const session = {
|
|
82
|
+
sessionId: message.sessionId,
|
|
83
|
+
clientId: message.clientId,
|
|
84
|
+
clientDisplayName: message.clientDisplayName,
|
|
85
|
+
pid: message.pid,
|
|
86
|
+
terminalPid: message.terminalPid,
|
|
87
|
+
cwd: message.cwd,
|
|
88
|
+
projectName: message.projectName,
|
|
89
|
+
state: "idle",
|
|
90
|
+
source: "wrapper",
|
|
91
|
+
startedAt: message.startedAt,
|
|
92
|
+
updatedAt: message.startedAt
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
this.sessions.set(message.sessionId, session);
|
|
96
|
+
return snapshotSession(session);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
applyHeartbeat(message) {
|
|
100
|
+
const session = this.requireSession(message.sessionId);
|
|
101
|
+
session.updatedAt = message.updatedAt;
|
|
102
|
+
return snapshotSession(session);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
applyState(message) {
|
|
106
|
+
const session = this.requireSession(message.sessionId);
|
|
107
|
+
session.state = message.state;
|
|
108
|
+
session.confidence = message.confidence;
|
|
109
|
+
session.source = message.source;
|
|
110
|
+
session.updatedAt = message.updatedAt;
|
|
111
|
+
|
|
112
|
+
if (Object.prototype.hasOwnProperty.call(message, "summary")) {
|
|
113
|
+
session.summary = message.summary;
|
|
114
|
+
} else {
|
|
115
|
+
delete session.summary;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return snapshotSession(session);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
unregister(message) {
|
|
122
|
+
const session = this.requireSession(message.sessionId);
|
|
123
|
+
session.state = "exited";
|
|
124
|
+
session.source = "wrapper";
|
|
125
|
+
session.confidence = 1;
|
|
126
|
+
session.exitCode = message.exitCode;
|
|
127
|
+
session.finishedAt = message.finishedAt;
|
|
128
|
+
session.updatedAt = message.finishedAt;
|
|
129
|
+
return snapshotSession(session);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
requireSession(sessionId) {
|
|
133
|
+
const session = this.sessions.get(sessionId);
|
|
134
|
+
if (!session) {
|
|
135
|
+
throw new Error(`Unknown session: ${sessionId}`);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return session;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function snapshotSession(session) {
|
|
143
|
+
return session ? { ...session } : undefined;
|
|
144
|
+
}
|