@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,61 @@
|
|
|
1
|
+
// Task control buttons (product plan section 23). The UI must hide or disable
|
|
2
|
+
// any control the selected adapter cannot safely support.
|
|
3
|
+
export const TASK_CONTROL_IDS = Object.freeze([
|
|
4
|
+
"focus_terminal",
|
|
5
|
+
"open_full",
|
|
6
|
+
"reply",
|
|
7
|
+
"approve",
|
|
8
|
+
"deny",
|
|
9
|
+
"continue",
|
|
10
|
+
"pause",
|
|
11
|
+
"resume",
|
|
12
|
+
"stop",
|
|
13
|
+
"restart",
|
|
14
|
+
"hide"
|
|
15
|
+
]);
|
|
16
|
+
|
|
17
|
+
const CONTROL_LABELS = Object.freeze({
|
|
18
|
+
focus_terminal: "Focus Terminal",
|
|
19
|
+
open_full: "Open Full Session",
|
|
20
|
+
reply: "Reply",
|
|
21
|
+
approve: "Approve",
|
|
22
|
+
deny: "Deny",
|
|
23
|
+
continue: "Continue",
|
|
24
|
+
pause: "Pause",
|
|
25
|
+
resume: "Resume",
|
|
26
|
+
stop: "Stop",
|
|
27
|
+
restart: "Restart",
|
|
28
|
+
hide: "Hide"
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
export function resolveTaskControls(capabilities = {}, status) {
|
|
32
|
+
const hasPendingApproval = status === "waiting_approval";
|
|
33
|
+
const canReply = capabilities.canReply !== undefined && capabilities.canReply !== "unsupported";
|
|
34
|
+
const canApprove = capabilities.canApprove !== undefined && capabilities.canApprove !== "unsupported";
|
|
35
|
+
|
|
36
|
+
const enabledById = {
|
|
37
|
+
focus_terminal: Boolean(capabilities.canFocusTerminal),
|
|
38
|
+
open_full: Boolean(capabilities.canOpenTranscript),
|
|
39
|
+
reply: canReply,
|
|
40
|
+
approve: canApprove && hasPendingApproval,
|
|
41
|
+
deny: canApprove && hasPendingApproval,
|
|
42
|
+
continue: Boolean(capabilities.canResume),
|
|
43
|
+
pause: Boolean(capabilities.canPause),
|
|
44
|
+
resume: Boolean(capabilities.canResume),
|
|
45
|
+
stop: Boolean(capabilities.canStop),
|
|
46
|
+
restart: Boolean(capabilities.canStop),
|
|
47
|
+
hide: true
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
return TASK_CONTROL_IDS.map((id) => ({
|
|
51
|
+
id,
|
|
52
|
+
label: CONTROL_LABELS[id],
|
|
53
|
+
enabled: enabledById[id]
|
|
54
|
+
}));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function listEnabledControls(capabilities, status) {
|
|
58
|
+
return resolveTaskControls(capabilities, status)
|
|
59
|
+
.filter((control) => control.enabled)
|
|
60
|
+
.map((control) => control.id);
|
|
61
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
export function buildTaskInputRequest({ sessionId, inputId, text, createdAt }) {
|
|
2
|
+
return {
|
|
3
|
+
type: "task_input",
|
|
4
|
+
sessionId,
|
|
5
|
+
inputId,
|
|
6
|
+
text,
|
|
7
|
+
createdAt
|
|
8
|
+
};
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function validateTaskInputRequest(input) {
|
|
12
|
+
if (!isPlainObject(input)) {
|
|
13
|
+
return { ok: false, errors: ["task input request must be an object"] };
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const errors = [];
|
|
17
|
+
|
|
18
|
+
if (input.type !== "task_input") {
|
|
19
|
+
errors.push('type must be "task_input"');
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
requireNonEmptyString(errors, input.sessionId, "sessionId");
|
|
23
|
+
requireNonEmptyString(errors, input.inputId, "inputId");
|
|
24
|
+
requireNonEmptyString(errors, input.text, "text");
|
|
25
|
+
|
|
26
|
+
if (!Number.isFinite(input.createdAt)) {
|
|
27
|
+
errors.push("createdAt must be a finite number");
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
return { ok: errors.length === 0, errors };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function validateTaskInputResult(input) {
|
|
34
|
+
if (!isPlainObject(input)) {
|
|
35
|
+
return { ok: false, errors: ["task input result must be an object"] };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const errors = [];
|
|
39
|
+
|
|
40
|
+
if (input.type !== "task_input_result") {
|
|
41
|
+
errors.push('type must be "task_input_result"');
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
requireNonEmptyString(errors, input.sessionId, "sessionId");
|
|
45
|
+
requireNonEmptyString(errors, input.inputId, "inputId");
|
|
46
|
+
|
|
47
|
+
if (typeof input.ok !== "boolean") {
|
|
48
|
+
errors.push("ok must be a boolean");
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (input.error !== undefined && typeof input.error !== "string") {
|
|
52
|
+
errors.push("error must be a string when provided");
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return { ok: errors.length === 0, errors };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Translate adapter reply capability into a safe UI mode. The reply button must
|
|
59
|
+
// never blindly type into a terminal when the adapter cannot verify the client
|
|
60
|
+
// is waiting for input (product plan section 21).
|
|
61
|
+
export function resolveReplyMode(capabilities = {}) {
|
|
62
|
+
switch (capabilities.canReply) {
|
|
63
|
+
case "supported":
|
|
64
|
+
return "send";
|
|
65
|
+
case "best_effort":
|
|
66
|
+
return "best-effort";
|
|
67
|
+
default:
|
|
68
|
+
return "open-terminal";
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function requireNonEmptyString(errors, value, fieldName) {
|
|
73
|
+
if (typeof value !== "string" || value.trim() === "") {
|
|
74
|
+
errors.push(`${fieldName} must be a non-empty string`);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function isPlainObject(value) {
|
|
79
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
80
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { isStateSource } from "../../protocol/src/messages.js";
|
|
2
|
+
import { isTaskStatus } from "./task-status.js";
|
|
3
|
+
|
|
4
|
+
export const TASK_EVENT_TYPES = Object.freeze([
|
|
5
|
+
"user_message",
|
|
6
|
+
"assistant_message",
|
|
7
|
+
"assistant_delta",
|
|
8
|
+
"status_changed",
|
|
9
|
+
"tool_started",
|
|
10
|
+
"tool_output",
|
|
11
|
+
"tool_finished",
|
|
12
|
+
"file_changed",
|
|
13
|
+
"diff_ready",
|
|
14
|
+
"test_started",
|
|
15
|
+
"test_finished",
|
|
16
|
+
"approval_requested",
|
|
17
|
+
"approval_resolved",
|
|
18
|
+
"error",
|
|
19
|
+
"summary",
|
|
20
|
+
"task_completed"
|
|
21
|
+
]);
|
|
22
|
+
|
|
23
|
+
const TASK_EVENT_TYPE_SET = new Set(TASK_EVENT_TYPES);
|
|
24
|
+
|
|
25
|
+
export function isTaskEventType(value) {
|
|
26
|
+
return TASK_EVENT_TYPE_SET.has(value);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function normalizeTaskEvent(input) {
|
|
30
|
+
if (!isPlainObject(input)) {
|
|
31
|
+
return { ok: false, errors: ["event must be an object"] };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const errors = [];
|
|
35
|
+
|
|
36
|
+
requireNonEmptyString(errors, input.id, "id");
|
|
37
|
+
requireNonEmptyString(errors, input.sessionId, "sessionId");
|
|
38
|
+
|
|
39
|
+
if (!isTaskEventType(input.type)) {
|
|
40
|
+
errors.push(`type must be one of: ${TASK_EVENT_TYPES.join(", ")}`);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (!Number.isFinite(input.timestamp)) {
|
|
44
|
+
errors.push("timestamp must be a finite number");
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (input.status !== undefined && !isTaskStatus(input.status)) {
|
|
48
|
+
errors.push("status must be a valid task status when provided");
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (input.source !== undefined && !isStateSource(input.source)) {
|
|
52
|
+
errors.push("source must be a valid state source when provided");
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (input.title !== undefined && typeof input.title !== "string") {
|
|
56
|
+
errors.push("title must be a string when provided");
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (input.text !== undefined && typeof input.text !== "string") {
|
|
60
|
+
errors.push("text must be a string when provided");
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (input.confidence !== undefined) {
|
|
64
|
+
if (!Number.isFinite(input.confidence) || input.confidence < 0 || input.confidence > 1) {
|
|
65
|
+
errors.push("confidence must be a number from 0 to 1 when provided");
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (errors.length > 0) {
|
|
70
|
+
return { ok: false, errors };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return { ok: true, errors: [], event: buildEvent(input) };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function buildEvent(input) {
|
|
77
|
+
const event = {
|
|
78
|
+
id: input.id,
|
|
79
|
+
sessionId: input.sessionId,
|
|
80
|
+
type: input.type,
|
|
81
|
+
timestamp: input.timestamp
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
for (const field of ["title", "text", "status", "confidence", "source", "payload"]) {
|
|
85
|
+
if (input[field] !== undefined) {
|
|
86
|
+
event[field] = input[field];
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return event;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function requireNonEmptyString(errors, value, fieldName) {
|
|
94
|
+
if (typeof value !== "string" || value.trim() === "") {
|
|
95
|
+
errors.push(`${fieldName} must be a non-empty string`);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function isPlainObject(value) {
|
|
100
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
101
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { mapAiStateToPetAction } from "../../pet-core/src/atlas.js";
|
|
2
|
+
|
|
3
|
+
export const TASK_STATUSES = Object.freeze([
|
|
4
|
+
"starting",
|
|
5
|
+
"thinking",
|
|
6
|
+
"running",
|
|
7
|
+
"running_command",
|
|
8
|
+
"editing_files",
|
|
9
|
+
"searching",
|
|
10
|
+
"waiting_user",
|
|
11
|
+
"waiting_approval",
|
|
12
|
+
"reviewing",
|
|
13
|
+
"compacting",
|
|
14
|
+
"blocked",
|
|
15
|
+
"failed",
|
|
16
|
+
"completed",
|
|
17
|
+
"paused",
|
|
18
|
+
"stopped",
|
|
19
|
+
"stale",
|
|
20
|
+
"exited"
|
|
21
|
+
]);
|
|
22
|
+
|
|
23
|
+
const TASK_STATUS_SET = new Set(TASK_STATUSES);
|
|
24
|
+
|
|
25
|
+
const TASK_STATUS_TO_AI_STATE = Object.freeze({
|
|
26
|
+
starting: "thinking",
|
|
27
|
+
thinking: "thinking",
|
|
28
|
+
running: "running_tool",
|
|
29
|
+
running_command: "running_tool",
|
|
30
|
+
editing_files: "editing_files",
|
|
31
|
+
searching: "running_tool",
|
|
32
|
+
waiting_user: "waiting_user",
|
|
33
|
+
waiting_approval: "waiting_approval",
|
|
34
|
+
reviewing: "reviewing",
|
|
35
|
+
compacting: "compacting",
|
|
36
|
+
blocked: "waiting_user",
|
|
37
|
+
failed: "failed",
|
|
38
|
+
completed: "success",
|
|
39
|
+
paused: "idle",
|
|
40
|
+
stopped: "exited",
|
|
41
|
+
stale: "stale",
|
|
42
|
+
exited: "exited"
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
const TASK_STATUS_PILLS = Object.freeze({
|
|
46
|
+
starting: "Starting",
|
|
47
|
+
thinking: "Thinking",
|
|
48
|
+
running: "Running",
|
|
49
|
+
running_command: "Running command",
|
|
50
|
+
editing_files: "Editing files",
|
|
51
|
+
searching: "Searching",
|
|
52
|
+
waiting_user: "Waiting for you",
|
|
53
|
+
waiting_approval: "Waiting for approval",
|
|
54
|
+
reviewing: "Reviewing",
|
|
55
|
+
compacting: "Compacting context",
|
|
56
|
+
blocked: "Blocked",
|
|
57
|
+
failed: "Failed",
|
|
58
|
+
completed: "Completed",
|
|
59
|
+
paused: "Paused",
|
|
60
|
+
stopped: "Stopped",
|
|
61
|
+
stale: "Stale",
|
|
62
|
+
exited: "Exited"
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
export function isTaskStatus(value) {
|
|
66
|
+
return TASK_STATUS_SET.has(value);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function mapTaskStatusToAiState(status) {
|
|
70
|
+
const aiState = TASK_STATUS_TO_AI_STATE[status];
|
|
71
|
+
if (!aiState) {
|
|
72
|
+
throw new Error(`Unknown task status: ${status}`);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return aiState;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function mapTaskStatusToPetAction(status) {
|
|
79
|
+
return mapAiStateToPetAction(mapTaskStatusToAiState(status));
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function buildTaskStatusPill(status) {
|
|
83
|
+
return TASK_STATUS_PILLS[status] ?? humanize(status);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function humanize(value) {
|
|
87
|
+
if (typeof value !== "string" || value === "") {
|
|
88
|
+
return "Unknown";
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const text = value.replace(/_/g, " ");
|
|
92
|
+
return text.charAt(0).toUpperCase() + text.slice(1);
|
|
93
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { normalizeTaskEvent } from "./task-events.js";
|
|
2
|
+
import { mapTaskStatusToAiState, mapTaskStatusToPetAction } from "./task-status.js";
|
|
3
|
+
|
|
4
|
+
const DEFAULT_MAX_EVENTS = 200;
|
|
5
|
+
|
|
6
|
+
export function createTaskStore(options = {}) {
|
|
7
|
+
return new TaskStore(options);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
class TaskStore {
|
|
11
|
+
constructor(options) {
|
|
12
|
+
this.maxEventsPerSession = options.maxEventsPerSession ?? DEFAULT_MAX_EVENTS;
|
|
13
|
+
this.events = new Map();
|
|
14
|
+
this.snapshots = new Map();
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
appendEvent(input) {
|
|
18
|
+
const result = normalizeTaskEvent(input);
|
|
19
|
+
if (!result.ok) {
|
|
20
|
+
throw new Error(result.errors.join("; "));
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const event = result.event;
|
|
24
|
+
const list = this.events.get(event.sessionId) ?? [];
|
|
25
|
+
list.push(event);
|
|
26
|
+
|
|
27
|
+
while (list.length > this.maxEventsPerSession) {
|
|
28
|
+
list.shift();
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
this.events.set(event.sessionId, list);
|
|
32
|
+
|
|
33
|
+
if (event.status !== undefined) {
|
|
34
|
+
this.updateSnapshot(event);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return { ...event };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
getEvents(sessionId, options = {}) {
|
|
41
|
+
const list = this.events.get(sessionId) ?? [];
|
|
42
|
+
const events = options.limit ? list.slice(-options.limit) : list.slice();
|
|
43
|
+
return events.map((event) => ({ ...event }));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
getLatestEvent(sessionId) {
|
|
47
|
+
const list = this.events.get(sessionId);
|
|
48
|
+
if (!list || list.length === 0) {
|
|
49
|
+
return undefined;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return { ...list[list.length - 1] };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
getStatusSnapshot(sessionId) {
|
|
56
|
+
const snapshot = this.snapshots.get(sessionId);
|
|
57
|
+
return snapshot ? { ...snapshot } : undefined;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
updateSnapshot(event) {
|
|
61
|
+
const previous = this.snapshots.get(event.sessionId);
|
|
62
|
+
|
|
63
|
+
this.snapshots.set(event.sessionId, {
|
|
64
|
+
sessionId: event.sessionId,
|
|
65
|
+
status: event.status,
|
|
66
|
+
aiState: mapTaskStatusToAiState(event.status),
|
|
67
|
+
petAction: mapTaskStatusToPetAction(event.status),
|
|
68
|
+
confidence: Number.isFinite(event.confidence) ? event.confidence : previous?.confidence ?? 1,
|
|
69
|
+
source: event.source ?? previous?.source ?? "manual",
|
|
70
|
+
updatedAt: event.timestamp,
|
|
71
|
+
summary: event.text ?? previous?.summary
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { test } from "../../../test/harness.mjs";
|
|
3
|
+
import {
|
|
4
|
+
APPROVAL_KINDS,
|
|
5
|
+
validateApprovalRequest,
|
|
6
|
+
buildApprovalDecision,
|
|
7
|
+
validateApprovalDecision
|
|
8
|
+
} from "../src/approvals.js";
|
|
9
|
+
|
|
10
|
+
test("validates an approval request with the documented fields", () => {
|
|
11
|
+
const result = validateApprovalRequest({
|
|
12
|
+
approvalId: "ap_1",
|
|
13
|
+
sessionId: "s",
|
|
14
|
+
kind: "command",
|
|
15
|
+
title: "Run npm test",
|
|
16
|
+
commandPreview: "npm test",
|
|
17
|
+
risk: "medium",
|
|
18
|
+
requestedAt: 100
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
assert.equal(result.ok, true);
|
|
22
|
+
assert.deepEqual(result.errors, []);
|
|
23
|
+
assert.ok(APPROVAL_KINDS.includes("file_write"));
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test("rejects approval requests with invalid kind or risk", () => {
|
|
27
|
+
const result = validateApprovalRequest({
|
|
28
|
+
approvalId: "ap_1",
|
|
29
|
+
sessionId: "s",
|
|
30
|
+
kind: "launch_missiles",
|
|
31
|
+
title: "x",
|
|
32
|
+
risk: "extreme",
|
|
33
|
+
requestedAt: 1
|
|
34
|
+
});
|
|
35
|
+
assert.equal(result.ok, false);
|
|
36
|
+
assert.ok(result.errors.some((m) => m.includes("kind")));
|
|
37
|
+
assert.ok(result.errors.some((m) => m.includes("risk")));
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test("builds and validates an approval decision", () => {
|
|
41
|
+
const decision = buildApprovalDecision({
|
|
42
|
+
sessionId: "s",
|
|
43
|
+
approvalId: "ap_1",
|
|
44
|
+
decision: "approve",
|
|
45
|
+
decidedAt: 200
|
|
46
|
+
});
|
|
47
|
+
assert.equal(decision.type, "approval_decision");
|
|
48
|
+
assert.equal(validateApprovalDecision(decision).ok, true);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("rejects decisions that are not approve or deny", () => {
|
|
52
|
+
const result = validateApprovalDecision({
|
|
53
|
+
type: "approval_decision",
|
|
54
|
+
sessionId: "s",
|
|
55
|
+
approvalId: "ap_1",
|
|
56
|
+
decision: "maybe",
|
|
57
|
+
decidedAt: 1
|
|
58
|
+
});
|
|
59
|
+
assert.equal(result.ok, false);
|
|
60
|
+
assert.ok(result.errors.some((m) => m.includes("decision")));
|
|
61
|
+
});
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { test } from "../../../test/harness.mjs";
|
|
3
|
+
import { resolveTaskControls, listEnabledControls } from "../src/controls.js";
|
|
4
|
+
|
|
5
|
+
const WRAPPER_CAPS = {
|
|
6
|
+
canReply: "unsupported",
|
|
7
|
+
canApprove: "unsupported",
|
|
8
|
+
canPause: false,
|
|
9
|
+
canResume: false,
|
|
10
|
+
canStop: true,
|
|
11
|
+
canFocusTerminal: true,
|
|
12
|
+
canOpenTranscript: false,
|
|
13
|
+
canShowDiffs: false,
|
|
14
|
+
canShowFiles: false,
|
|
15
|
+
canShowTests: false
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
const PTY_CAPS = {
|
|
19
|
+
canReply: "best_effort",
|
|
20
|
+
canApprove: "best_effort",
|
|
21
|
+
canPause: false,
|
|
22
|
+
canResume: false,
|
|
23
|
+
canStop: true,
|
|
24
|
+
canFocusTerminal: true,
|
|
25
|
+
canOpenTranscript: false,
|
|
26
|
+
canShowDiffs: false,
|
|
27
|
+
canShowFiles: false,
|
|
28
|
+
canShowTests: false
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
test("disables reply and approval for wrapper-only adapters", () => {
|
|
32
|
+
const controls = resolveTaskControls(WRAPPER_CAPS, "waiting_approval");
|
|
33
|
+
const reply = controls.find((c) => c.id === "reply");
|
|
34
|
+
const approve = controls.find((c) => c.id === "approve");
|
|
35
|
+
assert.equal(reply.enabled, false);
|
|
36
|
+
assert.equal(approve.enabled, false);
|
|
37
|
+
const focus = controls.find((c) => c.id === "focus_terminal");
|
|
38
|
+
assert.equal(focus.enabled, true);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("enables approval only when a pending approval exists", () => {
|
|
42
|
+
const waiting = resolveTaskControls(PTY_CAPS, "waiting_approval");
|
|
43
|
+
assert.equal(waiting.find((c) => c.id === "approve").enabled, true);
|
|
44
|
+
|
|
45
|
+
const running = resolveTaskControls(PTY_CAPS, "running_command");
|
|
46
|
+
assert.equal(running.find((c) => c.id === "approve").enabled, false);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("listEnabledControls returns only enabled control ids", () => {
|
|
50
|
+
const enabled = listEnabledControls(WRAPPER_CAPS, "running_command");
|
|
51
|
+
assert.ok(enabled.includes("focus_terminal"));
|
|
52
|
+
assert.ok(enabled.includes("stop"));
|
|
53
|
+
assert.ok(enabled.includes("hide"));
|
|
54
|
+
assert.ok(!enabled.includes("reply"));
|
|
55
|
+
assert.ok(!enabled.includes("approve"));
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("always allows hide regardless of capabilities", () => {
|
|
59
|
+
const controls = resolveTaskControls(WRAPPER_CAPS, "idle");
|
|
60
|
+
assert.equal(controls.find((c) => c.id === "hide").enabled, true);
|
|
61
|
+
});
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { test } from "../../../test/harness.mjs";
|
|
3
|
+
import {
|
|
4
|
+
buildTaskInputRequest,
|
|
5
|
+
validateTaskInputRequest,
|
|
6
|
+
validateTaskInputResult,
|
|
7
|
+
resolveReplyMode
|
|
8
|
+
} from "../src/replies.js";
|
|
9
|
+
|
|
10
|
+
test("builds and validates a task input request", () => {
|
|
11
|
+
const request = buildTaskInputRequest({
|
|
12
|
+
sessionId: "s",
|
|
13
|
+
inputId: "in_1",
|
|
14
|
+
text: "continue please",
|
|
15
|
+
createdAt: 10
|
|
16
|
+
});
|
|
17
|
+
assert.equal(request.type, "task_input");
|
|
18
|
+
assert.equal(validateTaskInputRequest(request).ok, true);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test("rejects empty reply text", () => {
|
|
22
|
+
const result = validateTaskInputRequest({
|
|
23
|
+
type: "task_input",
|
|
24
|
+
sessionId: "s",
|
|
25
|
+
inputId: "in_1",
|
|
26
|
+
text: " ",
|
|
27
|
+
createdAt: 1
|
|
28
|
+
});
|
|
29
|
+
assert.equal(result.ok, false);
|
|
30
|
+
assert.ok(result.errors.some((m) => m.includes("text")));
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("validates a task input result", () => {
|
|
34
|
+
assert.equal(
|
|
35
|
+
validateTaskInputResult({
|
|
36
|
+
type: "task_input_result",
|
|
37
|
+
sessionId: "s",
|
|
38
|
+
inputId: "in_1",
|
|
39
|
+
ok: true,
|
|
40
|
+
acceptedAt: 20
|
|
41
|
+
}).ok,
|
|
42
|
+
true
|
|
43
|
+
);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test("resolves the safe reply mode from adapter capability", () => {
|
|
47
|
+
assert.equal(resolveReplyMode({ canReply: "supported" }), "send");
|
|
48
|
+
assert.equal(resolveReplyMode({ canReply: "best_effort" }), "best-effort");
|
|
49
|
+
assert.equal(resolveReplyMode({ canReply: "unsupported" }), "open-terminal");
|
|
50
|
+
assert.equal(resolveReplyMode({}), "open-terminal");
|
|
51
|
+
});
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { test } from "../../../test/harness.mjs";
|
|
3
|
+
import {
|
|
4
|
+
TASK_EVENT_TYPES,
|
|
5
|
+
isTaskEventType,
|
|
6
|
+
normalizeTaskEvent
|
|
7
|
+
} from "../src/task-events.js";
|
|
8
|
+
|
|
9
|
+
test("defines the documented task event vocabulary", () => {
|
|
10
|
+
for (const type of [
|
|
11
|
+
"user_message",
|
|
12
|
+
"assistant_message",
|
|
13
|
+
"status_changed",
|
|
14
|
+
"tool_started",
|
|
15
|
+
"approval_requested",
|
|
16
|
+
"task_completed"
|
|
17
|
+
]) {
|
|
18
|
+
assert.ok(TASK_EVENT_TYPES.includes(type), `missing ${type}`);
|
|
19
|
+
}
|
|
20
|
+
assert.equal(isTaskEventType("tool_output"), true);
|
|
21
|
+
assert.equal(isTaskEventType("nope"), false);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test("normalizes a valid task event", () => {
|
|
25
|
+
const result = normalizeTaskEvent({
|
|
26
|
+
id: "evt_1",
|
|
27
|
+
sessionId: "sess_a",
|
|
28
|
+
type: "status_changed",
|
|
29
|
+
timestamp: 1000,
|
|
30
|
+
status: "running_command",
|
|
31
|
+
source: "pty_output",
|
|
32
|
+
text: "running tests"
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
assert.equal(result.ok, true);
|
|
36
|
+
assert.deepEqual(result.errors, []);
|
|
37
|
+
assert.equal(result.event.type, "status_changed");
|
|
38
|
+
assert.equal(result.event.status, "running_command");
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("rejects events with missing or invalid fields", () => {
|
|
42
|
+
const missing = normalizeTaskEvent({ type: "user_message" });
|
|
43
|
+
assert.equal(missing.ok, false);
|
|
44
|
+
assert.ok(missing.errors.some((m) => m.includes("id")));
|
|
45
|
+
assert.ok(missing.errors.some((m) => m.includes("sessionId")));
|
|
46
|
+
|
|
47
|
+
const badType = normalizeTaskEvent({
|
|
48
|
+
id: "e",
|
|
49
|
+
sessionId: "s",
|
|
50
|
+
type: "explode",
|
|
51
|
+
timestamp: 1
|
|
52
|
+
});
|
|
53
|
+
assert.equal(badType.ok, false);
|
|
54
|
+
assert.ok(badType.errors.some((m) => m.includes("type")));
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("rejects an invalid status on an otherwise valid event", () => {
|
|
58
|
+
const result = normalizeTaskEvent({
|
|
59
|
+
id: "e",
|
|
60
|
+
sessionId: "s",
|
|
61
|
+
type: "status_changed",
|
|
62
|
+
timestamp: 1,
|
|
63
|
+
status: "not-a-status"
|
|
64
|
+
});
|
|
65
|
+
assert.equal(result.ok, false);
|
|
66
|
+
assert.ok(result.errors.some((m) => m.includes("status")));
|
|
67
|
+
});
|