@hayasaka7/haya-pet 0.1.0 → 0.2.1
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/CHANGELOG.md +94 -0
- package/README.md +59 -17
- package/apps/cli/src/haya-pet.js +246 -5
- package/apps/cli/test/haya-pet.test.mjs +269 -4
- package/apps/companion/package.json +1 -1
- package/apps/companion/src/main/index.js +40 -1
- package/apps/companion/test/position-store.test.mjs +2 -1
- package/docs/architecture.md +84 -7
- package/docs/cross-os-qa.md +72 -0
- package/docs/known-issues.md +204 -49
- package/docs/troubleshooting.md +33 -1
- package/package.json +1 -1
- package/packages/adapters/src/claude-hooks.js +77 -0
- package/packages/adapters/src/claude-transcript.js +74 -0
- package/packages/adapters/src/codex-hooks.js +152 -0
- package/packages/adapters/src/codex-transcript.js +73 -0
- package/packages/adapters/test/claude-hooks.test.mjs +87 -0
- package/packages/adapters/test/claude-transcript.test.mjs +70 -0
- package/packages/adapters/test/codex-hooks.test.mjs +120 -0
- package/packages/adapters/test/codex-transcript.test.mjs +97 -0
- package/packages/app-state/src/state.js +21 -1
- package/packages/cli-core/src/claude-hook-injection.js +42 -0
- package/packages/cli-core/src/claude-transcript-watcher.js +185 -0
- package/packages/cli-core/src/codex-hook-injection.js +49 -0
- package/packages/cli-core/src/codex-transcript-watcher.js +160 -0
- package/packages/cli-core/src/run-command.js +7 -3
- package/packages/cli-core/src/run-state.js +87 -0
- package/packages/cli-core/test/claude-hook-injection.test.mjs +45 -0
- package/packages/cli-core/test/claude-transcript-watcher.test.mjs +121 -0
- package/packages/cli-core/test/codex-hook-injection.test.mjs +45 -0
- package/packages/cli-core/test/codex-transcript-watcher.test.mjs +108 -0
- package/packages/cli-core/test/run-command.test.mjs +20 -0
- package/packages/cli-core/test/run-state.test.mjs +113 -0
- package/packages/daemon-core/src/approval-process-watcher.js +169 -0
- package/packages/daemon-core/test/approval-process-watcher.test.mjs +295 -0
- package/packages/platform-core/src/process-snapshot.js +88 -0
- package/packages/platform-core/test/process-snapshot.test.mjs +105 -0
|
@@ -17,12 +17,12 @@ test("parses generic run command arguments", () => {
|
|
|
17
17
|
);
|
|
18
18
|
});
|
|
19
19
|
|
|
20
|
-
test("
|
|
21
|
-
assert.equal(parseAiPetArgs(["run", "--client", "codex"]).observe,
|
|
22
|
-
assert.equal(parseAiPetArgs(["run", "--
|
|
20
|
+
test("native passthrough is the default and --observe opts in", () => {
|
|
21
|
+
assert.equal(parseAiPetArgs(["run", "--client", "codex"]).observe, false);
|
|
22
|
+
assert.equal(parseAiPetArgs(["run", "--observe", "--client", "codex"]).observe, true);
|
|
23
23
|
|
|
24
24
|
const parsedWithCommand = parseAiPetArgs(["run", "--", "claude", "--resume"]);
|
|
25
|
-
assert.equal(parsedWithCommand.observe,
|
|
25
|
+
assert.equal(parsedWithCommand.observe, false);
|
|
26
26
|
assert.equal(parsedWithCommand.childCommand, "claude");
|
|
27
27
|
assert.deepEqual(parsedWithCommand.childArgs, ["--resume"]);
|
|
28
28
|
});
|
|
@@ -326,6 +326,271 @@ test("stop command is a no-op when nothing is running", async () => {
|
|
|
326
326
|
assert.ok(lines.some((line) => line.includes("not running")));
|
|
327
327
|
});
|
|
328
328
|
|
|
329
|
+
test("parses the state command", () => {
|
|
330
|
+
assert.deepEqual(parseAiPetArgs(["state", "thinking", "--session", "sess_q"]), {
|
|
331
|
+
command: "state",
|
|
332
|
+
state: "thinking",
|
|
333
|
+
summary: undefined,
|
|
334
|
+
session: "sess_q"
|
|
335
|
+
});
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
const hooksStateFile = (hooksEnabled) => () => ({
|
|
339
|
+
load: async () => ({ settings: { hooksEnabled } }),
|
|
340
|
+
save: async (state) => state
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
test("claude-code does NOT inject hooks by default (safe out-of-box)", async () => {
|
|
344
|
+
const calls = [];
|
|
345
|
+
let injected = 0;
|
|
346
|
+
await runAiPet(["run", "--client", "claude-code", "--", "claude"], {
|
|
347
|
+
cwd: process.cwd(),
|
|
348
|
+
env: { USERPROFILE: "C:\\Users\\A" },
|
|
349
|
+
heartbeatIntervalMs: 10,
|
|
350
|
+
send: async () => {},
|
|
351
|
+
createStateFile: hooksStateFile(false),
|
|
352
|
+
injectClaudeHooks: () => { injected += 1; return { settingsPath: "x", cleanup: () => {} }; },
|
|
353
|
+
runGenericCommand: async (options) => {
|
|
354
|
+
calls.push(options);
|
|
355
|
+
return { sessionId: "s", pid: 1, exitCode: 0 };
|
|
356
|
+
}
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
assert.equal(injected, 0, "no hook injection unless opted in");
|
|
360
|
+
assert.deepEqual(calls[0].args, []);
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
test("persisted `hooks on` opts claude-code into injection without an env var", async () => {
|
|
364
|
+
const calls = [];
|
|
365
|
+
let injected = 0;
|
|
366
|
+
await runAiPet(["run", "--client", "claude-code", "--", "claude"], {
|
|
367
|
+
cwd: process.cwd(),
|
|
368
|
+
env: { USERPROFILE: "C:\\Users\\A" }, // no HAYA_PET_HOOKS
|
|
369
|
+
heartbeatIntervalMs: 10,
|
|
370
|
+
send: async () => {},
|
|
371
|
+
createStateFile: hooksStateFile(true), // persisted preference = on
|
|
372
|
+
injectClaudeHooks: () => { injected += 1; return { settingsPath: "/tmp/s.json", cleanup: () => {} }; },
|
|
373
|
+
watchClaudeTranscript: () => ({ stop: () => {} }),
|
|
374
|
+
runGenericCommand: async (options) => {
|
|
375
|
+
calls.push(options);
|
|
376
|
+
return { sessionId: options.sessionId, pid: 1, exitCode: 0 };
|
|
377
|
+
}
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
assert.equal(injected, 1, "config preference enables hooks");
|
|
381
|
+
assert.deepEqual(calls[0].args, ["--settings", "/tmp/s.json"]);
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
test("HAYA_PET_NO_HOOKS=1 overrides a persisted `hooks on`", async () => {
|
|
385
|
+
const calls = [];
|
|
386
|
+
let injected = 0;
|
|
387
|
+
await runAiPet(["run", "--client", "claude-code", "--", "claude"], {
|
|
388
|
+
cwd: process.cwd(),
|
|
389
|
+
env: { HAYA_PET_NO_HOOKS: "1", USERPROFILE: "C:\\Users\\A" },
|
|
390
|
+
heartbeatIntervalMs: 10,
|
|
391
|
+
send: async () => {},
|
|
392
|
+
createStateFile: hooksStateFile(true),
|
|
393
|
+
injectClaudeHooks: () => { injected += 1; return { settingsPath: "x", cleanup: () => {} }; },
|
|
394
|
+
runGenericCommand: async (options) => {
|
|
395
|
+
calls.push(options);
|
|
396
|
+
return { sessionId: "s", pid: 1, exitCode: 0 };
|
|
397
|
+
}
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
assert.equal(injected, 0, "env override forces hooks off");
|
|
401
|
+
assert.deepEqual(calls[0].args, []);
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
test("hooks command parses and persists the toggle", async () => {
|
|
405
|
+
assert.deepEqual(parseAiPetArgs(["hooks"]), { command: "hooks", action: "status" });
|
|
406
|
+
assert.deepEqual(parseAiPetArgs(["hooks", "on"]), { command: "hooks", action: "on" });
|
|
407
|
+
assert.throws(() => parseAiPetArgs(["hooks", "bogus"]), /Unknown hooks action/);
|
|
408
|
+
|
|
409
|
+
let saved;
|
|
410
|
+
const lines = [];
|
|
411
|
+
const store = {
|
|
412
|
+
load: async () => ({ settings: { hooksEnabled: false } }),
|
|
413
|
+
save: async (state) => { saved = state; return state; }
|
|
414
|
+
};
|
|
415
|
+
const result = await runAiPet(["hooks", "on"], {
|
|
416
|
+
homeDir: "C:\\Users\\A",
|
|
417
|
+
createStateFile: () => store,
|
|
418
|
+
print: (line) => lines.push(line)
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
assert.equal(result.enabled, true);
|
|
422
|
+
assert.equal(saved.settings.hooksEnabled, true);
|
|
423
|
+
assert.ok(lines.some((l) => l.includes("on")));
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
test("persisted `hooks on` injects a Codex profile via -p at the front of args", async () => {
|
|
427
|
+
const calls = [];
|
|
428
|
+
let injected = 0;
|
|
429
|
+
await runAiPet(["run", "--client", "codex", "--", "codex"], {
|
|
430
|
+
cwd: process.cwd(),
|
|
431
|
+
env: { USERPROFILE: "C:\\Users\\A" }, // no HAYA_PET_HOOKS
|
|
432
|
+
heartbeatIntervalMs: 10,
|
|
433
|
+
send: async () => {},
|
|
434
|
+
createStateFile: hooksStateFile(true),
|
|
435
|
+
injectCodexHooks: () => { injected += 1; return { profileName: "haya-pet", cleanup: () => {} }; },
|
|
436
|
+
runGenericCommand: async (options) => {
|
|
437
|
+
calls.push(options);
|
|
438
|
+
return { sessionId: options.sessionId, pid: 1, exitCode: 0 };
|
|
439
|
+
}
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
assert.equal(injected, 1, "config preference enables Codex hooks");
|
|
443
|
+
assert.deepEqual(calls[0].args, ["-p", "haya-pet"], "profile flag goes at the front");
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
test("codex hooks also start a transcript watcher for tool activity", async () => {
|
|
447
|
+
const sent = [];
|
|
448
|
+
let fireToolEvent;
|
|
449
|
+
let stopped = false;
|
|
450
|
+
|
|
451
|
+
await runAiPet(["run", "--client", "codex", "--", "codex"], {
|
|
452
|
+
cwd: process.cwd(),
|
|
453
|
+
env: { USERPROFILE: "C:\\Users\\A" },
|
|
454
|
+
now: () => 42,
|
|
455
|
+
heartbeatIntervalMs: 10,
|
|
456
|
+
send: async (message) => sent.push(message),
|
|
457
|
+
createStateFile: hooksStateFile(true),
|
|
458
|
+
injectCodexHooks: () => ({ profileName: "haya-pet", cleanup: () => {} }),
|
|
459
|
+
watchCodexTranscript: ({ onToolEvent }) => {
|
|
460
|
+
fireToolEvent = onToolEvent;
|
|
461
|
+
return { stop: () => { stopped = true; } };
|
|
462
|
+
},
|
|
463
|
+
runGenericCommand: async (options) => {
|
|
464
|
+
fireToolEvent({
|
|
465
|
+
type: "tool_started",
|
|
466
|
+
toolCallId: "call_shell",
|
|
467
|
+
toolName: "shell_command",
|
|
468
|
+
state: "running_tool"
|
|
469
|
+
});
|
|
470
|
+
fireToolEvent({
|
|
471
|
+
type: "tool_finished",
|
|
472
|
+
toolCallId: "call_shell"
|
|
473
|
+
});
|
|
474
|
+
return { sessionId: options.sessionId, pid: 1, exitCode: 0 };
|
|
475
|
+
}
|
|
476
|
+
});
|
|
477
|
+
|
|
478
|
+
assert.ok(stopped, "transcript watcher is stopped after the wrapped command exits");
|
|
479
|
+
assert.deepEqual(
|
|
480
|
+
sent.filter((message) => message.type === "state" && message.source === "client_log").map((message) => message.state),
|
|
481
|
+
["running_tool", "thinking"]
|
|
482
|
+
);
|
|
483
|
+
assert.ok(sent.every((message) => message.updatedAt === undefined || message.updatedAt === 42));
|
|
484
|
+
});
|
|
485
|
+
|
|
486
|
+
test("codex hooks are skipped (with a notice) when the user passes their own -p", async () => {
|
|
487
|
+
const calls = [];
|
|
488
|
+
let injected = 0;
|
|
489
|
+
const lines = [];
|
|
490
|
+
await runAiPet(["run", "--client", "codex", "--", "codex", "-p", "mine"], {
|
|
491
|
+
cwd: process.cwd(),
|
|
492
|
+
env: { USERPROFILE: "C:\\Users\\A" },
|
|
493
|
+
heartbeatIntervalMs: 10,
|
|
494
|
+
send: async () => {},
|
|
495
|
+
createStateFile: hooksStateFile(true),
|
|
496
|
+
print: (line) => lines.push(line),
|
|
497
|
+
injectCodexHooks: () => { injected += 1; return { profileName: "haya-pet", cleanup: () => {} }; },
|
|
498
|
+
runGenericCommand: async (options) => {
|
|
499
|
+
calls.push(options);
|
|
500
|
+
return { sessionId: options.sessionId, pid: 1, exitCode: 0 };
|
|
501
|
+
}
|
|
502
|
+
});
|
|
503
|
+
|
|
504
|
+
assert.equal(injected, 0, "user's profile is respected — no injection");
|
|
505
|
+
assert.deepEqual(calls[0].args, ["-p", "mine"], "user args untouched");
|
|
506
|
+
assert.ok(lines.some((l) => /skipped/i.test(l)), "user is told why");
|
|
507
|
+
});
|
|
508
|
+
|
|
509
|
+
test("codex does NOT inject hooks by default (safe out-of-box)", async () => {
|
|
510
|
+
const calls = [];
|
|
511
|
+
let injected = 0;
|
|
512
|
+
await runAiPet(["run", "--client", "codex", "--", "codex"], {
|
|
513
|
+
cwd: process.cwd(),
|
|
514
|
+
env: { USERPROFILE: "C:\\Users\\A" },
|
|
515
|
+
heartbeatIntervalMs: 10,
|
|
516
|
+
send: async () => {},
|
|
517
|
+
createStateFile: hooksStateFile(false),
|
|
518
|
+
injectCodexHooks: () => { injected += 1; return { profileName: "haya-pet", cleanup: () => {} }; },
|
|
519
|
+
runGenericCommand: async (options) => {
|
|
520
|
+
calls.push(options);
|
|
521
|
+
return { sessionId: "s", pid: 1, exitCode: 0 };
|
|
522
|
+
}
|
|
523
|
+
});
|
|
524
|
+
|
|
525
|
+
assert.equal(injected, 0, "no hook injection unless opted in");
|
|
526
|
+
assert.deepEqual(calls[0].args, []);
|
|
527
|
+
});
|
|
528
|
+
|
|
529
|
+
test("HAYA_PET_HOOKS=1 opts claude-code into --settings + HAYA_PET_SESSION_ID", async () => {
|
|
530
|
+
const calls = [];
|
|
531
|
+
let watched = 0;
|
|
532
|
+
await runAiPet(["run", "--client", "claude-code", "--", "claude"], {
|
|
533
|
+
cwd: process.cwd(),
|
|
534
|
+
env: { HAYA_PET_HOOKS: "1", USERPROFILE: "C:\\Users\\A", HOME: "/home/a" },
|
|
535
|
+
heartbeatIntervalMs: 10,
|
|
536
|
+
send: async () => {},
|
|
537
|
+
injectClaudeHooks: () => ({ settingsPath: "/tmp/s.json", cleanup: () => {} }),
|
|
538
|
+
watchClaudeTranscript: () => { watched += 1; return { stop: () => {} }; },
|
|
539
|
+
runGenericCommand: async (options) => {
|
|
540
|
+
calls.push(options);
|
|
541
|
+
return { sessionId: options.sessionId, pid: 1, exitCode: 0 };
|
|
542
|
+
}
|
|
543
|
+
});
|
|
544
|
+
|
|
545
|
+
assert.equal(calls.length, 1);
|
|
546
|
+
assert.deepEqual(calls[0].args, ["--settings", "/tmp/s.json"]);
|
|
547
|
+
assert.equal(calls[0].env.HAYA_PET_SESSION_ID, calls[0].sessionId);
|
|
548
|
+
assert.ok(calls[0].sessionId, "a session id was generated and shared via env");
|
|
549
|
+
assert.equal(watched, 1, "transcript watcher started for approval-denial recovery");
|
|
550
|
+
});
|
|
551
|
+
|
|
552
|
+
test("a transcript denial clears the stuck approval to idle", async () => {
|
|
553
|
+
const sent = [];
|
|
554
|
+
let fireDenial;
|
|
555
|
+
await runAiPet(["run", "--client", "claude-code", "--", "claude"], {
|
|
556
|
+
cwd: process.cwd(),
|
|
557
|
+
env: { HAYA_PET_HOOKS: "1", USERPROFILE: "C:\\Users\\A" },
|
|
558
|
+
now: () => 42,
|
|
559
|
+
heartbeatIntervalMs: 10,
|
|
560
|
+
send: async (message) => sent.push(message),
|
|
561
|
+
injectClaudeHooks: () => ({ settingsPath: "/tmp/s.json", cleanup: () => {} }),
|
|
562
|
+
watchClaudeTranscript: ({ onDenial }) => { fireDenial = onDenial; return { stop: () => {} }; },
|
|
563
|
+
runGenericCommand: async (options) => {
|
|
564
|
+
// Simulate the user denying a permission mid-session.
|
|
565
|
+
fireDenial({ type: "tool_denied", toolUseId: "toolu_1" });
|
|
566
|
+
return { sessionId: options.sessionId, pid: 1, exitCode: 0 };
|
|
567
|
+
}
|
|
568
|
+
});
|
|
569
|
+
|
|
570
|
+
const idle = sent.find((m) => m.type === "state" && m.source === "client_log");
|
|
571
|
+
assert.ok(idle, "a client_log state was sent on denial");
|
|
572
|
+
assert.equal(idle.state, "idle");
|
|
573
|
+
assert.equal(idle.summary, "approval denied");
|
|
574
|
+
assert.equal(idle.updatedAt, 42);
|
|
575
|
+
});
|
|
576
|
+
|
|
577
|
+
test("non-hook-capable clients are never injected even with HAYA_PET_HOOKS=1", async () => {
|
|
578
|
+
const calls = [];
|
|
579
|
+
await runAiPet(["run", "--client", "generic", "--", "aider"], {
|
|
580
|
+
cwd: process.cwd(),
|
|
581
|
+
env: { HAYA_PET_HOOKS: "1", USERPROFILE: "C:\\Users\\A" },
|
|
582
|
+
heartbeatIntervalMs: 10,
|
|
583
|
+
send: async () => {},
|
|
584
|
+
injectClaudeHooks: () => { throw new Error("should not inject for generic"); },
|
|
585
|
+
injectCodexHooks: () => { throw new Error("should not inject for generic"); },
|
|
586
|
+
runGenericCommand: async (options) => {
|
|
587
|
+
calls.push(options);
|
|
588
|
+
return { sessionId: "s", pid: 1, exitCode: 0 };
|
|
589
|
+
}
|
|
590
|
+
});
|
|
591
|
+
assert.deepEqual(calls[0].args, []);
|
|
592
|
+
});
|
|
593
|
+
|
|
329
594
|
async function waitFor(predicate) {
|
|
330
595
|
const startedAt = Date.now();
|
|
331
596
|
|
|
@@ -3,6 +3,11 @@ import { fileURLToPath } from "node:url";
|
|
|
3
3
|
import { dirname, join } from "node:path";
|
|
4
4
|
import { createDaemonRuntime } from "../../../../packages/daemon-core/src/daemon-runtime.js";
|
|
5
5
|
import { createIpcServer } from "../../../../packages/daemon-core/src/ipc-server.js";
|
|
6
|
+
import {
|
|
7
|
+
createApprovalWatchCoordinator,
|
|
8
|
+
watchForApprovedProcess
|
|
9
|
+
} from "../../../../packages/daemon-core/src/approval-process-watcher.js";
|
|
10
|
+
import { createProcessSnapshotLister } from "../../../../packages/platform-core/src/process-snapshot.js";
|
|
6
11
|
import { getDefaultPaths } from "../../../../packages/platform-core/src/paths.js";
|
|
7
12
|
import { getPlatformCapabilities } from "../../../../packages/platform-core/src/capabilities.js";
|
|
8
13
|
import { buildBubbleViews } from "../../../../packages/session-core/src/bubble-view.js";
|
|
@@ -38,6 +43,7 @@ let runtime;
|
|
|
38
43
|
let currentWorkArea;
|
|
39
44
|
let currentDisplayId;
|
|
40
45
|
let petLocal = { x: 0, y: 0 };
|
|
46
|
+
let approvalWatch;
|
|
41
47
|
|
|
42
48
|
// Electron singleton: a second launch forwards to the running instance.
|
|
43
49
|
if (!app.requestSingleInstanceLock()) {
|
|
@@ -54,8 +60,40 @@ async function bootstrap() {
|
|
|
54
60
|
positionState = await stateFile.load();
|
|
55
61
|
pets = await discoverPets(paths.petSearchPaths);
|
|
56
62
|
|
|
63
|
+
// Clients fire no event at the moment the user ACCEPTS a permission prompt
|
|
64
|
+
// (only denial/finish are observable), so a waiting_approval session would
|
|
65
|
+
// otherwise look stuck until its tool completed. The approval watcher flips
|
|
66
|
+
// it to running_tool when the approved command verifiably starts — a new
|
|
67
|
+
// persistent process under the client — and never on a timer, so a genuinely
|
|
68
|
+
// unanswered prompt keeps warning. Unsupported platforms simply skip this.
|
|
69
|
+
const processLister = createProcessSnapshotLister();
|
|
70
|
+
approvalWatch = processLister
|
|
71
|
+
? createApprovalWatchCoordinator({
|
|
72
|
+
createWatcher: ({ rootPid, onApproved }) =>
|
|
73
|
+
watchForApprovedProcess({ rootPid, listProcesses: processLister, onApproved }),
|
|
74
|
+
onApproved: (sessionId) => {
|
|
75
|
+
try {
|
|
76
|
+
runtime.handleMessage({
|
|
77
|
+
type: "state",
|
|
78
|
+
sessionId,
|
|
79
|
+
state: "running_tool",
|
|
80
|
+
summary: "approved",
|
|
81
|
+
confidence: 0.6,
|
|
82
|
+
source: "client_log",
|
|
83
|
+
updatedAt: Date.now()
|
|
84
|
+
});
|
|
85
|
+
} catch {
|
|
86
|
+
// The session may have unregistered between detection and report.
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
})
|
|
90
|
+
: undefined;
|
|
91
|
+
|
|
57
92
|
runtime = createDaemonRuntime({
|
|
58
|
-
onSessionChanged: () =>
|
|
93
|
+
onSessionChanged: (session) => {
|
|
94
|
+
approvalWatch?.onSessionChanged(session);
|
|
95
|
+
pushSessions();
|
|
96
|
+
}
|
|
59
97
|
});
|
|
60
98
|
|
|
61
99
|
ipcServer = await createIpcServer({
|
|
@@ -85,6 +123,7 @@ async function bootstrap() {
|
|
|
85
123
|
|
|
86
124
|
app.on("before-quit", async () => {
|
|
87
125
|
clearInterval(sweep);
|
|
126
|
+
approvalWatch?.stopAll();
|
|
88
127
|
await ipcServer?.close();
|
|
89
128
|
});
|
|
90
129
|
}
|
package/docs/architecture.md
CHANGED
|
@@ -48,14 +48,46 @@ as each client allows:
|
|
|
48
48
|
| Tier | Source | Fidelity |
|
|
49
49
|
|---|---|---|
|
|
50
50
|
| L1 | Process wrapper (lifecycle only) | session exists / exit code |
|
|
51
|
-
| L2 | PTY output observation (`--observe`,
|
|
52
|
-
| L3 | Client logs / state files |
|
|
53
|
-
| L4 |
|
|
51
|
+
| L2 | PTY output observation (`--observe`, opt-in) | activity-based working/idle |
|
|
52
|
+
| L3 | Client logs / state files / process tree | transcript watchers (Claude denial, Codex tools) + approval-accept detection |
|
|
53
|
+
| L4 | Client hooks | richest — implemented for Claude Code (full) and Codex (partial) |
|
|
54
|
+
|
|
55
|
+
The **default** is native passthrough (`stdio: "inherit"`) for full terminal
|
|
56
|
+
fidelity, with **L1 lifecycle** status for every client. Richer status is opt-in:
|
|
57
|
+
**Claude Code** and **Codex** gain **L4 hooks** when enabled with the global
|
|
58
|
+
`haya-pet hooks on` (persisted; or per-run via `HAYA_PET_HOOKS=1`). Both report
|
|
59
|
+
in-session activity through the shared, client-agnostic `haya-pet state` command
|
|
60
|
+
(lifecycle still comes from the wrapper's exit code); any client gains **L2** with
|
|
61
|
+
`--observe`. Hooks are opt-in because injecting them triggers the client's one-time
|
|
62
|
+
*review hooks* trust prompt.
|
|
63
|
+
|
|
64
|
+
The injection mechanism differs per client. **Claude Code** takes a stable
|
|
65
|
+
`claude --settings <file>`. **Codex** has no per-invocation settings flag, so the
|
|
66
|
+
wrapper writes a stable `$CODEX_HOME/haya-pet.config.toml` profile and prepends
|
|
67
|
+
`-p haya-pet` to the codex args (a profile layers on top of the user's base config,
|
|
68
|
+
leaving auth/model/MCP intact, and is inert otherwise). Codex allows only one
|
|
69
|
+
profile, so if the user already passes `-p/--profile`, injection is skipped with a
|
|
70
|
+
notice. Codex's hook command must be unquoted at the program position (it runs via
|
|
71
|
+
`cmd /c`, which strips a leading quote) and its matchers can't use look-around
|
|
72
|
+
(Rust regex) — see [known-issues.md](known-issues.md). Codex's L4 is **partial**:
|
|
73
|
+
`PreToolUse`/`PermissionRequest` don't fire upstream yet, so only `thinking`/`idle`
|
|
74
|
+
arrive today.
|
|
75
|
+
|
|
76
|
+
Hooks alone can't see one moment: clients emit **no event when the user accepts a
|
|
77
|
+
permission prompt** (denial and completion are observable; the accept click is
|
|
78
|
+
not). The companion bridges it with **L3 process-tree observation**: while a
|
|
79
|
+
session sits in `waiting_approval`, it polls the client's process subtree (the
|
|
80
|
+
wrapper reported the pid at register), and when a new descendant process appears
|
|
81
|
+
and persists across two polls — the approved command verifiably running — the
|
|
82
|
+
session flips to `running_tool`. No timers: an unanswered prompt keeps warning
|
|
83
|
+
until a real event resolves it (`approval-process-watcher.js`,
|
|
84
|
+
`process-snapshot.js`; Windows/macOS/Linux listers).
|
|
54
85
|
|
|
55
86
|
L2 is **activity-based**: any visible output → *working*; a short quiet window →
|
|
56
87
|
*idle*; success/failure come from the real exit code, never from scraping output
|
|
57
|
-
text.
|
|
58
|
-
[known-issues.md](known-issues.md) for the
|
|
88
|
+
text. It is opt-in because routing input through a PTY (ConPTY on Windows) breaks
|
|
89
|
+
special keys like Shift+Tab — see [known-issues.md](known-issues.md) for the
|
|
90
|
+
L2/PTY tradeoffs.
|
|
59
91
|
|
|
60
92
|
## Overlay model
|
|
61
93
|
|
|
@@ -85,8 +117,9 @@ packages/
|
|
|
85
117
|
session-core/ registry, priority, summaries, bubble views, linger, pet-state
|
|
86
118
|
task-core/ task status, events, store, approvals, replies, controls
|
|
87
119
|
adapters/ client info, heuristics, capabilities, output observer, routing
|
|
88
|
-
daemon-core/ IPC server/transport, runtime bridge, singleton
|
|
89
|
-
|
|
120
|
+
daemon-core/ IPC server/transport, runtime bridge, singleton,
|
|
121
|
+
approval process watcher (waiting_approval -> running_tool)
|
|
122
|
+
platform-core/ platform, paths, capabilities, process snapshots (win/mac/linux)
|
|
90
123
|
apps/
|
|
91
124
|
cli/ haya-pet entrypoint + parser (run / start / stop / pets)
|
|
92
125
|
companion/ Electron overlay app (main + renderer)
|
|
@@ -141,4 +174,48 @@ helper. In progress:
|
|
|
141
174
|
- Faithful PTY passthrough (see [known-issues.md](known-issues.md)).
|
|
142
175
|
- Production overlay/IPC validation across all platforms.
|
|
143
176
|
|
|
177
|
+
### Deferred: focus a session's terminal on bubble click
|
|
178
|
+
|
|
179
|
+
Clicking a session bubble should raise/focus the terminal window running that
|
|
180
|
+
session. Deferred because it can't be done as a clean cross-OS feature yet:
|
|
181
|
+
|
|
182
|
+
- **Windows** — doable now: the helper already *locates* the window (HWND); add a
|
|
183
|
+
`focus` op that calls `SetForegroundWindow` (+ the usual `AllowSetForegroundWindow`
|
|
184
|
+
/ attach-thread-input dance), then wire bubble click → IPC → helper.
|
|
185
|
+
- **macOS** — needs an (unbuilt) Accessibility/window-list helper and a
|
|
186
|
+
user-granted Accessibility permission.
|
|
187
|
+
- **Linux X11** — needs the (unbuilt) X11 helper (EWMH `_NET_ACTIVE_WINDOW`).
|
|
188
|
+
- **Linux Wayland** — blocked by the compositor security model; no portable API to
|
|
189
|
+
focus another app's window.
|
|
190
|
+
|
|
191
|
+
Implementation sketch when picked up: bubble `click` in `session-bubbles.js` →
|
|
192
|
+
`haya-pet:focus-session` IPC with `sessionId` → main resolves `session.pid`
|
|
193
|
+
(/`terminalPid`) → terminal helper `focus` op (per-OS), with a graceful no-op
|
|
194
|
+
where unsupported.
|
|
195
|
+
|
|
196
|
+
### Deferred: per-session token usage
|
|
197
|
+
|
|
198
|
+
Show each session's token usage on its bubble. Feasible as an **L3 client-log
|
|
199
|
+
adapter** (`source: "client_log"`) — and it's cross-OS, since only the log path
|
|
200
|
+
differs by client, not by OS. There is no generic source: the process wrapper
|
|
201
|
+
only sees terminal bytes, so usage must come from each client's own logs.
|
|
202
|
+
|
|
203
|
+
- **Claude Code** — confirmed: per-turn `usage` (`input_tokens`, `output_tokens`,
|
|
204
|
+
`cache_creation_input_tokens`, `cache_read_input_tokens`) in
|
|
205
|
+
`~/.claude/projects/<encoded-cwd>/<session-uuid>.jsonl`. Clean JSONL to parse.
|
|
206
|
+
- **Codex** — usage exists in its logs (`~/.codex/history.jsonl`, `sessions/`,
|
|
207
|
+
sqlite) but in a messier shape; needs a dedicated adapter + investigation.
|
|
208
|
+
- **Generic / other clients** — no reliable source; the adapter should no-op.
|
|
209
|
+
|
|
210
|
+
Implementation sketch when picked up: a per-client usage adapter tails the
|
|
211
|
+
session's transcript (matched via the session's `cwd` → the newest `.jsonl` in
|
|
212
|
+
that project dir), sums usage across turns, and emits an optional `usage` field
|
|
213
|
+
(protocol addition) → `session-core` stores it → the bubble renders it
|
|
214
|
+
(e.g. `↑ in / ↓ out`). Open questions: (1) which metric to surface — cache-read
|
|
215
|
+
tokens are huge under prompt caching, so likely show output + input, with total
|
|
216
|
+
context separate; (2) disambiguating multiple concurrent sessions in the same
|
|
217
|
+
project dir (by start time / newest file). The JSONL parser is pure and
|
|
218
|
+
TDD-friendly. Investigate non-Claude client adapters (Codex, etc.) as part of
|
|
219
|
+
this.
|
|
220
|
+
|
|
144
221
|
See [`../PROGRESS.md`](../PROGRESS.md) for the detailed log.
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# Cross-OS QA Matrix
|
|
2
|
+
|
|
3
|
+
Use this checklist before release candidates and after changes to IPC, windowing, display handling, terminal attachment, or CLI process wrapping.
|
|
4
|
+
|
|
5
|
+
## Automated Gates
|
|
6
|
+
|
|
7
|
+
- [ ] `npm test` passes on the target branch.
|
|
8
|
+
- [ ] Generic command lifecycle emits register, running state, heartbeat, final state, and unregister.
|
|
9
|
+
- [ ] Daemon accepts local IPC messages and updates the session registry.
|
|
10
|
+
- [ ] CLI can run through daemon IPC without an injected sender.
|
|
11
|
+
- [ ] Pet preview loads a Codex-compatible `1536x1872` spritesheet.
|
|
12
|
+
- [ ] Pet manifest parsing and atlas/action validation pass (`pet-core`).
|
|
13
|
+
- [ ] Generic regex heuristics map sample output to normalized states (`adapters`).
|
|
14
|
+
- [ ] Task status mapping, event normalization, and control gating pass (`task-core`).
|
|
15
|
+
- [ ] Session bubble view models build with status label, summary, action, and elapsed (`session-core`).
|
|
16
|
+
- [ ] PTY output observer infers debounced `pty_output` states from sample client output (`adapters`).
|
|
17
|
+
- [ ] Reply/approval routing dispatches to injectors and safely refuses unsupported adapters (`adapters`).
|
|
18
|
+
|
|
19
|
+
## Manual Platform Matrix
|
|
20
|
+
|
|
21
|
+
| Platform | Shell/Terminal | Display Setup | Required Checks |
|
|
22
|
+
|---|---|---|---|
|
|
23
|
+
| Windows 11 | PowerShell | 100% DPI | `haya-pet run --client generic -- node -e "setTimeout(() => process.exit(0), 1000)"`; daemon sees session exit; overlay opens without focus stealing. |
|
|
24
|
+
| Windows 11 | Windows Terminal | 125% DPI | Pet drag/click/double-click; position persists after restart; terminal attachment helper reports best-effort capability. |
|
|
25
|
+
| Windows 11 | Windows Terminal | 150% or mixed DPI | Saved offscreen position clamps to visible work area; session bubbles remain visible. |
|
|
26
|
+
| macOS current stable | Terminal.app | Retina display | Unix socket IPC works; transparent overlay opens; click/drag behavior works; position persists. |
|
|
27
|
+
| macOS current stable | iTerm2 | External display | Moving terminal across displays does not lose session bubble fallback; permission denial produces best-effort/fallback state. |
|
|
28
|
+
| Ubuntu Linux X11 | GNOME Terminal | Single display | Unix socket IPC works; transparent overlay opens; X11 terminal strategy reports best-effort. |
|
|
29
|
+
| Ubuntu or Fedora Linux Wayland | Default terminal | Single display | Overlay fallback mode works; terminal attachment reports manual fallback; global pet plus cluster/session bubbles remain usable. |
|
|
30
|
+
|
|
31
|
+
## Release Acceptance Gates
|
|
32
|
+
|
|
33
|
+
- [ ] No platform stores prompts, screenshots, or raw terminal logs by default.
|
|
34
|
+
- [ ] Windows uses `\\.\pipe\haya-petd` for local IPC.
|
|
35
|
+
- [ ] macOS/Linux use `~/.haya-pet/haya-petd.sock` for local IPC.
|
|
36
|
+
- [ ] Windows state path is under `%LOCALAPPDATA%\haya-pet\state.json`.
|
|
37
|
+
- [ ] macOS/Linux state path is under `~/.haya-pet/state.json`.
|
|
38
|
+
- [ ] If transparent overlay fails, a normal companion window is available.
|
|
39
|
+
- [ ] If terminal attachment fails, global pet plus manual/cluster bubbles remain available.
|
|
40
|
+
- [ ] Wayland does not use unsupported global positioning assumptions.
|
|
41
|
+
- [ ] Saved display IDs are validated; missing displays fall back to primary visible work area.
|
|
42
|
+
- [ ] Task talk controls are hidden or disabled when adapter capability is unsupported.
|
|
43
|
+
- [ ] Reply composer shows "Open terminal to reply" for wrapper-only adapters (no blind injection).
|
|
44
|
+
- [ ] Approvals require explicit approve/deny and are never auto-approved.
|
|
45
|
+
- [ ] Companion runs as a single instance; a second launch focuses the existing pet.
|
|
46
|
+
- [ ] A stale daemon lock (dead PID) is reclaimed; a live one forwards.
|
|
47
|
+
|
|
48
|
+
## Companion App Smoke Test (per OS)
|
|
49
|
+
|
|
50
|
+
Run from `apps/companion` after `npm install`:
|
|
51
|
+
|
|
52
|
+
- [ ] `npm start` opens the overlay; empty space stays click-through.
|
|
53
|
+
- [ ] Single click → waving; double click → jumping; drag moves and persists.
|
|
54
|
+
- [ ] Running `haya-pet run --client generic -- sleep 10` shows a session bubble.
|
|
55
|
+
- [ ] Two concurrent sessions show two bubbles without renderer conflicts.
|
|
56
|
+
- [ ] Selecting a bubble opens the task talk window (peek mode).
|
|
57
|
+
- [ ] Tray menu can show/hide the pet and reset its position.
|
|
58
|
+
|
|
59
|
+
## Current Implementation Status
|
|
60
|
+
|
|
61
|
+
- Shared protocol/session/pet core: automated coverage exists.
|
|
62
|
+
- Pet asset manifest + validation + frame animator: automated coverage exists.
|
|
63
|
+
- Client adapters (info, generic/PTY heuristics, capabilities): automated coverage exists.
|
|
64
|
+
- Task talk core (status, events, store, approvals, replies, controls): automated coverage exists.
|
|
65
|
+
- Session summaries + bubble view models: automated coverage exists.
|
|
66
|
+
- Daemon singleton decision logic + tray menu model + position state file: automated coverage exists.
|
|
67
|
+
- Cross-OS platform paths and capabilities: automated coverage exists.
|
|
68
|
+
- Test-mode IPC server/client: automated coverage exists.
|
|
69
|
+
- Electron overlay app: implemented as glue (`apps/companion`) consuming the pure cores; requires `electron` install and manual run/QA per OS (not unit-tested).
|
|
70
|
+
- Production OS endpoint behavior: needs manual validation on Windows, macOS, and Linux.
|
|
71
|
+
- Terminal attachment: facade + documented IPC contract (`native/README.md`). Windows helper is implemented in .NET and compiles + runs; macOS/Linux helper binaries are still TODO. Node helper client (`terminal-helper-client.js`) is unit-tested.
|
|
72
|
+
- PTY output observer + reply/approval routing: implemented and unit-tested as pure cores. Live wiring (real PTY via node-pty; bidirectional IPC + real injectors) is a later phase.
|