@katyella/legio 0.1.3 → 0.2.2
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 +61 -3
- package/README.md +21 -10
- package/agents/builder.md +11 -10
- package/agents/coordinator.md +36 -27
- package/agents/cto.md +9 -8
- package/agents/gateway.md +28 -12
- package/agents/lead.md +45 -30
- package/agents/merger.md +4 -4
- package/agents/monitor.md +10 -9
- package/agents/reviewer.md +8 -8
- package/agents/scout.md +10 -10
- package/agents/supervisor.md +60 -45
- package/package.json +2 -2
- package/src/agents/hooks-deployer.test.ts +46 -41
- package/src/agents/hooks-deployer.ts +10 -9
- package/src/agents/manifest.test.ts +6 -2
- package/src/agents/overlay.test.ts +9 -7
- package/src/agents/overlay.ts +29 -7
- package/src/commands/agents.test.ts +1 -5
- package/src/commands/clean.test.ts +2 -5
- package/src/commands/clean.ts +25 -1
- package/src/commands/completions.test.ts +1 -1
- package/src/commands/completions.ts +26 -7
- package/src/commands/coordinator.test.ts +87 -82
- package/src/commands/coordinator.ts +94 -48
- package/src/commands/costs.test.ts +2 -6
- package/src/commands/dashboard.test.ts +2 -5
- package/src/commands/doctor.test.ts +2 -6
- package/src/commands/down.ts +3 -3
- package/src/commands/errors.test.ts +2 -6
- package/src/commands/feed.test.ts +2 -6
- package/src/commands/gateway.test.ts +43 -17
- package/src/commands/gateway.ts +101 -11
- package/src/commands/hooks.test.ts +2 -5
- package/src/commands/init.test.ts +4 -13
- package/src/commands/inspect.test.ts +2 -6
- package/src/commands/log.test.ts +2 -6
- package/src/commands/logs.test.ts +2 -9
- package/src/commands/mail.test.ts +76 -215
- package/src/commands/mail.ts +43 -187
- package/src/commands/metrics.test.ts +3 -10
- package/src/commands/nudge.ts +15 -0
- package/src/commands/prime.test.ts +4 -11
- package/src/commands/replay.test.ts +2 -6
- package/src/commands/server.test.ts +1 -5
- package/src/commands/server.ts +1 -1
- package/src/commands/sling.test.ts +6 -1
- package/src/commands/sling.ts +42 -17
- package/src/commands/spec.test.ts +2 -5
- package/src/commands/status.test.ts +2 -4
- package/src/commands/stop.test.ts +2 -5
- package/src/commands/supervisor.ts +6 -6
- package/src/commands/trace.test.ts +2 -6
- package/src/commands/up.test.ts +43 -9
- package/src/commands/up.ts +15 -11
- package/src/commands/watchman.ts +327 -0
- package/src/commands/worktree.test.ts +2 -6
- package/src/config.test.ts +34 -104
- package/src/config.ts +120 -32
- package/src/doctor/agents.test.ts +52 -2
- package/src/doctor/agents.ts +4 -2
- package/src/doctor/config-check.test.ts +7 -2
- package/src/doctor/consistency.test.ts +7 -2
- package/src/doctor/databases.test.ts +6 -2
- package/src/doctor/dependencies.test.ts +18 -13
- package/src/doctor/dependencies.ts +23 -94
- package/src/doctor/logs.test.ts +7 -2
- package/src/doctor/merge-queue.test.ts +6 -2
- package/src/doctor/structure.test.ts +7 -2
- package/src/doctor/version.test.ts +7 -2
- package/src/e2e/init-sling-lifecycle.test.ts +2 -5
- package/src/index.ts +7 -7
- package/src/mail/pending.ts +120 -0
- package/src/mail/store.test.ts +89 -0
- package/src/mail/store.ts +11 -0
- package/src/merge/resolver.test.ts +518 -489
- package/src/server/index.ts +33 -2
- package/src/server/public/app.js +3 -3
- package/src/server/public/components/message-bubble.js +11 -1
- package/src/server/public/components/terminal-panel.js +66 -74
- package/src/server/public/views/chat.js +18 -2
- package/src/server/public/views/costs.js +5 -5
- package/src/server/public/views/dashboard.js +80 -51
- package/src/server/public/views/gateway-chat.js +37 -131
- package/src/server/public/views/inspect.js +16 -4
- package/src/server/public/views/issues.js +16 -12
- package/src/server/routes.test.ts +55 -39
- package/src/server/routes.ts +38 -26
- package/src/test-helpers.ts +6 -3
- package/src/tracker/beads.ts +159 -0
- package/src/tracker/exec.ts +44 -0
- package/src/tracker/factory.test.ts +283 -0
- package/src/tracker/factory.ts +59 -0
- package/src/tracker/seeds.ts +156 -0
- package/src/tracker/types.ts +46 -0
- package/src/types.ts +11 -2
- package/src/{watchdog → watchman}/daemon.test.ts +421 -515
- package/src/watchman/daemon.ts +940 -0
- package/src/worktree/tmux.test.ts +2 -1
- package/src/worktree/tmux.ts +4 -4
- package/templates/hooks.json.tmpl +17 -17
- package/src/beads/client.test.ts +0 -210
- package/src/commands/merge.test.ts +0 -676
- package/src/commands/watch.test.ts +0 -152
- package/src/commands/watch.ts +0 -238
- package/src/test-helpers.test.ts +0 -97
- package/src/watchdog/daemon.ts +0 -533
- package/src/watchdog/health.test.ts +0 -371
- package/src/watchdog/triage.test.ts +0 -162
- package/src/worktree/manager.test.ts +0 -444
- /package/src/{watchdog → watchman}/health.ts +0 -0
- /package/src/{watchdog → watchman}/triage.ts +0 -0
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
import { access, mkdir, mkdtemp, readdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
9
9
|
import { tmpdir } from "node:os";
|
|
10
10
|
import { join } from "node:path";
|
|
11
|
-
import { afterEach, beforeEach, describe, expect, test } from "vitest";
|
|
11
|
+
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
|
12
12
|
import { createEventStore } from "../events/store.ts";
|
|
13
13
|
import { createMailClient } from "../mail/client.ts";
|
|
14
14
|
import { createMailStore } from "../mail/store.ts";
|
|
@@ -17,7 +17,6 @@ import { mailCommand } from "./mail.ts";
|
|
|
17
17
|
|
|
18
18
|
describe("mailCommand", () => {
|
|
19
19
|
let tempDir: string;
|
|
20
|
-
let origCwd: string;
|
|
21
20
|
let origWrite: typeof process.stdout.write;
|
|
22
21
|
let origStderrWrite: typeof process.stderr.write;
|
|
23
22
|
let output: string;
|
|
@@ -44,9 +43,7 @@ describe("mailCommand", () => {
|
|
|
44
43
|
});
|
|
45
44
|
client.close();
|
|
46
45
|
|
|
47
|
-
|
|
48
|
-
origCwd = process.cwd();
|
|
49
|
-
process.chdir(tempDir);
|
|
46
|
+
vi.spyOn(process, "cwd").mockReturnValue(tempDir);
|
|
50
47
|
|
|
51
48
|
// Capture stdout
|
|
52
49
|
output = "";
|
|
@@ -68,7 +65,6 @@ describe("mailCommand", () => {
|
|
|
68
65
|
afterEach(async () => {
|
|
69
66
|
process.stdout.write = origWrite;
|
|
70
67
|
process.stderr.write = origStderrWrite;
|
|
71
|
-
process.chdir(origCwd);
|
|
72
68
|
await rm(tempDir, { recursive: true, force: true });
|
|
73
69
|
});
|
|
74
70
|
|
|
@@ -546,240 +542,105 @@ describe("mailCommand", () => {
|
|
|
546
542
|
});
|
|
547
543
|
});
|
|
548
544
|
|
|
549
|
-
describe("mail check
|
|
550
|
-
test("
|
|
551
|
-
// Send first message
|
|
552
|
-
const store = createMailStore(join(tempDir, ".legio", "mail.db"));
|
|
553
|
-
const client = createMailClient(store);
|
|
554
|
-
client.send({
|
|
555
|
-
from: "orchestrator",
|
|
556
|
-
to: "test-agent",
|
|
557
|
-
subject: "Message 1",
|
|
558
|
-
body: "First message",
|
|
559
|
-
});
|
|
560
|
-
client.close();
|
|
561
|
-
|
|
562
|
-
// First check
|
|
545
|
+
describe("mail check --signal", () => {
|
|
546
|
+
test("--signal skips DB query when no signal file exists", async () => {
|
|
563
547
|
output = "";
|
|
564
|
-
await mailCommand(["check", "--inject", "--agent", "
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
// Send second message
|
|
568
|
-
const store2 = createMailStore(join(tempDir, ".legio", "mail.db"));
|
|
569
|
-
const client2 = createMailClient(store2);
|
|
570
|
-
client2.send({
|
|
571
|
-
from: "orchestrator",
|
|
572
|
-
to: "test-agent",
|
|
573
|
-
subject: "Message 2",
|
|
574
|
-
body: "Second message",
|
|
575
|
-
});
|
|
576
|
-
client2.close();
|
|
577
|
-
|
|
578
|
-
// Second check immediately after
|
|
579
|
-
output = "";
|
|
580
|
-
await mailCommand(["check", "--inject", "--agent", "test-agent"]);
|
|
581
|
-
const secondOutput = output;
|
|
582
|
-
|
|
583
|
-
// Both should execute (no debouncing without flag)
|
|
584
|
-
expect(firstOutput).toContain("Message 1");
|
|
585
|
-
expect(secondOutput).toContain("Message 2");
|
|
586
|
-
});
|
|
587
|
-
|
|
588
|
-
test("mail check with --debounce skips second check within window", async () => {
|
|
589
|
-
// First check with debounce (large window to survive concurrency)
|
|
590
|
-
output = "";
|
|
591
|
-
await mailCommand(["check", "--agent", "builder-1", "--debounce", "10000"]);
|
|
592
|
-
expect(output).toContain("Build task");
|
|
593
|
-
|
|
594
|
-
// Second check immediately (within debounce window)
|
|
595
|
-
output = "";
|
|
596
|
-
await mailCommand(["check", "--agent", "builder-1", "--debounce", "10000"]);
|
|
597
|
-
// Should be skipped silently
|
|
548
|
+
await mailCommand(["check", "--inject", "--agent", "builder-1", "--signal"]);
|
|
549
|
+
// No signal file = no output, no DB query
|
|
598
550
|
expect(output).toBe("");
|
|
599
551
|
});
|
|
600
552
|
|
|
601
|
-
test("
|
|
602
|
-
//
|
|
603
|
-
const
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
await mailCommand(["check", "--inject", "--agent", "debounce-test", "--debounce", "100"]);
|
|
616
|
-
expect(output).toContain("First check");
|
|
617
|
-
|
|
618
|
-
// Wait for debounce window to expire
|
|
619
|
-
await new Promise((resolve) => setTimeout(resolve, 150));
|
|
620
|
-
|
|
621
|
-
// Send second message
|
|
622
|
-
const store2 = createMailStore(join(tempDir, ".legio", "mail.db"));
|
|
623
|
-
const client2 = createMailClient(store2);
|
|
624
|
-
client2.send({
|
|
625
|
-
from: "orchestrator",
|
|
626
|
-
to: "debounce-test",
|
|
627
|
-
subject: "Second",
|
|
628
|
-
body: "Second check",
|
|
629
|
-
});
|
|
630
|
-
client2.close();
|
|
553
|
+
test("--signal queries DB and injects when signal file exists", async () => {
|
|
554
|
+
// Write a signal file for builder-1
|
|
555
|
+
const nudgeDir = join(tempDir, ".legio", "pending-nudges");
|
|
556
|
+
await mkdir(nudgeDir, { recursive: true });
|
|
557
|
+
await writeFile(
|
|
558
|
+
join(nudgeDir, "builder-1.json"),
|
|
559
|
+
JSON.stringify({
|
|
560
|
+
from: "orchestrator",
|
|
561
|
+
reason: "status",
|
|
562
|
+
subject: "Build task",
|
|
563
|
+
messageId: "test-msg-1",
|
|
564
|
+
createdAt: new Date().toISOString(),
|
|
565
|
+
}),
|
|
566
|
+
);
|
|
631
567
|
|
|
632
|
-
// Second check after debounce window
|
|
633
568
|
output = "";
|
|
634
|
-
await mailCommand(["check", "--inject", "--agent", "
|
|
635
|
-
|
|
569
|
+
await mailCommand(["check", "--inject", "--agent", "builder-1", "--signal"]);
|
|
570
|
+
// Should inject the message AND show priority banner
|
|
571
|
+
expect(output).toContain("Build task");
|
|
572
|
+
expect(output).toContain("PRIORITY");
|
|
636
573
|
});
|
|
637
574
|
|
|
638
|
-
test("
|
|
639
|
-
//
|
|
640
|
-
const
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
expect(output).toContain("Message one");
|
|
575
|
+
test("--signal clears signal file after check", async () => {
|
|
576
|
+
// Write a signal file
|
|
577
|
+
const nudgeDir = join(tempDir, ".legio", "pending-nudges");
|
|
578
|
+
await mkdir(nudgeDir, { recursive: true });
|
|
579
|
+
const signalPath = join(nudgeDir, "builder-1.json");
|
|
580
|
+
await writeFile(
|
|
581
|
+
signalPath,
|
|
582
|
+
JSON.stringify({
|
|
583
|
+
from: "orchestrator",
|
|
584
|
+
reason: "status",
|
|
585
|
+
subject: "Build task",
|
|
586
|
+
messageId: "test-msg-1",
|
|
587
|
+
createdAt: new Date().toISOString(),
|
|
588
|
+
}),
|
|
589
|
+
);
|
|
654
590
|
|
|
655
|
-
|
|
656
|
-
const store2 = createMailStore(join(tempDir, ".legio", "mail.db"));
|
|
657
|
-
const client2 = createMailClient(store2);
|
|
658
|
-
client2.send({
|
|
659
|
-
from: "orchestrator",
|
|
660
|
-
to: "zero-debounce",
|
|
661
|
-
subject: "Msg 2",
|
|
662
|
-
body: "Message two",
|
|
663
|
-
});
|
|
664
|
-
client2.close();
|
|
591
|
+
await mailCommand(["check", "--inject", "--agent", "builder-1", "--signal"]);
|
|
665
592
|
|
|
666
|
-
//
|
|
667
|
-
|
|
668
|
-
await mailCommand(["check", "--inject", "--agent", "zero-debounce", "--debounce", "0"]);
|
|
669
|
-
expect(output).toContain("Message two");
|
|
593
|
+
// Signal file should be cleared by readAndClearPendingNudge
|
|
594
|
+
await expect(access(signalPath)).rejects.toThrow();
|
|
670
595
|
});
|
|
671
596
|
|
|
672
|
-
test("
|
|
673
|
-
//
|
|
674
|
-
output = "";
|
|
675
|
-
await mailCommand(["check", "--agent", "builder-1", "--debounce", "10000"]);
|
|
676
|
-
expect(output).toContain("Build task");
|
|
677
|
-
|
|
678
|
-
// Check for scout-1 immediately (different agent, should NOT be debounced)
|
|
679
|
-
output = "";
|
|
680
|
-
await mailCommand(["check", "--agent", "scout-1", "--debounce", "10000"]);
|
|
681
|
-
expect(output).toContain("Explore API");
|
|
682
|
-
|
|
683
|
-
// Check for builder-1 again (should be debounced)
|
|
597
|
+
test("--signal without --inject still respects signal gating", async () => {
|
|
598
|
+
// No signal file — should skip
|
|
684
599
|
output = "";
|
|
685
|
-
await mailCommand(["check", "--agent", "builder-1", "--
|
|
600
|
+
await mailCommand(["check", "--agent", "builder-1", "--signal"]);
|
|
686
601
|
expect(output).toBe("");
|
|
687
|
-
});
|
|
688
|
-
|
|
689
|
-
test("mail check --debounce with invalid value throws ValidationError", async () => {
|
|
690
|
-
try {
|
|
691
|
-
await mailCommand(["check", "--agent", "builder-1", "--debounce", "invalid"]);
|
|
692
|
-
expect(true).toBe(false); // Should not reach here
|
|
693
|
-
} catch (err) {
|
|
694
|
-
expect(err).toBeInstanceOf(Error);
|
|
695
|
-
if (err instanceof Error) {
|
|
696
|
-
expect(err.message).toContain("must be a non-negative integer");
|
|
697
|
-
}
|
|
698
|
-
}
|
|
699
|
-
});
|
|
700
602
|
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
603
|
+
// With signal file — should show messages
|
|
604
|
+
const nudgeDir = join(tempDir, ".legio", "pending-nudges");
|
|
605
|
+
await mkdir(nudgeDir, { recursive: true });
|
|
606
|
+
await writeFile(
|
|
607
|
+
join(nudgeDir, "builder-1.json"),
|
|
608
|
+
JSON.stringify({
|
|
609
|
+
from: "orchestrator",
|
|
610
|
+
reason: "status",
|
|
611
|
+
subject: "Build task",
|
|
612
|
+
messageId: "test-msg-1",
|
|
613
|
+
createdAt: new Date().toISOString(),
|
|
614
|
+
}),
|
|
615
|
+
);
|
|
712
616
|
|
|
713
|
-
test("mail check --inject with --debounce skips check within window", async () => {
|
|
714
|
-
// First inject check with debounce
|
|
715
617
|
output = "";
|
|
716
|
-
await mailCommand(["check", "--
|
|
618
|
+
await mailCommand(["check", "--agent", "builder-1", "--signal"]);
|
|
717
619
|
expect(output).toContain("Build task");
|
|
718
|
-
|
|
719
|
-
// Second inject check immediately (should be debounced)
|
|
720
|
-
output = "";
|
|
721
|
-
await mailCommand(["check", "--inject", "--agent", "builder-1", "--debounce", "500"]);
|
|
722
|
-
expect(output).toBe("");
|
|
723
620
|
});
|
|
724
621
|
|
|
725
|
-
test("
|
|
726
|
-
|
|
727
|
-
output = "";
|
|
622
|
+
test("--debounce flag emits deprecation warning", async () => {
|
|
623
|
+
stderrOutput = "";
|
|
728
624
|
await mailCommand(["check", "--agent", "builder-1", "--debounce", "500"]);
|
|
729
|
-
expect(
|
|
730
|
-
|
|
731
|
-
// Verify state file was created
|
|
732
|
-
const statePath = join(tempDir, ".legio", "mail-check-state.json");
|
|
733
|
-
{
|
|
734
|
-
let _e = false;
|
|
735
|
-
try {
|
|
736
|
-
await access(statePath);
|
|
737
|
-
_e = true;
|
|
738
|
-
} catch {}
|
|
739
|
-
expect(_e).toBe(true);
|
|
740
|
-
}
|
|
741
|
-
|
|
742
|
-
const state = JSON.parse(await readFile(statePath, "utf-8")) as Record<string, number>;
|
|
743
|
-
expect(state["builder-1"]).toBeTruthy();
|
|
744
|
-
expect(typeof state["builder-1"]).toBe("number");
|
|
625
|
+
expect(stderrOutput).toContain("--debounce is deprecated");
|
|
745
626
|
});
|
|
746
627
|
|
|
747
|
-
test("
|
|
748
|
-
//
|
|
749
|
-
const
|
|
750
|
-
|
|
628
|
+
test("without --signal flag always executes (backward compat)", async () => {
|
|
629
|
+
// Send first message
|
|
630
|
+
const store = createMailStore(join(tempDir, ".legio", "mail.db"));
|
|
631
|
+
const client = createMailClient(store);
|
|
632
|
+
client.send({
|
|
633
|
+
from: "orchestrator",
|
|
634
|
+
to: "test-agent",
|
|
635
|
+
subject: "Message 1",
|
|
636
|
+
body: "First message",
|
|
637
|
+
});
|
|
638
|
+
client.close();
|
|
751
639
|
|
|
752
|
-
//
|
|
640
|
+
// Check without --signal flag — always queries DB
|
|
753
641
|
output = "";
|
|
754
|
-
await mailCommand(["check", "--
|
|
755
|
-
expect(output).toContain("
|
|
756
|
-
|
|
757
|
-
// State should be corrected
|
|
758
|
-
const state = JSON.parse(await readFile(statePath, "utf-8")) as Record<string, number>;
|
|
759
|
-
expect(state["builder-1"]).toBeTruthy();
|
|
760
|
-
});
|
|
761
|
-
|
|
762
|
-
test("mail check debounce only records timestamp when flag is provided", async () => {
|
|
763
|
-
const statePath = join(tempDir, ".legio", "mail-check-state.json");
|
|
764
|
-
|
|
765
|
-
// Check without debounce flag
|
|
766
|
-
await mailCommand(["check", "--agent", "builder-1"]);
|
|
767
|
-
|
|
768
|
-
// State file should not be created
|
|
769
|
-
await expect(access(statePath)).rejects.toThrow();
|
|
770
|
-
|
|
771
|
-
// Check with debounce flag
|
|
772
|
-
await mailCommand(["check", "--agent", "builder-1", "--debounce", "500"]);
|
|
773
|
-
|
|
774
|
-
// Now state file should exist
|
|
775
|
-
{
|
|
776
|
-
let _e = false;
|
|
777
|
-
try {
|
|
778
|
-
await access(statePath);
|
|
779
|
-
_e = true;
|
|
780
|
-
} catch {}
|
|
781
|
-
expect(_e).toBe(true);
|
|
782
|
-
}
|
|
642
|
+
await mailCommand(["check", "--inject", "--agent", "test-agent"]);
|
|
643
|
+
expect(output).toContain("Message 1");
|
|
783
644
|
});
|
|
784
645
|
});
|
|
785
646
|
|
package/src/commands/mail.ts
CHANGED
|
@@ -6,13 +6,19 @@
|
|
|
6
6
|
* and various filters for listing messages.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
import { access, readFile
|
|
9
|
+
import { access, readFile } from "node:fs/promises";
|
|
10
10
|
import { join } from "node:path";
|
|
11
11
|
import { resolveProjectRoot } from "../config.ts";
|
|
12
12
|
import { MailError, ValidationError } from "../errors.ts";
|
|
13
13
|
import { createEventStore } from "../events/store.ts";
|
|
14
14
|
import { isGroupAddress, resolveGroupAddress } from "../mail/broadcast.ts";
|
|
15
15
|
import { createMailClient } from "../mail/client.ts";
|
|
16
|
+
import {
|
|
17
|
+
isAgentIdle,
|
|
18
|
+
pendingNudgeDir,
|
|
19
|
+
readAndClearPendingNudge,
|
|
20
|
+
writePendingNudge,
|
|
21
|
+
} from "../mail/pending.ts";
|
|
16
22
|
import { createMailStore } from "../mail/store.ts";
|
|
17
23
|
import { openSessionStore } from "../sessions/compat.ts";
|
|
18
24
|
import type { MailAudience, MailMessage } from "../types.ts";
|
|
@@ -56,7 +62,15 @@ function hasFlag(args: string[], flag: string): boolean {
|
|
|
56
62
|
}
|
|
57
63
|
|
|
58
64
|
/** Boolean flags that do NOT consume the next arg as a value. */
|
|
59
|
-
const BOOLEAN_FLAGS = new Set([
|
|
65
|
+
const BOOLEAN_FLAGS = new Set([
|
|
66
|
+
"--json",
|
|
67
|
+
"--inject",
|
|
68
|
+
"--unread",
|
|
69
|
+
"--all",
|
|
70
|
+
"--signal",
|
|
71
|
+
"--help",
|
|
72
|
+
"-h",
|
|
73
|
+
]);
|
|
60
74
|
|
|
61
75
|
/**
|
|
62
76
|
* Extract positional arguments from an args array, skipping flag-value pairs.
|
|
@@ -141,165 +155,6 @@ function openStore(cwd: string) {
|
|
|
141
155
|
return createMailStore(dbPath);
|
|
142
156
|
}
|
|
143
157
|
|
|
144
|
-
// === Pending Nudge Markers ===
|
|
145
|
-
//
|
|
146
|
-
// Instead of sending tmux keys (which corrupt tool I/O), auto-nudge writes
|
|
147
|
-
// a JSON marker file per agent. The `mail check --inject` flow reads and
|
|
148
|
-
// clears these markers, prepending a priority banner to the injected output.
|
|
149
|
-
|
|
150
|
-
/** Directory where pending nudge markers are stored. */
|
|
151
|
-
function pendingNudgeDir(cwd: string): string {
|
|
152
|
-
return join(cwd, ".legio", "pending-nudges");
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
/**
|
|
156
|
-
* Check if an agent is idle (not actively executing a tool).
|
|
157
|
-
*
|
|
158
|
-
* An agent is considered idle when `.legio/agent-busy/{agentName}` does NOT exist.
|
|
159
|
-
* The busy marker is written by hooks during active tool execution and removed when idle.
|
|
160
|
-
* Idle agents can receive a direct tmux nudge; busy agents only get the pending marker.
|
|
161
|
-
*/
|
|
162
|
-
async function isAgentIdle(cwd: string, agentName: string): Promise<boolean> {
|
|
163
|
-
const busyPath = join(cwd, ".legio", "agent-busy", agentName);
|
|
164
|
-
try {
|
|
165
|
-
await access(busyPath);
|
|
166
|
-
return false; // busy marker present — agent is actively working
|
|
167
|
-
} catch {
|
|
168
|
-
return true; // no busy marker — agent is idle
|
|
169
|
-
}
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
/** Shape of a pending nudge marker file. */
|
|
173
|
-
interface PendingNudge {
|
|
174
|
-
from: string;
|
|
175
|
-
reason: string;
|
|
176
|
-
subject: string;
|
|
177
|
-
messageId: string;
|
|
178
|
-
createdAt: string;
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
/**
|
|
182
|
-
* Write a pending nudge marker for an agent.
|
|
183
|
-
*
|
|
184
|
-
* Creates `.legio/pending-nudges/{agent}.json` so that the next
|
|
185
|
-
* `mail check --inject` call surfaces a priority banner for this message.
|
|
186
|
-
* Overwrites any existing marker (only the latest nudge matters).
|
|
187
|
-
*/
|
|
188
|
-
async function writePendingNudge(
|
|
189
|
-
cwd: string,
|
|
190
|
-
agentName: string,
|
|
191
|
-
nudge: Omit<PendingNudge, "createdAt">,
|
|
192
|
-
): Promise<void> {
|
|
193
|
-
const dir = pendingNudgeDir(cwd);
|
|
194
|
-
const { mkdir } = await import("node:fs/promises");
|
|
195
|
-
await mkdir(dir, { recursive: true });
|
|
196
|
-
|
|
197
|
-
const marker: PendingNudge = {
|
|
198
|
-
...nudge,
|
|
199
|
-
createdAt: new Date().toISOString(),
|
|
200
|
-
};
|
|
201
|
-
const filePath = join(dir, `${agentName}.json`);
|
|
202
|
-
await writeFile(filePath, `${JSON.stringify(marker, null, "\t")}\n`);
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
/**
|
|
206
|
-
* Read and clear pending nudge markers for an agent.
|
|
207
|
-
*
|
|
208
|
-
* Returns the pending nudge (if any) and removes the marker file.
|
|
209
|
-
* Called by `mail check --inject` to prepend a priority banner.
|
|
210
|
-
*/
|
|
211
|
-
async function readAndClearPendingNudge(
|
|
212
|
-
cwd: string,
|
|
213
|
-
agentName: string,
|
|
214
|
-
): Promise<PendingNudge | null> {
|
|
215
|
-
const filePath = join(pendingNudgeDir(cwd), `${agentName}.json`);
|
|
216
|
-
try {
|
|
217
|
-
await access(filePath);
|
|
218
|
-
} catch {
|
|
219
|
-
return null;
|
|
220
|
-
}
|
|
221
|
-
try {
|
|
222
|
-
const text = await readFile(filePath, "utf-8");
|
|
223
|
-
const nudge = JSON.parse(text) as PendingNudge;
|
|
224
|
-
const { unlink } = await import("node:fs/promises");
|
|
225
|
-
await unlink(filePath);
|
|
226
|
-
return nudge;
|
|
227
|
-
} catch {
|
|
228
|
-
// Corrupt or race condition — clear it and move on
|
|
229
|
-
try {
|
|
230
|
-
const { unlink } = await import("node:fs/promises");
|
|
231
|
-
await unlink(filePath);
|
|
232
|
-
} catch {
|
|
233
|
-
// Already gone
|
|
234
|
-
}
|
|
235
|
-
return null;
|
|
236
|
-
}
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
// === Mail Check Debounce ===
|
|
240
|
-
//
|
|
241
|
-
// Prevents excessive mail checking by tracking the last check timestamp per agent.
|
|
242
|
-
// When --debounce flag is provided, mail check will skip if called within the
|
|
243
|
-
// debounce window.
|
|
244
|
-
|
|
245
|
-
/**
|
|
246
|
-
* Path to the mail check debounce state file.
|
|
247
|
-
*/
|
|
248
|
-
function mailCheckStatePath(cwd: string): string {
|
|
249
|
-
return join(cwd, ".legio", "mail-check-state.json");
|
|
250
|
-
}
|
|
251
|
-
|
|
252
|
-
/**
|
|
253
|
-
* Check if a mail check for this agent is within the debounce window.
|
|
254
|
-
*
|
|
255
|
-
* @param cwd - Project root directory
|
|
256
|
-
* @param agentName - Agent name
|
|
257
|
-
* @param debounceMs - Debounce interval in milliseconds
|
|
258
|
-
* @returns true if the last check was within the debounce window
|
|
259
|
-
*/
|
|
260
|
-
async function isMailCheckDebounced(
|
|
261
|
-
cwd: string,
|
|
262
|
-
agentName: string,
|
|
263
|
-
debounceMs: number,
|
|
264
|
-
): Promise<boolean> {
|
|
265
|
-
const statePath = mailCheckStatePath(cwd);
|
|
266
|
-
try {
|
|
267
|
-
await access(statePath);
|
|
268
|
-
} catch {
|
|
269
|
-
return false;
|
|
270
|
-
}
|
|
271
|
-
try {
|
|
272
|
-
const text = await readFile(statePath, "utf-8");
|
|
273
|
-
const state = JSON.parse(text) as Record<string, number>;
|
|
274
|
-
const lastCheck = state[agentName];
|
|
275
|
-
if (lastCheck === undefined) {
|
|
276
|
-
return false;
|
|
277
|
-
}
|
|
278
|
-
return Date.now() - lastCheck < debounceMs;
|
|
279
|
-
} catch {
|
|
280
|
-
return false;
|
|
281
|
-
}
|
|
282
|
-
}
|
|
283
|
-
|
|
284
|
-
/**
|
|
285
|
-
* Record a mail check timestamp for debounce tracking.
|
|
286
|
-
*
|
|
287
|
-
* @param cwd - Project root directory
|
|
288
|
-
* @param agentName - Agent name
|
|
289
|
-
*/
|
|
290
|
-
async function recordMailCheck(cwd: string, agentName: string): Promise<void> {
|
|
291
|
-
const statePath = mailCheckStatePath(cwd);
|
|
292
|
-
let state: Record<string, number> = {};
|
|
293
|
-
try {
|
|
294
|
-
const text = await readFile(statePath, "utf-8");
|
|
295
|
-
state = JSON.parse(text) as Record<string, number>;
|
|
296
|
-
} catch {
|
|
297
|
-
// File does not exist or corrupt state — start fresh
|
|
298
|
-
}
|
|
299
|
-
state[agentName] = Date.now();
|
|
300
|
-
await writeFile(statePath, `${JSON.stringify(state, null, "\t")}\n`);
|
|
301
|
-
}
|
|
302
|
-
|
|
303
158
|
/**
|
|
304
159
|
* Open a mail client connected to the project's mail.db.
|
|
305
160
|
* The cwd must already be resolved to the canonical project root.
|
|
@@ -336,7 +191,14 @@ async function handleSend(args: string[], cwd: string): Promise<void> {
|
|
|
336
191
|
}
|
|
337
192
|
|
|
338
193
|
const type = rawType as MailMessage["type"];
|
|
339
|
-
|
|
194
|
+
let priority = rawPriority as MailMessage["priority"];
|
|
195
|
+
|
|
196
|
+
// escalation and dispatch default to high priority when no explicit --priority was given
|
|
197
|
+
const HIGH_PRIORITY_DEFAULT_TYPES = new Set(["escalation", "dispatch"]);
|
|
198
|
+
const explicitPriority = getFlag(args, "--priority") !== undefined;
|
|
199
|
+
if (!explicitPriority && HIGH_PRIORITY_DEFAULT_TYPES.has(type)) {
|
|
200
|
+
priority = "high";
|
|
201
|
+
}
|
|
340
202
|
|
|
341
203
|
// Parse --audience flag (optional, auto-derived from type if not specified)
|
|
342
204
|
const rawAudience = getFlag(args, "--audience");
|
|
@@ -628,8 +490,17 @@ async function handleCheck(args: string[], cwd: string): Promise<void> {
|
|
|
628
490
|
const agent = getFlag(args, "--agent") ?? "orchestrator";
|
|
629
491
|
const inject = hasFlag(args, "--inject");
|
|
630
492
|
const json = hasFlag(args, "--json");
|
|
631
|
-
const
|
|
493
|
+
const signal = hasFlag(args, "--signal");
|
|
632
494
|
const audience = getFlag(args, "--audience");
|
|
495
|
+
|
|
496
|
+
// --debounce is deprecated (no-op). Accept silently for backward compat.
|
|
497
|
+
// The --signal flag replaces debounce — signal files are the authoritative trigger.
|
|
498
|
+
if (getFlag(args, "--debounce") !== undefined) {
|
|
499
|
+
process.stderr.write(
|
|
500
|
+
"⚠️ --debounce is deprecated and ignored. Use --signal for signal-gated mail checks.\n",
|
|
501
|
+
);
|
|
502
|
+
}
|
|
503
|
+
|
|
633
504
|
if (audience !== undefined && !(VALID_AUDIENCES as readonly string[]).includes(audience)) {
|
|
634
505
|
throw new ValidationError(
|
|
635
506
|
`Invalid --audience "${audience}". Must be one of: ${VALID_AUDIENCES.join(", ")}`,
|
|
@@ -637,24 +508,14 @@ async function handleCheck(args: string[], cwd: string): Promise<void> {
|
|
|
637
508
|
);
|
|
638
509
|
}
|
|
639
510
|
|
|
640
|
-
//
|
|
641
|
-
|
|
642
|
-
if (
|
|
643
|
-
const
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
);
|
|
649
|
-
}
|
|
650
|
-
debounceMs = parsed;
|
|
651
|
-
}
|
|
652
|
-
|
|
653
|
-
// Check debounce if enabled
|
|
654
|
-
if (debounceMs !== undefined) {
|
|
655
|
-
const debounced = await isMailCheckDebounced(cwd, agent, debounceMs);
|
|
656
|
-
if (debounced) {
|
|
657
|
-
// Silent skip — no output when debounced
|
|
511
|
+
// Signal-gated mode: skip DB query entirely if no signal file exists.
|
|
512
|
+
// The signal file is the pending nudge marker written by `mail send`.
|
|
513
|
+
if (signal) {
|
|
514
|
+
const signalPath = join(pendingNudgeDir(cwd), `${agent}.json`);
|
|
515
|
+
try {
|
|
516
|
+
await access(signalPath);
|
|
517
|
+
} catch {
|
|
518
|
+
// No signal file — no new mail. Exit immediately (zero cost).
|
|
658
519
|
return;
|
|
659
520
|
}
|
|
660
521
|
}
|
|
@@ -712,11 +573,6 @@ async function handleCheck(args: string[], cwd: string): Promise<void> {
|
|
|
712
573
|
}
|
|
713
574
|
}
|
|
714
575
|
}
|
|
715
|
-
|
|
716
|
-
// Record this check for debounce tracking (only if debounce is enabled)
|
|
717
|
-
if (debounceMs !== undefined) {
|
|
718
|
-
await recordMailCheck(cwd, agent);
|
|
719
|
-
}
|
|
720
576
|
} finally {
|
|
721
577
|
client.close();
|
|
722
578
|
}
|
|
@@ -869,7 +725,7 @@ Subcommands:
|
|
|
869
725
|
Audience: defaults to 'agent' for protocol types, 'both' for semantic types
|
|
870
726
|
check Check inbox (unread messages)
|
|
871
727
|
[--agent <name>] [--audience <human|agent|both>]
|
|
872
|
-
[--inject] [--json]
|
|
728
|
+
[--inject] [--signal] [--json]
|
|
873
729
|
list List messages with filters
|
|
874
730
|
[--from <name>] [--to <name>] [--agent <name> (alias for --to)]
|
|
875
731
|
[--audience <human|agent|both>] [--unread] [--json]
|