@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,164 @@
|
|
|
1
|
+
import { getClientRules, matchAiState } from "./heuristics.js";
|
|
2
|
+
|
|
3
|
+
// Confidence is intentionally moderate: PTY output is best-effort and lower
|
|
4
|
+
// precedence than official hooks or client state files (plan §25).
|
|
5
|
+
const DEFAULT_CONFIDENCE = 0.6;
|
|
6
|
+
const SUMMARY_MAX_LENGTH = 120;
|
|
7
|
+
// Quiet window after which we assume the CLI stopped working (e.g. waiting for
|
|
8
|
+
// the user) and revert to idle.
|
|
9
|
+
const DEFAULT_IDLE_MS = 2000;
|
|
10
|
+
const DEFAULT_ACTIVE_STATE = "running_tool";
|
|
11
|
+
// How long after a keystroke we treat fresh output as the terminal echoing the
|
|
12
|
+
// user's typing rather than the AI working. Each keystroke re-arms it, so a
|
|
13
|
+
// burst of typing never registers as activity.
|
|
14
|
+
const DEFAULT_INPUT_GRACE_MS = 250;
|
|
15
|
+
|
|
16
|
+
const defaultSetTimer = (fn, ms) => {
|
|
17
|
+
const timer = setTimeout(fn, ms);
|
|
18
|
+
if (typeof timer.unref === "function") {
|
|
19
|
+
timer.unref();
|
|
20
|
+
}
|
|
21
|
+
return timer;
|
|
22
|
+
};
|
|
23
|
+
const defaultClearTimer = (id) => clearTimeout(id);
|
|
24
|
+
|
|
25
|
+
// Level 2 PTY observer (product plan section 6/24).
|
|
26
|
+
//
|
|
27
|
+
// Default mode is ACTIVITY-BASED, which is robust for rich TUIs (Claude Code,
|
|
28
|
+
// Codex) where output is full of ANSI/redraws and rarely contains tidy keywords:
|
|
29
|
+
// - any visible output -> "working" (running_tool)
|
|
30
|
+
// - no output for idleMs -> idle
|
|
31
|
+
// It deliberately does NOT infer "failed" from output text — a stray "error" in
|
|
32
|
+
// normal content must not flip the pet to failed; real failures come from the
|
|
33
|
+
// process exit code in the wrapper.
|
|
34
|
+
//
|
|
35
|
+
// Opt in to keyword heuristics with `useHeuristics: true` to additionally detect
|
|
36
|
+
// specific states (e.g. waiting_approval) from recognizable lines.
|
|
37
|
+
export function createOutputObserver({
|
|
38
|
+
clientId,
|
|
39
|
+
rules,
|
|
40
|
+
onState,
|
|
41
|
+
now = Date.now,
|
|
42
|
+
confidence = DEFAULT_CONFIDENCE,
|
|
43
|
+
idleMs = DEFAULT_IDLE_MS,
|
|
44
|
+
idleState = "idle",
|
|
45
|
+
activeState = DEFAULT_ACTIVE_STATE,
|
|
46
|
+
useHeuristics = false,
|
|
47
|
+
inputGraceMs = DEFAULT_INPUT_GRACE_MS,
|
|
48
|
+
setTimer = defaultSetTimer,
|
|
49
|
+
clearTimer = defaultClearTimer
|
|
50
|
+
} = {}) {
|
|
51
|
+
if (typeof onState !== "function") {
|
|
52
|
+
throw new TypeError("onState must be a function");
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const resolvedRules = rules ?? getClientRules(clientId);
|
|
56
|
+
let buffer = "";
|
|
57
|
+
let currentState;
|
|
58
|
+
let idleTimer;
|
|
59
|
+
let lastInputAt;
|
|
60
|
+
|
|
61
|
+
// True while fresh output is most likely the terminal echoing the user's
|
|
62
|
+
// keystrokes (which a PTY mirrors back) rather than AI activity.
|
|
63
|
+
function isInputEcho() {
|
|
64
|
+
return inputGraceMs > 0 && lastInputAt !== undefined && now() - lastInputAt <= inputGraceMs;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function emit(state, summary) {
|
|
68
|
+
currentState = state;
|
|
69
|
+
onState({ state, summary, confidence, source: "pty_output", updatedAt: now() });
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function cancelIdle() {
|
|
73
|
+
if (idleTimer !== undefined) {
|
|
74
|
+
clearTimer(idleTimer);
|
|
75
|
+
idleTimer = undefined;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function armIdle() {
|
|
80
|
+
if (!idleMs) {
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
cancelIdle();
|
|
84
|
+
idleTimer = setTimer(() => {
|
|
85
|
+
idleTimer = undefined;
|
|
86
|
+
if (currentState !== idleState) {
|
|
87
|
+
emit(idleState, "no recent activity");
|
|
88
|
+
}
|
|
89
|
+
}, idleMs);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return {
|
|
93
|
+
noteInput() {
|
|
94
|
+
// Record that the user just typed; subsequent echoed output is not work.
|
|
95
|
+
lastInputAt = now();
|
|
96
|
+
},
|
|
97
|
+
|
|
98
|
+
push(chunk) {
|
|
99
|
+
const text = String(chunk);
|
|
100
|
+
buffer += text;
|
|
101
|
+
|
|
102
|
+
// Echoed keystrokes are the user, not the AI — don't classify or count
|
|
103
|
+
// them as activity, but keep the buffer bounded.
|
|
104
|
+
const echo = isInputEcho();
|
|
105
|
+
|
|
106
|
+
let matched = false;
|
|
107
|
+
if (useHeuristics && !echo) {
|
|
108
|
+
let newlineIndex = buffer.indexOf("\n");
|
|
109
|
+
while (newlineIndex !== -1) {
|
|
110
|
+
const line = buffer.slice(0, newlineIndex);
|
|
111
|
+
buffer = buffer.slice(newlineIndex + 1);
|
|
112
|
+
const state = matchAiState(line, resolvedRules);
|
|
113
|
+
if (state) {
|
|
114
|
+
matched = true;
|
|
115
|
+
if (state !== currentState) {
|
|
116
|
+
emit(state, summarize(line));
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
newlineIndex = buffer.indexOf("\n");
|
|
120
|
+
}
|
|
121
|
+
} else {
|
|
122
|
+
// Activity mode (or echo): we don't classify lines, so drop completed
|
|
123
|
+
// ones to keep the buffer bounded.
|
|
124
|
+
const lastNewline = buffer.lastIndexOf("\n");
|
|
125
|
+
if (lastNewline !== -1) {
|
|
126
|
+
buffer = buffer.slice(lastNewline + 1);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const hadOutput = /\S/.test(text);
|
|
131
|
+
if (hadOutput && !echo) {
|
|
132
|
+
if (!matched && currentState !== activeState) {
|
|
133
|
+
emit(activeState, "working");
|
|
134
|
+
}
|
|
135
|
+
armIdle();
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return currentState;
|
|
139
|
+
},
|
|
140
|
+
|
|
141
|
+
flush() {
|
|
142
|
+
buffer = "";
|
|
143
|
+
return currentState;
|
|
144
|
+
},
|
|
145
|
+
|
|
146
|
+
getState() {
|
|
147
|
+
return currentState;
|
|
148
|
+
},
|
|
149
|
+
|
|
150
|
+
stop() {
|
|
151
|
+
cancelIdle();
|
|
152
|
+
},
|
|
153
|
+
|
|
154
|
+
reset() {
|
|
155
|
+
buffer = "";
|
|
156
|
+
currentState = undefined;
|
|
157
|
+
cancelIdle();
|
|
158
|
+
}
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function summarize(line) {
|
|
163
|
+
return line.trim().slice(0, SUMMARY_MAX_LENGTH);
|
|
164
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { validateTaskInputRequest } from "../../task-core/src/replies.js";
|
|
2
|
+
import { validateApprovalDecision } from "../../task-core/src/approvals.js";
|
|
3
|
+
|
|
4
|
+
// Maps an adapter capability level to a concrete injection strategy following
|
|
5
|
+
// the preferred order in product plan section 24. The "focus_terminal" result
|
|
6
|
+
// means the runtime must NOT inject and should fall back to the user typing.
|
|
7
|
+
function strategyFor(level) {
|
|
8
|
+
if (level === "supported") {
|
|
9
|
+
return "structured";
|
|
10
|
+
}
|
|
11
|
+
if (level === "best_effort") {
|
|
12
|
+
return "pty";
|
|
13
|
+
}
|
|
14
|
+
return "focus_terminal";
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function resolveReplyStrategy(capabilities = {}) {
|
|
18
|
+
return strategyFor(capabilities.canReply);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function resolveApprovalStrategy(capabilities = {}) {
|
|
22
|
+
return strategyFor(capabilities.canApprove);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export async function routeReply({ request, capabilities = {}, injectors = {}, now = Date.now }) {
|
|
26
|
+
const validation = validateTaskInputRequest(request);
|
|
27
|
+
if (!validation.ok) {
|
|
28
|
+
return replyResult(request, { ok: false, error: validation.errors.join("; ") });
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const strategy = resolveReplyStrategy(capabilities);
|
|
32
|
+
if (strategy === "focus_terminal") {
|
|
33
|
+
return replyResult(request, { ok: false, error: "reply_unsupported" });
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return dispatch(injectors[strategy], request, () => replyResult(request, { ok: true, acceptedAt: now() }), (error) =>
|
|
37
|
+
replyResult(request, { ok: false, error })
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export async function routeApproval({ decision, capabilities = {}, injectors = {}, now = Date.now }) {
|
|
42
|
+
const validation = validateApprovalDecision(decision);
|
|
43
|
+
if (!validation.ok) {
|
|
44
|
+
return approvalResult(decision, { ok: false, error: validation.errors.join("; ") });
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const strategy = resolveApprovalStrategy(capabilities);
|
|
48
|
+
if (strategy === "focus_terminal") {
|
|
49
|
+
return approvalResult(decision, { ok: false, error: "approval_unsupported" });
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return dispatch(injectors[strategy], decision, () => approvalResult(decision, { ok: true, decidedAt: now() }), (error) =>
|
|
53
|
+
approvalResult(decision, { ok: false, error })
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async function dispatch(injector, payload, onOk, onError) {
|
|
58
|
+
if (typeof injector !== "function") {
|
|
59
|
+
return onError("no_injector");
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
try {
|
|
63
|
+
await injector(payload);
|
|
64
|
+
return onOk();
|
|
65
|
+
} catch (error) {
|
|
66
|
+
return onError(error?.message ?? String(error));
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function replyResult(request, fields) {
|
|
71
|
+
return {
|
|
72
|
+
type: "task_input_result",
|
|
73
|
+
sessionId: request?.sessionId,
|
|
74
|
+
inputId: request?.inputId,
|
|
75
|
+
...fields
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function approvalResult(decision, fields) {
|
|
80
|
+
return {
|
|
81
|
+
type: "approval_result",
|
|
82
|
+
sessionId: decision?.sessionId,
|
|
83
|
+
approvalId: decision?.approvalId,
|
|
84
|
+
...fields
|
|
85
|
+
};
|
|
86
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { test } from "../../../test/harness.mjs";
|
|
3
|
+
import {
|
|
4
|
+
KNOWN_CLIENT_IDS,
|
|
5
|
+
getAdapterInfo,
|
|
6
|
+
listAdapters,
|
|
7
|
+
resolveAdapterInfo
|
|
8
|
+
} from "../src/adapter-info.js";
|
|
9
|
+
|
|
10
|
+
test("registers the four initial client targets", () => {
|
|
11
|
+
assert.deepEqual(KNOWN_CLIENT_IDS, ["codex", "claude-code", "antigravity", "generic"]);
|
|
12
|
+
assert.equal(listAdapters().length, 4);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
test("exposes adapter info fields from the plan", () => {
|
|
16
|
+
const codex = getAdapterInfo("codex");
|
|
17
|
+
assert.equal(codex.id, "codex");
|
|
18
|
+
assert.equal(codex.displayName, "Codex");
|
|
19
|
+
assert.ok([1, 2, 3, 4].includes(codex.supportLevel));
|
|
20
|
+
assert.ok(Array.isArray(codex.knownProcessNames));
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test("returns undefined for unknown clients", () => {
|
|
24
|
+
assert.equal(getAdapterInfo("nope"), undefined);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test("resolves unknown clients to a generic-based adapter", () => {
|
|
28
|
+
const resolved = resolveAdapterInfo("my-ai-cli");
|
|
29
|
+
assert.equal(resolved.id, "my-ai-cli");
|
|
30
|
+
assert.equal(resolved.displayName, "my-ai-cli");
|
|
31
|
+
assert.equal(resolved.supportLevel, 1);
|
|
32
|
+
|
|
33
|
+
const known = resolveAdapterInfo("claude-code");
|
|
34
|
+
assert.equal(known.displayName, "Claude Code");
|
|
35
|
+
});
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { test } from "../../../test/harness.mjs";
|
|
3
|
+
import { getAdapterCapabilities } from "../src/capabilities.js";
|
|
4
|
+
|
|
5
|
+
test("generic wrapper cannot safely reply or approve", () => {
|
|
6
|
+
const caps = getAdapterCapabilities("generic");
|
|
7
|
+
assert.equal(caps.canReply, "unsupported");
|
|
8
|
+
assert.equal(caps.canApprove, "unsupported");
|
|
9
|
+
assert.equal(caps.canStop, true);
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
test("PTY-capable adapters report best-effort reply/approval", () => {
|
|
13
|
+
const codex = getAdapterCapabilities("codex");
|
|
14
|
+
assert.equal(codex.canReply, "best_effort");
|
|
15
|
+
assert.equal(codex.canApprove, "best_effort");
|
|
16
|
+
|
|
17
|
+
const claude = getAdapterCapabilities("claude-code");
|
|
18
|
+
assert.equal(claude.canReply, "best_effort");
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test("unknown clients fall back to safe wrapper capabilities", () => {
|
|
22
|
+
const caps = getAdapterCapabilities("mystery");
|
|
23
|
+
assert.equal(caps.canReply, "unsupported");
|
|
24
|
+
assert.equal(caps.canApprove, "unsupported");
|
|
25
|
+
assert.equal(typeof caps.canFocusTerminal, "boolean");
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test("capability objects declare every documented field", () => {
|
|
29
|
+
const caps = getAdapterCapabilities("codex");
|
|
30
|
+
for (const field of [
|
|
31
|
+
"canReply",
|
|
32
|
+
"canApprove",
|
|
33
|
+
"canPause",
|
|
34
|
+
"canResume",
|
|
35
|
+
"canStop",
|
|
36
|
+
"canFocusTerminal",
|
|
37
|
+
"canOpenTranscript",
|
|
38
|
+
"canShowDiffs",
|
|
39
|
+
"canShowFiles",
|
|
40
|
+
"canShowTests"
|
|
41
|
+
]) {
|
|
42
|
+
assert.ok(field in caps, `missing ${field}`);
|
|
43
|
+
}
|
|
44
|
+
});
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { test } from "../../../test/harness.mjs";
|
|
3
|
+
import {
|
|
4
|
+
DEFAULT_GENERIC_RULES,
|
|
5
|
+
getClientRules,
|
|
6
|
+
matchAiState
|
|
7
|
+
} from "../src/heuristics.js";
|
|
8
|
+
|
|
9
|
+
test("matches generic regex rules to normalized states", () => {
|
|
10
|
+
assert.equal(matchAiState("Do you want to approve this command?"), "waiting_approval");
|
|
11
|
+
assert.equal(matchAiState("$ npm test"), "running_tool");
|
|
12
|
+
assert.equal(matchAiState("Error: build failed"), "failed");
|
|
13
|
+
assert.equal(matchAiState("Reviewing the diff now"), "reviewing");
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
test("returns undefined when no rule matches", () => {
|
|
17
|
+
assert.equal(matchAiState("just some neutral text"), undefined);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test("prefers the most urgent state when multiple rules match", () => {
|
|
21
|
+
// contains both "running" and "permission" -> approval should win
|
|
22
|
+
assert.equal(matchAiState("running command, needs permission to continue"), "waiting_approval");
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test("exposes default generic rules covering the documented categories", () => {
|
|
26
|
+
assert.ok(DEFAULT_GENERIC_RULES.waiting_approval);
|
|
27
|
+
assert.ok(DEFAULT_GENERIC_RULES.running_tool);
|
|
28
|
+
assert.ok(DEFAULT_GENERIC_RULES.failed);
|
|
29
|
+
assert.ok(DEFAULT_GENERIC_RULES.reviewing);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test("provides client-specific rules with generic fallback", () => {
|
|
33
|
+
const codexRules = getClientRules("codex");
|
|
34
|
+
assert.ok(codexRules.editing_files);
|
|
35
|
+
|
|
36
|
+
const fallback = getClientRules("unknown-client");
|
|
37
|
+
assert.deepEqual(fallback, DEFAULT_GENERIC_RULES);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test("ignores invalid patterns instead of throwing", () => {
|
|
41
|
+
assert.doesNotThrow(() => matchAiState("text", { failed: ["(unclosed"] }));
|
|
42
|
+
});
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { test } from "../../../test/harness.mjs";
|
|
3
|
+
import { createOutputObserver } from "../src/output-observer.js";
|
|
4
|
+
|
|
5
|
+
function makeObserver(overrides = {}) {
|
|
6
|
+
const states = [];
|
|
7
|
+
const timers = [];
|
|
8
|
+
const observer = createOutputObserver({
|
|
9
|
+
clientId: "generic",
|
|
10
|
+
now: () => 1,
|
|
11
|
+
onState: (event) => states.push(event),
|
|
12
|
+
setTimer: (fn) => {
|
|
13
|
+
timers.push(fn);
|
|
14
|
+
return timers.length - 1;
|
|
15
|
+
},
|
|
16
|
+
clearTimer: (id) => {
|
|
17
|
+
timers[id] = undefined;
|
|
18
|
+
},
|
|
19
|
+
...overrides
|
|
20
|
+
});
|
|
21
|
+
return { observer, states, fireIdle: () => timers.filter(Boolean).at(-1)?.() };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// --- Default: activity-based ---
|
|
25
|
+
|
|
26
|
+
test("treats any output as working (running_tool)", () => {
|
|
27
|
+
const { observer, states } = makeObserver();
|
|
28
|
+
observer.push("anything the AI prints\n");
|
|
29
|
+
assert.equal(states.at(-1).state, "running_tool");
|
|
30
|
+
assert.equal(states.at(-1).source, "pty_output");
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("does NOT infer failed from a stray 'error' in output", () => {
|
|
34
|
+
const { observer, states } = makeObserver();
|
|
35
|
+
observer.push("No errors found; build succeeded\n");
|
|
36
|
+
// activity = working, never failed
|
|
37
|
+
assert.equal(states.at(-1).state, "running_tool");
|
|
38
|
+
assert.ok(!states.some((event) => event.state === "failed"));
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("does not re-emit working on continued output", () => {
|
|
42
|
+
const { observer, states } = makeObserver();
|
|
43
|
+
observer.push("line one\n");
|
|
44
|
+
observer.push("line two\n");
|
|
45
|
+
assert.equal(states.filter((event) => event.state === "running_tool").length, 1);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("ignores whitespace-only chunks", () => {
|
|
49
|
+
const { observer, states } = makeObserver();
|
|
50
|
+
observer.push(" \r\n");
|
|
51
|
+
assert.equal(states.length, 0);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("reverts to idle after a quiet period and resumes on new output", () => {
|
|
55
|
+
const { observer, states, fireIdle } = makeObserver();
|
|
56
|
+
observer.push("working...\n");
|
|
57
|
+
assert.equal(observer.getState(), "running_tool");
|
|
58
|
+
|
|
59
|
+
fireIdle();
|
|
60
|
+
assert.equal(states.at(-1).state, "idle");
|
|
61
|
+
assert.equal(observer.getState(), "idle");
|
|
62
|
+
|
|
63
|
+
observer.push("more output\n");
|
|
64
|
+
assert.equal(states.at(-1).state, "running_tool");
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("does not arm an idle timer when idleMs is 0", () => {
|
|
68
|
+
const timers = [];
|
|
69
|
+
const observer = createOutputObserver({
|
|
70
|
+
clientId: "generic",
|
|
71
|
+
idleMs: 0,
|
|
72
|
+
onState: () => {},
|
|
73
|
+
setTimer: (fn) => {
|
|
74
|
+
timers.push(fn);
|
|
75
|
+
return timers.length - 1;
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
observer.push("running\n");
|
|
79
|
+
assert.equal(timers.length, 0);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
// --- Opt-in keyword heuristics ---
|
|
83
|
+
|
|
84
|
+
test("with useHeuristics, recognizes specific states from lines", () => {
|
|
85
|
+
const { observer, states } = makeObserver({ useHeuristics: true });
|
|
86
|
+
observer.push("Do you want to approve this command?\n");
|
|
87
|
+
assert.equal(states.at(-1).state, "waiting_approval");
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test("with useHeuristics, unmatched output still counts as working", () => {
|
|
91
|
+
const { observer, states } = makeObserver({ useHeuristics: true });
|
|
92
|
+
observer.push("some neutral progress text\n");
|
|
93
|
+
assert.equal(states.at(-1).state, "running_tool");
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
// --- User-typing echo suppression (working is AI activity, not the user) ---
|
|
97
|
+
|
|
98
|
+
function makeClockedObserver(overrides = {}) {
|
|
99
|
+
const states = [];
|
|
100
|
+
let clock = 1000;
|
|
101
|
+
const observer = createOutputObserver({
|
|
102
|
+
clientId: "generic",
|
|
103
|
+
now: () => clock,
|
|
104
|
+
onState: (event) => states.push(event),
|
|
105
|
+
setTimer: () => 0,
|
|
106
|
+
clearTimer: () => {},
|
|
107
|
+
...overrides
|
|
108
|
+
});
|
|
109
|
+
return { observer, states, tick: (ms) => { clock += ms; }, now: () => clock };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
test("does NOT flip to working when output is an echo of recent keystrokes", () => {
|
|
113
|
+
const { observer, states } = makeClockedObserver({ inputGraceMs: 250 });
|
|
114
|
+
observer.noteInput(); // user pressed a key
|
|
115
|
+
observer.push("h"); // terminal echoes it back immediately
|
|
116
|
+
assert.equal(states.length, 0); // not AI activity -> no state change
|
|
117
|
+
assert.equal(observer.getState(), undefined);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test("treats output as working once the input-grace window has passed", () => {
|
|
121
|
+
const { observer, states, tick } = makeClockedObserver({ inputGraceMs: 250 });
|
|
122
|
+
observer.noteInput();
|
|
123
|
+
tick(400); // user has stopped typing; the AI now responds
|
|
124
|
+
observer.push("AI is generating a response...\n");
|
|
125
|
+
assert.equal(states.at(-1).state, "running_tool");
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test("each keystroke re-arms the grace window so continuous typing never looks like work", () => {
|
|
129
|
+
const { observer, states, tick } = makeClockedObserver({ inputGraceMs: 250 });
|
|
130
|
+
for (let i = 0; i < 5; i++) {
|
|
131
|
+
observer.noteInput();
|
|
132
|
+
tick(100); // typing faster than the grace window
|
|
133
|
+
observer.push("x"); // echo
|
|
134
|
+
}
|
|
135
|
+
assert.equal(states.filter((e) => e.state === "running_tool").length, 0);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test("output with no preceding input is still working (default behaviour unchanged)", () => {
|
|
139
|
+
const { observer, states } = makeClockedObserver({ inputGraceMs: 250 });
|
|
140
|
+
observer.push("autonomous AI output\n");
|
|
141
|
+
assert.equal(states.at(-1).state, "running_tool");
|
|
142
|
+
});
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { test } from "../../../test/harness.mjs";
|
|
3
|
+
import {
|
|
4
|
+
resolveReplyStrategy,
|
|
5
|
+
resolveApprovalStrategy,
|
|
6
|
+
routeReply,
|
|
7
|
+
routeApproval
|
|
8
|
+
} from "../src/routing.js";
|
|
9
|
+
|
|
10
|
+
const SUPPORTED = { canReply: "supported", canApprove: "supported" };
|
|
11
|
+
const BEST_EFFORT = { canReply: "best_effort", canApprove: "best_effort" };
|
|
12
|
+
const UNSUPPORTED = { canReply: "unsupported", canApprove: "unsupported" };
|
|
13
|
+
|
|
14
|
+
const validRequest = { type: "task_input", sessionId: "s", inputId: "in_1", text: "go", createdAt: 1 };
|
|
15
|
+
const validDecision = { type: "approval_decision", sessionId: "s", approvalId: "ap_1", decision: "approve", decidedAt: 1 };
|
|
16
|
+
|
|
17
|
+
test("resolves injection strategy from capability level", () => {
|
|
18
|
+
assert.equal(resolveReplyStrategy(SUPPORTED), "structured");
|
|
19
|
+
assert.equal(resolveReplyStrategy(BEST_EFFORT), "pty");
|
|
20
|
+
assert.equal(resolveReplyStrategy(UNSUPPORTED), "focus_terminal");
|
|
21
|
+
assert.equal(resolveApprovalStrategy(BEST_EFFORT), "pty");
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test("routes a reply through the structured injector when supported", async () => {
|
|
25
|
+
const calls = [];
|
|
26
|
+
const result = await routeReply({
|
|
27
|
+
request: validRequest,
|
|
28
|
+
capabilities: SUPPORTED,
|
|
29
|
+
now: () => 50,
|
|
30
|
+
injectors: { structured: async (req) => calls.push(req) }
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
assert.equal(result.ok, true);
|
|
34
|
+
assert.equal(result.acceptedAt, 50);
|
|
35
|
+
assert.equal(calls.length, 1);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("never injects for unsupported adapters", async () => {
|
|
39
|
+
const calls = [];
|
|
40
|
+
const result = await routeReply({
|
|
41
|
+
request: validRequest,
|
|
42
|
+
capabilities: UNSUPPORTED,
|
|
43
|
+
injectors: { structured: async () => calls.push("x"), pty: async () => calls.push("y") }
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
assert.equal(result.ok, false);
|
|
47
|
+
assert.equal(result.error, "reply_unsupported");
|
|
48
|
+
assert.equal(calls.length, 0);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("reports injector failure without throwing", async () => {
|
|
52
|
+
const result = await routeReply({
|
|
53
|
+
request: validRequest,
|
|
54
|
+
capabilities: BEST_EFFORT,
|
|
55
|
+
injectors: { pty: async () => { throw new Error("pty closed"); } }
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
assert.equal(result.ok, false);
|
|
59
|
+
assert.match(result.error, /pty closed/);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("rejects an invalid reply request before routing", async () => {
|
|
63
|
+
const result = await routeReply({
|
|
64
|
+
request: { type: "task_input", sessionId: "s", inputId: "in_1", text: " ", createdAt: 1 },
|
|
65
|
+
capabilities: SUPPORTED,
|
|
66
|
+
injectors: { structured: async () => { throw new Error("should not run"); } }
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
assert.equal(result.ok, false);
|
|
70
|
+
assert.match(result.error, /text/);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test("routes an approval decision and reports the result", async () => {
|
|
74
|
+
const calls = [];
|
|
75
|
+
const ok = await routeApproval({
|
|
76
|
+
decision: validDecision,
|
|
77
|
+
capabilities: SUPPORTED,
|
|
78
|
+
now: () => 70,
|
|
79
|
+
injectors: { structured: async (d) => calls.push(d) }
|
|
80
|
+
});
|
|
81
|
+
assert.equal(ok.ok, true);
|
|
82
|
+
assert.equal(ok.decidedAt, 70);
|
|
83
|
+
assert.equal(calls.length, 1);
|
|
84
|
+
|
|
85
|
+
const blocked = await routeApproval({
|
|
86
|
+
decision: validDecision,
|
|
87
|
+
capabilities: UNSUPPORTED,
|
|
88
|
+
injectors: { structured: async () => calls.push("nope") }
|
|
89
|
+
});
|
|
90
|
+
assert.equal(blocked.ok, false);
|
|
91
|
+
assert.equal(blocked.error, "approval_unsupported");
|
|
92
|
+
assert.equal(calls.length, 1);
|
|
93
|
+
});
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { dirname } from "node:path";
|
|
2
|
+
import {
|
|
3
|
+
createDefaultPositionState,
|
|
4
|
+
parsePositionState,
|
|
5
|
+
serializePositionState
|
|
6
|
+
} from "./state.js";
|
|
7
|
+
|
|
8
|
+
// Thin filesystem wrapper around the pure state helpers. The fs functions are
|
|
9
|
+
// injectable so the load/save behaviour is testable without touching the disk.
|
|
10
|
+
export function createStateFile({ statePath, readFile, writeFile, mkdir } = {}) {
|
|
11
|
+
if (typeof statePath !== "string" || statePath.trim() === "") {
|
|
12
|
+
throw new Error("statePath is required");
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const read = readFile ?? defaultReadFile;
|
|
16
|
+
const write = writeFile ?? defaultWriteFile;
|
|
17
|
+
const ensureDir = mkdir ?? defaultMkdir;
|
|
18
|
+
|
|
19
|
+
return {
|
|
20
|
+
statePath,
|
|
21
|
+
|
|
22
|
+
async load() {
|
|
23
|
+
try {
|
|
24
|
+
const text = await read(statePath, "utf8");
|
|
25
|
+
return parsePositionState(text);
|
|
26
|
+
} catch {
|
|
27
|
+
// Missing or unreadable file falls back to defaults, same as corrupt JSON.
|
|
28
|
+
return createDefaultPositionState();
|
|
29
|
+
}
|
|
30
|
+
},
|
|
31
|
+
|
|
32
|
+
async save(state) {
|
|
33
|
+
await ensureDir(dirname(statePath), { recursive: true });
|
|
34
|
+
await write(statePath, serializePositionState(state));
|
|
35
|
+
return state;
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async function defaultReadFile(path, encoding) {
|
|
41
|
+
const { readFile } = await import("node:fs/promises");
|
|
42
|
+
return readFile(path, encoding);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function defaultWriteFile(path, content) {
|
|
46
|
+
const { writeFile } = await import("node:fs/promises");
|
|
47
|
+
return writeFile(path, content);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async function defaultMkdir(path, options) {
|
|
51
|
+
const { mkdir } = await import("node:fs/promises");
|
|
52
|
+
return mkdir(path, options);
|
|
53
|
+
}
|