@ianwremmel/dispatch 0.32.1-bootstrap.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.
Files changed (165) hide show
  1. package/.claude-plugin/plugin.json +59 -0
  2. package/.mcp.json +8 -0
  3. package/LICENSE +21 -0
  4. package/README.md +93 -0
  5. package/agents/.gitkeep +0 -0
  6. package/agents/build-graph.md +99 -0
  7. package/agents/milestone-reviewer.md +50 -0
  8. package/agents/pr-worker.md +172 -0
  9. package/agents/ticket-worker.md +97 -0
  10. package/bin/dispatch +101 -0
  11. package/bin/dispatch-mcp +19 -0
  12. package/bin/pr-status +931 -0
  13. package/commands/.gitkeep +0 -0
  14. package/commands/orchestrate.md +6 -0
  15. package/hooks/.gitkeep +0 -0
  16. package/hooks/claim-guard.mts +98 -0
  17. package/hooks/hooks.json +15 -0
  18. package/package.json +46 -0
  19. package/skills/.gitkeep +0 -0
  20. package/skills/land/SKILL.md +238 -0
  21. package/skills/land/credentials-dedicated.md +33 -0
  22. package/skills/land/credentials-shared.md +76 -0
  23. package/skills/land/mode-solo.md +76 -0
  24. package/skills/land/mode-team.md +103 -0
  25. package/skills/land/reference.md +152 -0
  26. package/skills/land/ticket.md +94 -0
  27. package/skills/orchestrate/SKILL.md +87 -0
  28. package/skills/tracker-adapter-linear/SKILL.md +142 -0
  29. package/src/commands/CLAUDE.md +12 -0
  30. package/src/commands/claim/check.mts +88 -0
  31. package/src/commands/claim/guard.mts +95 -0
  32. package/src/commands/claim/status.mts +49 -0
  33. package/src/commands/edge/add.mts +44 -0
  34. package/src/commands/edge/rm.mts +44 -0
  35. package/src/commands/edge/set.mts +56 -0
  36. package/src/commands/greet.mts +34 -0
  37. package/src/commands/mcp/ack.mts +43 -0
  38. package/src/commands/mcp/ping.mts +61 -0
  39. package/src/commands/mcp/status.mts +89 -0
  40. package/src/commands/mcp.mts +155 -0
  41. package/src/commands/milestone/rm.mts +35 -0
  42. package/src/commands/milestone/set.mts +49 -0
  43. package/src/commands/outcome/rm.mts +36 -0
  44. package/src/commands/outcome/set.mts +86 -0
  45. package/src/commands/pr/rm.mts +33 -0
  46. package/src/commands/pr/set.mts +110 -0
  47. package/src/commands/pr/yield.mts +114 -0
  48. package/src/commands/project/rm.mts +33 -0
  49. package/src/commands/project/set.mts +50 -0
  50. package/src/commands/queue.mts +41 -0
  51. package/src/commands/refresh/done.mts +42 -0
  52. package/src/commands/refresh/status.mts +40 -0
  53. package/src/commands/refresh.mts +56 -0
  54. package/src/commands/review/record.mts +46 -0
  55. package/src/commands/review/release.mts +49 -0
  56. package/src/commands/status.mts +85 -0
  57. package/src/commands/ticket/missing.mts +31 -0
  58. package/src/commands/ticket/rm.mts +33 -0
  59. package/src/commands/ticket/set.mts +134 -0
  60. package/src/commands/worker/rm.mts +46 -0
  61. package/src/commands/worker/set.mts +63 -0
  62. package/src/lib/cli/CLAUDE.md +13 -0
  63. package/src/lib/cli/cli.mts +226 -0
  64. package/src/lib/cli/index.mts +1 -0
  65. package/src/lib/command/CLAUDE.md +26 -0
  66. package/src/lib/command/__fixtures__/bad-export/oops.mts +1 -0
  67. package/src/lib/command/__fixtures__/bad-name/mismatch.mts +19 -0
  68. package/src/lib/command/__fixtures__/commands/cli-only.mts +20 -0
  69. package/src/lib/command/__fixtures__/commands/greet.mts +39 -0
  70. package/src/lib/command/__fixtures__/commands/math/add.mts +32 -0
  71. package/src/lib/command/__fixtures__/commands/mcp-only.mts +20 -0
  72. package/src/lib/command/__fixtures__/commands/needs-token.mts +19 -0
  73. package/src/lib/command/__fixtures__/commands/store/get.mts +26 -0
  74. package/src/lib/command/__fixtures__/commands/store.mts +26 -0
  75. package/src/lib/command/abstract-command.mts +104 -0
  76. package/src/lib/command/discovery.mts +100 -0
  77. package/src/lib/command/env.mts +19 -0
  78. package/src/lib/command/index.mts +6 -0
  79. package/src/lib/command/parse.mts +64 -0
  80. package/src/lib/command/test-support.mts +81 -0
  81. package/src/lib/command/transports.mts +17 -0
  82. package/src/lib/command/types.mts +53 -0
  83. package/src/lib/db/CLAUDE.md +13 -0
  84. package/src/lib/db/database.mts +160 -0
  85. package/src/lib/db/index.mts +4 -0
  86. package/src/lib/db/schema.mts +195 -0
  87. package/src/lib/db/time.mts +24 -0
  88. package/src/lib/db/with-database.mts +56 -0
  89. package/src/lib/errors/CLAUDE.md +18 -0
  90. package/src/lib/errors/command-error.mts +13 -0
  91. package/src/lib/errors/data-error.mts +12 -0
  92. package/src/lib/errors/definition-error.mts +6 -0
  93. package/src/lib/errors/dispatch-error.mts +27 -0
  94. package/src/lib/errors/ensure.mts +22 -0
  95. package/src/lib/errors/environment-error.mts +7 -0
  96. package/src/lib/errors/index.mts +8 -0
  97. package/src/lib/errors/json-rpc-error.mts +18 -0
  98. package/src/lib/errors/usage-error.mts +7 -0
  99. package/src/lib/graph/CLAUDE.md +17 -0
  100. package/src/lib/graph/anomalies.mts +110 -0
  101. package/src/lib/graph/derive.mts +96 -0
  102. package/src/lib/graph/index.mts +26 -0
  103. package/src/lib/graph/pipeline.mts +410 -0
  104. package/src/lib/graph/queries.mts +207 -0
  105. package/src/lib/graph/rows.mts +99 -0
  106. package/src/lib/graph/types.mts +137 -0
  107. package/src/lib/liveness/CLAUDE.md +14 -0
  108. package/src/lib/liveness/index.mts +10 -0
  109. package/src/lib/liveness/liveness.mts +147 -0
  110. package/src/lib/liveness/retire.mts +63 -0
  111. package/src/lib/logger/CLAUDE.md +12 -0
  112. package/src/lib/logger/index.mts +2 -0
  113. package/src/lib/logger/logger.mts +58 -0
  114. package/src/lib/logger/stream-sink.mts +23 -0
  115. package/src/lib/mcp/CLAUDE.md +21 -0
  116. package/src/lib/mcp/channel.mts +41 -0
  117. package/src/lib/mcp/dispatch.mts +60 -0
  118. package/src/lib/mcp/drain.mts +83 -0
  119. package/src/lib/mcp/index.mts +5 -0
  120. package/src/lib/mcp/mcp.mts +267 -0
  121. package/src/lib/mcp/tools.mts +77 -0
  122. package/src/lib/model/CLAUDE.md +8 -0
  123. package/src/lib/model/index.mts +3 -0
  124. package/src/lib/model/repo-caps.mts +95 -0
  125. package/src/lib/model/status.mts +91 -0
  126. package/src/lib/model/types.mts +83 -0
  127. package/src/lib/refresh/index.mts +2 -0
  128. package/src/lib/refresh/placeholders.mts +43 -0
  129. package/src/lib/refresh/refresh-service.mts +203 -0
  130. package/src/lib/schedule/CLAUDE.md +18 -0
  131. package/src/lib/schedule/caps.mts +113 -0
  132. package/src/lib/schedule/correlate.mts +69 -0
  133. package/src/lib/schedule/index.mts +7 -0
  134. package/src/lib/schedule/scheduler.mts +355 -0
  135. package/src/lib/schedule/tick.mts +266 -0
  136. package/src/lib/stores/CLAUDE.md +24 -0
  137. package/src/lib/stores/coordination.mts +359 -0
  138. package/src/lib/stores/cursor.mts +41 -0
  139. package/src/lib/stores/edge.mts +138 -0
  140. package/src/lib/stores/fetch-request.mts +346 -0
  141. package/src/lib/stores/index.mts +19 -0
  142. package/src/lib/stores/materialize.mts +69 -0
  143. package/src/lib/stores/milestone.mts +74 -0
  144. package/src/lib/stores/notice.mts +57 -0
  145. package/src/lib/stores/policy.mts +48 -0
  146. package/src/lib/stores/pr-event.mts +94 -0
  147. package/src/lib/stores/pr.mts +167 -0
  148. package/src/lib/stores/project.mts +79 -0
  149. package/src/lib/stores/refresh.mts +197 -0
  150. package/src/lib/stores/review.mts +113 -0
  151. package/src/lib/stores/session.mts +170 -0
  152. package/src/lib/stores/ticket.mts +246 -0
  153. package/src/lib/stores/watch.mts +360 -0
  154. package/src/lib/stores/worker.mts +121 -0
  155. package/src/lib/watch/adopt.mts +151 -0
  156. package/src/lib/watch/arm.mts +48 -0
  157. package/src/lib/watch/cadence.mts +45 -0
  158. package/src/lib/watch/diff.mts +274 -0
  159. package/src/lib/watch/index.mts +11 -0
  160. package/src/lib/watch/marker.mts +24 -0
  161. package/src/lib/watch/payload.mts +56 -0
  162. package/src/lib/watch/poll.mts +87 -0
  163. package/src/lib/watch/render.mts +61 -0
  164. package/src/lib/watch/snapshot.mts +312 -0
  165. package/src/main.mts +18 -0
@@ -0,0 +1,99 @@
1
+ import type {Row} from '../db/database.mts';
2
+ import {isOutcome, isStatus, isTargetKind} from '../model/status.mts';
3
+ import type {
4
+ Classification,
5
+ ClassifiedItem,
6
+ ClaimView,
7
+ OutcomeView,
8
+ } from './types.mts';
9
+
10
+ /** SQLite hands values back loosely typed; these narrow without trusting a cast. */
11
+ export function text(value: unknown): string | null {
12
+ if (typeof value === 'string') return value;
13
+ if (typeof value === 'number' || typeof value === 'bigint')
14
+ return String(value);
15
+ return null;
16
+ }
17
+
18
+ export function integer(value: unknown): number {
19
+ if (typeof value === 'number') return value;
20
+ if (typeof value === 'bigint') return Number(value);
21
+ return 0;
22
+ }
23
+
24
+ export function splitList(value: unknown): string[] {
25
+ const joined = text(value);
26
+ return joined === null || joined === '' ? [] : joined.split(',');
27
+ }
28
+
29
+ export function toClaim(row: Row): ClaimView | null {
30
+ const session = text(row.claim_session);
31
+ if (session === null) return null;
32
+ return {
33
+ session,
34
+ live: integer(row.claim_live) === 1,
35
+ actor: text(row.claim_actor),
36
+ worktree: text(row.claim_worktree),
37
+ branch: text(row.claim_branch),
38
+ claimedAt: text(row.claim_claimed_at) ?? '',
39
+ };
40
+ }
41
+
42
+ export function toOutcome(row: Row): OutcomeView | null {
43
+ const outcome = text(row.outcome);
44
+ if (outcome === null || !isOutcome(outcome)) return null;
45
+ return {
46
+ outcome,
47
+ retryable:
48
+ row.outcome_retryable === null || row.outcome_retryable === undefined
49
+ ? null
50
+ : integer(row.outcome_retryable) === 1,
51
+ detail: text(row.outcome_detail),
52
+ };
53
+ }
54
+
55
+ export function toClassified(row: Row): ClassifiedItem {
56
+ const status = text(row.status);
57
+ const targetKind = text(row.target_kind);
58
+ const rawLabels: unknown = JSON.parse(text(row.labels) ?? '[]');
59
+ const blockedBy = splitList(row.blocked_by);
60
+ const gatedBy = splitList(row.gated_by);
61
+
62
+ return {
63
+ item: {
64
+ id: text(row.id) ?? '',
65
+ kind: text(row.kind) === 'pr' ? 'pr' : 'ticket',
66
+ ticket: text(row.ticket),
67
+ project: text(row.project),
68
+ url: text(row.url),
69
+ title: text(row.title) ?? '',
70
+ // The CHECK constraints validated these on the way in; the guards keep
71
+ // the types honest without trusting a cast.
72
+ status: status !== null && isStatus(status) ? status : null,
73
+ repo: text(row.repo),
74
+ prNumber:
75
+ row.pr_number === null || row.pr_number === undefined
76
+ ? null
77
+ : integer(row.pr_number),
78
+ targetKind:
79
+ targetKind !== null && isTargetKind(targetKind) ? targetKind : null,
80
+ requiresHuman: integer(row.requires_human) === 1,
81
+ injected: integer(row.injected) === 1,
82
+ priority: typeof row.priority === 'number' ? row.priority : null,
83
+ branchHint: text(row.branch_hint),
84
+ labels: Array.isArray(rawLabels)
85
+ ? rawLabels.filter(
86
+ (label): label is string => typeof label === 'string'
87
+ )
88
+ : [],
89
+ milestones: splitList(row.milestones),
90
+ },
91
+ classification: (text(row.classification) ?? 'dormant') as Classification,
92
+ effectiveBlocked: blockedBy.length > 0 || gatedBy.length > 0,
93
+ blockedBy,
94
+ gatedBy,
95
+ claim: toClaim(row),
96
+ outcome: toOutcome(row),
97
+ fanout: integer(row.fanout),
98
+ };
99
+ }
@@ -0,0 +1,137 @@
1
+ import type {OutcomeKind, Status, TargetKind} from '../model/status.mts';
2
+
3
+ /**
4
+ * §2.6-derived buckets, highest precedence first: resolved → in-flight →
5
+ * dormant → blocked → human-blocked → available.
6
+ */
7
+ export const CLASSIFICATIONS = [
8
+ 'verified',
9
+ 'canceled',
10
+ 'in-flight',
11
+ 'dormant',
12
+ 'blocked',
13
+ 'human-blocked',
14
+ 'available',
15
+ ] as const;
16
+ export type Classification = (typeof CLASSIFICATIONS)[number];
17
+
18
+ /** A dispatch that continues earlier work rather than starting fresh. */
19
+ export const PASSES = ['resume', 'verify', 'finalize', 'retry'] as const;
20
+ export type Pass = (typeof PASSES)[number];
21
+
22
+ export interface DeriveOptions {
23
+ /** RFC 3339 instant used for claim staleness; defaults to now. */
24
+ now?: string | undefined;
25
+ /** A session heartbeat older than this makes its claims stale. */
26
+ staleAfterSeconds?: number | undefined;
27
+ /** Restrict scheduling reads to one project (external id). */
28
+ project?: string | undefined;
29
+ }
30
+
31
+ export interface ClaimView {
32
+ session: string;
33
+ live: boolean;
34
+ actor: string | null;
35
+ worktree: string | null;
36
+ branch: string | null;
37
+ claimedAt: string;
38
+ }
39
+
40
+ export interface OutcomeView {
41
+ outcome: OutcomeKind;
42
+ retryable: boolean | null;
43
+ detail: string | null;
44
+ }
45
+
46
+ /** One dispatchable work item: a ticket, or a PR item (bare or ticket-backed). */
47
+ export interface WorkItem {
48
+ id: string;
49
+ kind: 'ticket' | 'pr';
50
+ /** The ticket a PR item implements; null for tickets and bare PRs. */
51
+ ticket: string | null;
52
+ /** Null for a bare PR. */
53
+ project: string | null;
54
+ url: string | null;
55
+ title: string;
56
+ /** Null for a bare PR — its lifecycle is its outcome row. */
57
+ status: Status | null;
58
+ /** `owner/name`, on a PR item that names one; null on a ticket. */
59
+ repo: string | null;
60
+ /** The forge's number, once a PR exists; null while the item is unopened. */
61
+ prNumber: number | null;
62
+ targetKind: TargetKind | null;
63
+ requiresHuman: boolean;
64
+ injected: boolean;
65
+ priority: number | null;
66
+ branchHint: string | null;
67
+ labels: string[];
68
+ milestones: string[];
69
+ }
70
+
71
+ export interface ClassifiedItem {
72
+ item: WorkItem;
73
+ classification: Classification;
74
+ effectiveBlocked: boolean;
75
+ /** Unresolved blocking ancestors, by external id. */
76
+ blockedBy: string[];
77
+ /** Milestones whose unfinished review gates this item, by external id. */
78
+ gatedBy: string[];
79
+ claim: ClaimView | null;
80
+ outcome: OutcomeView | null;
81
+ /** Transitive descendant count — how much work this item gates. */
82
+ fanout: number;
83
+ }
84
+
85
+ export interface QueueEntry {
86
+ entry: ClassifiedItem;
87
+ /** Null for ordinary available work; otherwise the follow-up pass. */
88
+ pass: Pass | null;
89
+ }
90
+
91
+ export interface MilestoneState {
92
+ id: string;
93
+ project: string;
94
+ name: string;
95
+ members: string[];
96
+ memberCount: number;
97
+ openCount: number;
98
+ readyForReview: boolean;
99
+ reviewRecorded: boolean;
100
+ /** Ready, reviewed, and no member carries an unresolved dependency. */
101
+ open: boolean;
102
+ claim: ClaimView | null;
103
+ }
104
+
105
+ export interface Anomaly {
106
+ kind: 'cycle' | 'dangling-edge' | 'cross-project-reverse';
107
+ nodes: string[];
108
+ detail: string;
109
+ }
110
+
111
+ export interface ClassificationCounts {
112
+ available: number;
113
+ blocked: number;
114
+ humanBlocked: number;
115
+ inFlight: number;
116
+ dormant: number;
117
+ verified: number;
118
+ canceled: number;
119
+ }
120
+
121
+ export interface ProjectCounts extends ClassificationCounts {
122
+ project: string;
123
+ total: number;
124
+ terminal: boolean;
125
+ }
126
+
127
+ export interface DerivedGraph {
128
+ projects: {id: string; name: string; terminal: boolean}[];
129
+ items: ClassifiedItem[];
130
+ milestones: MilestoneState[];
131
+ counts: ProjectCounts[];
132
+ /** PR work items — prompt-injected or ticket-registered. */
133
+ prs: ClassifiedItem[];
134
+ anomalies: Anomaly[];
135
+ /** Every selected project terminal and no PR item open. */
136
+ terminal: boolean;
137
+ }
@@ -0,0 +1,14 @@
1
+ # liveness
2
+
3
+ Process-level liveness for session registry rows — the half heartbeat
4
+ freshness cannot see. Servers record their own process start as
5
+ `started_at`; the probe (`ps -o etime=`) reads the running process's start
6
+ back and `sameProcess` compares the two, so a reused pid never resolves.
7
+
8
+ The two consumers lean opposite ways. `withLiveProcesses` (matching) needs
9
+ proof of life: anything unverifiable — another host, no pid, a failed probe
10
+ — is dropped, because a false `active` strands a session while a false
11
+ `inactive` only costs polling. `retireNonLive` (the server's startup sweep)
12
+ needs proof of death: it deletes a row only for a stale heartbeat, a
13
+ vanished pid, or a reused pid, and leaves what it merely cannot verify to
14
+ the heartbeat sweep.
@@ -0,0 +1,10 @@
1
+ export {
2
+ parseEtime,
3
+ probeProcessStart,
4
+ processStartIso,
5
+ provenReused,
6
+ sameProcess,
7
+ withLiveProcesses,
8
+ } from './liveness.mts';
9
+ export type {ProbeResult} from './liveness.mts';
10
+ export {retireNonLive} from './retire.mts';
@@ -0,0 +1,147 @@
1
+ import {execFile} from 'node:child_process';
2
+ import {hostname} from 'node:os';
3
+ import {promisify} from 'node:util';
4
+
5
+ import type {Session} from '../model/types.mts';
6
+
7
+ const exec = promisify(execFile);
8
+
9
+ /**
10
+ * Slack when comparing a probed process start against the row's registered
11
+ * one: `ps -o etime=` has second granularity and the two readings happen at
12
+ * different moments. Kept small deliberately — a wider window would tolerate
13
+ * larger clock steps, but it would also accept a pid reused by a process
14
+ * that started near the registered instant, and a false "live" strands a
15
+ * session where a false "dead" only costs polling.
16
+ */
17
+ const START_SLACK_MS = 2_000;
18
+
19
+ /**
20
+ * What a probe learned about the process at `pid`: its start instant (epoch
21
+ * ms), `absent` when no such process exists, or `unknown` when the probe
22
+ * itself failed — `ps` missing or broken, or output it could not parse. The
23
+ * three-way split matters because matching and retiring lean opposite ways:
24
+ * a match needs proof of life, retirement needs proof of death, and
25
+ * `unknown` provides neither.
26
+ */
27
+ export type ProbeResult = number | 'absent' | 'unknown';
28
+
29
+ /** Parse `ps -o etime=` output — `[[dd-]hh:]mm:ss` — into seconds. */
30
+ export function parseEtime(raw: string): number | null {
31
+ const match = /^(?:(?:(\d+)-)?(\d+):)?(\d+):(\d+)$/u.exec(raw.trim());
32
+ if (match === null) return null;
33
+ const [, days, hours, minutes, seconds] = match;
34
+ return (
35
+ Number(days ?? 0) * 86_400 +
36
+ Number(hours ?? 0) * 3_600 +
37
+ Number(minutes) * 60 +
38
+ Number(seconds)
39
+ );
40
+ }
41
+
42
+ /**
43
+ * The start instant this very process would register: what its pid must
44
+ * verify against when a later caller probes it.
45
+ */
46
+ export function processStartIso(): string {
47
+ return new Date(Date.now() - process.uptime() * 1_000).toISOString();
48
+ }
49
+
50
+ /**
51
+ * POSIX `etime` rather than procps's `etimes`: macOS `ps` has no `etimes`
52
+ * keyword and would exit nonzero on every probe. `ps` rather than
53
+ * `process.kill(pid, 0)` because it also sees processes owned by other
54
+ * users, where the signal probe reports EPERM.
55
+ */
56
+ export async function probeProcessStart(pid: number): Promise<ProbeResult> {
57
+ try {
58
+ const {stdout} = await exec('ps', ['-o', 'etime=', '-p', String(pid)]);
59
+ const elapsed = parseEtime(stdout);
60
+ return elapsed === null ? 'unknown' : Date.now() - elapsed * 1_000;
61
+ } catch (error) {
62
+ // A string code (ENOENT, a spawn failure) is a failed probe. A numeric
63
+ // exit is how `ps` reports a missing pid — but an operationally broken
64
+ // `ps` exits nonzero too, so only trust "absent" once `ps` demonstrably
65
+ // works on this very process.
66
+ if (typeof (error as {code?: unknown}).code !== 'number') return 'unknown';
67
+ try {
68
+ const {stdout} = await exec('ps', [
69
+ '-o',
70
+ 'etime=',
71
+ '-p',
72
+ String(process.pid),
73
+ ]);
74
+ return parseEtime(stdout) === null ? 'unknown' : 'absent';
75
+ } catch {
76
+ return 'unknown';
77
+ }
78
+ }
79
+ }
80
+
81
+ /**
82
+ * Whether a probed start instant identifies the process registered on the
83
+ * row — servers record their own process start as `started_at`. False on an
84
+ * unparseable registered instant: identity that cannot be read cannot be
85
+ * confirmed.
86
+ */
87
+ export function sameProcess(probedStartMs: number, startedAt: string): boolean {
88
+ const registered = Date.parse(startedAt);
89
+ return (
90
+ Number.isFinite(registered) &&
91
+ Math.abs(probedStartMs - registered) <= START_SLACK_MS
92
+ );
93
+ }
94
+
95
+ /**
96
+ * Whether a probed start proves the registered process is gone: a reused
97
+ * pid can only start after the registered server died, so only a probed
98
+ * start decisively past the registered instant is proof. An earlier probed
99
+ * start merely means registration lagged the process start — rows written
100
+ * by older plugin versions recorded the registration instant here — and
101
+ * proves nothing about death.
102
+ *
103
+ * `slackMs` should be generous — the staleness window, not the matching
104
+ * slack: retiring a live row (say, after a forward clock step) is
105
+ * unrecoverable, while a reuse this slack fails to prove only leaves a row
106
+ * the heartbeat sweep bounds anyway.
107
+ */
108
+ export function provenReused(
109
+ probedStartMs: number,
110
+ startedAt: string,
111
+ slackMs: number
112
+ ): boolean {
113
+ const registered = Date.parse(startedAt);
114
+ return Number.isFinite(registered) && probedStartMs > registered + slackMs;
115
+ }
116
+
117
+ /**
118
+ * Keep only rows whose server process this one can vouch for: same host, a
119
+ * recorded pid, and a running process whose start matches the registered
120
+ * one (which rules out a reused pid). This is the other half of liveness
121
+ * beside heartbeat freshness — a server killed without cleanup (a plugin
122
+ * reload) stops heartbeating, but its row would otherwise stay "live" for
123
+ * the whole staleness window, holding its session ambiguous. Rows that
124
+ * cannot pass the check — another host, no pid recorded, a failed probe —
125
+ * are dropped too: reporting a server the caller cannot verify risks a
126
+ * false `active`, which strands a session yielding for events that never
127
+ * arrive, where a false `inactive` only costs polling.
128
+ */
129
+ export async function withLiveProcesses(
130
+ sessions: Session[],
131
+ opts?: {
132
+ probe?: ((pid: number) => Promise<ProbeResult>) | undefined;
133
+ host?: string | undefined;
134
+ }
135
+ ): Promise<Session[]> {
136
+ const probe = opts?.probe ?? probeProcessStart;
137
+ const host = opts?.host ?? hostname();
138
+ const live: Session[] = [];
139
+ for (const session of sessions) {
140
+ if (session.host !== host || session.pid === null) continue;
141
+ const probed = await probe(session.pid);
142
+ if (typeof probed !== 'number') continue;
143
+ if (!sameProcess(probed, session.startedAt)) continue;
144
+ live.push(session);
145
+ }
146
+ return live;
147
+ }
@@ -0,0 +1,63 @@
1
+ import {hostname} from 'node:os';
2
+
3
+ import type {Database} from '../db/database.mts';
4
+ import type {Session} from '../model/types.mts';
5
+ import {SessionStore} from '../stores/session.mts';
6
+ import {probeProcessStart, provenReused} from './liveness.mts';
7
+ import type {ProbeResult} from './liveness.mts';
8
+
9
+ /**
10
+ * Startup retirement: delete rows carrying this session id whose server is
11
+ * provably dead — heartbeat gone stale, pid gone, or pid reused by a
12
+ * different process — so a server killed without cleanup (a plugin reload)
13
+ * does not hold its claims and slots for the rest of the staleness window.
14
+ * Deletion demands proof of death, the opposite bias from matching: a row
15
+ * that merely cannot be verified (another host, no pid, a failed probe) is
16
+ * left for the heartbeat sweep, and a genuinely live rival always stays —
17
+ * two live servers under one session id are the `ambiguous-session` case,
18
+ * which fails closed rather than being resolved by whichever registered
19
+ * last. Returns the number of rows retired.
20
+ */
21
+ export async function retireNonLive(
22
+ db: Database,
23
+ opts: {
24
+ claudeSessionId: string;
25
+ /** The caller's own registry row, never retired. */
26
+ keep: string;
27
+ now: string;
28
+ staleAfterSeconds: number;
29
+ probe?: ((pid: number) => Promise<ProbeResult>) | undefined;
30
+ host?: string | undefined;
31
+ }
32
+ ): Promise<number> {
33
+ const sessions = new SessionStore(db);
34
+ const probe = opts.probe ?? probeProcessStart;
35
+ const host = opts.host ?? hostname();
36
+ let retired = 0;
37
+ for (const row of await sessions.forCaller(opts.claudeSessionId)) {
38
+ if (row.id === opts.keep) continue;
39
+ if (!(await provenDead(row, {...opts, probe, host}))) continue;
40
+ if (await sessions.close(row.id)) retired += 1;
41
+ }
42
+ return retired;
43
+ }
44
+
45
+ async function provenDead(
46
+ row: Session,
47
+ opts: {
48
+ now: string;
49
+ staleAfterSeconds: number;
50
+ probe: (pid: number) => Promise<ProbeResult>;
51
+ host: string;
52
+ }
53
+ ): Promise<boolean> {
54
+ const quietMs = Date.parse(opts.now) - Date.parse(row.heartbeatAt);
55
+ if (Number.isFinite(quietMs) && quietMs > opts.staleAfterSeconds * 1_000) {
56
+ return true;
57
+ }
58
+ if (row.host !== opts.host || row.pid === null) return false;
59
+ const probed = await opts.probe(row.pid);
60
+ if (probed === 'absent') return true;
61
+ if (probed === 'unknown') return false;
62
+ return provenReused(probed, row.startedAt, opts.staleAfterSeconds * 1_000);
63
+ }
@@ -0,0 +1,12 @@
1
+ # Logger
2
+
3
+ `createLogger(sink = console)` in `logger.mts` — a metadata-accumulating logger
4
+ with a `child(meta)` binder. The docblock there is the contract: merge precedence
5
+ and empty-meta handling.
6
+
7
+ `streamSink(stream)` in `stream-sink.mts` is the sink every entry point binds to
8
+ stderr, because `dispatch mcp` owns stdout as its JSON-RPC channel and `console`
9
+ would put `log`/`info`/`debug` there.
10
+
11
+ Tests pass a recording sink and assert what each call produced — level,
12
+ message, metadata, and argument count.
@@ -0,0 +1,2 @@
1
+ export * from './logger.mts';
2
+ export * from './stream-sink.mts';
@@ -0,0 +1,58 @@
1
+ export type LogMethod = (
2
+ message: string,
3
+ meta?: Record<string, unknown>
4
+ ) => void;
5
+
6
+ export const LEVELS = [
7
+ 'error',
8
+ 'warn',
9
+ 'info',
10
+ 'debug',
11
+ 'trace',
12
+ 'log',
13
+ ] as const;
14
+
15
+ export type CoreLogger = Record<(typeof LEVELS)[number], LogMethod>;
16
+
17
+ export interface Logger extends CoreLogger {
18
+ child(meta: Record<string, unknown>): Logger;
19
+ }
20
+
21
+ /**
22
+ * Wrap a console-shaped sink so every call carries accumulated metadata and the
23
+ * logger gains a `child()` binder. The default sink is `console`; the MCP server
24
+ * passes a stderr-bound sink so it never writes to the JSON-RPC channel.
25
+ *
26
+ * Bound metadata wins over a colliding key at the call site, and a deeper
27
+ * `child()` wins over a shallower one. When the merged metadata is empty the
28
+ * sink is called without a metadata argument, so plain calls stay plain.
29
+ */
30
+ export function createLogger(sink: CoreLogger = console): Logger {
31
+ const bind = (bound: Record<string, unknown>): Logger =>
32
+ new Proxy(sink, {
33
+ get(target, prop, receiver) {
34
+ if (prop === 'child') {
35
+ return (meta: Record<string, unknown>): Logger =>
36
+ bind({...bound, ...meta});
37
+ }
38
+ if (
39
+ typeof prop === 'string' &&
40
+ (LEVELS as readonly string[]).includes(prop)
41
+ ) {
42
+ const method = Reflect.get(target, prop, receiver) as LogMethod;
43
+ return (message: string, meta?: Record<string, unknown>): void => {
44
+ const merged = {...meta, ...bound};
45
+ if (Object.keys(merged).length === 0) {
46
+ method.call(target, message);
47
+ } else {
48
+ method.call(target, message, merged);
49
+ }
50
+ };
51
+ }
52
+ const passthrough: unknown = Reflect.get(target, prop, receiver);
53
+ return passthrough;
54
+ },
55
+ }) as Logger;
56
+
57
+ return bind({});
58
+ }
@@ -0,0 +1,23 @@
1
+ import type {Writable} from 'node:stream';
2
+
3
+ import {LEVELS} from './logger.mts';
4
+ import type {CoreLogger} from './logger.mts';
5
+
6
+ /**
7
+ * A logger sink that writes one line per call to a stream — bind it to stderr.
8
+ * The default `console` sink sends `log`, `info`, and `debug` to stdout, which
9
+ * `dispatch mcp` owns as its JSON-RPC channel: one diagnostic line there is a
10
+ * protocol error for the client parsing it.
11
+ */
12
+ export function streamSink(stream: Writable): CoreLogger {
13
+ const write = (message: string, meta?: Record<string, unknown>): void => {
14
+ stream.write(
15
+ meta === undefined
16
+ ? `${message}\n`
17
+ : `${message} ${JSON.stringify(meta)}\n`
18
+ );
19
+ };
20
+ const sink = {} as CoreLogger;
21
+ for (const level of LEVELS) sink[level] = write;
22
+ return sink;
23
+ }
@@ -0,0 +1,21 @@
1
+ # MCP
2
+
3
+ `runMcpServer({tree, stdin, stdout, stderr, env})` in `mcp.mts` serves the
4
+ command tree over newline-delimited JSON-RPC 2.0 on stdio — the sibling of
5
+ `lib/cli` for the MCP transport. stdout is the protocol channel; diagnostics go
6
+ to stderr. `index.mts` is the barrel.
7
+
8
+ - `tools.mts` — `buildTools(tree)` walks the tree into MCP tool defs (name =
9
+ `_`-joined path, `inputSchema` from `options`) plus a name -> command map,
10
+ skipping commands whose `mcp` transport is off.
11
+ - `dispatch.mts` — `callTool` runs one command with a capturing `io` (its output
12
+ is the result text); a `DispatchError` becomes an `isError` result.
13
+ - `channel.mts` — `ChannelWriter` frames `notifications/claude/channel` events:
14
+ monotonic `seq`, meta keys filtered to `^[a-zA-Z_][a-zA-Z0-9_]*$`, never a
15
+ `source` key (the runner sets that one).
16
+ - `drain.mts` — `drainInstructions` turns undelivered `fetch_request` rows and
17
+ owed completions into events, and records delivery in the database.
18
+
19
+ The loop throws `JsonRpcError` (in `lib/errors`) for protocol failures (unknown
20
+ method, malformed request, unknown tool) and renders it into a JSON-RPC `error`.
21
+ Tool failures are `isError` results, not protocol errors.
@@ -0,0 +1,41 @@
1
+ /** The runner drops any meta key outside this shape. */
2
+ const META_KEY = /^[a-zA-Z_][a-zA-Z0-9_]*$/u;
3
+
4
+ /**
5
+ * Pushes channel events into the session that spawned the server. `source` is
6
+ * the runner's own attribute — a second one on the tag would not override it,
7
+ * so this never sets one.
8
+ */
9
+ export class ChannelWriter {
10
+ readonly #emit: (payload: unknown) => void;
11
+ #seq = 0;
12
+
13
+ constructor(emit: (payload: unknown) => void) {
14
+ this.#emit = emit;
15
+ }
16
+
17
+ push(
18
+ kind: string,
19
+ meta: Readonly<Record<string, string | null>>,
20
+ content: string
21
+ ): void {
22
+ this.#seq += 1;
23
+ const params: Record<string, string> = {kind, seq: String(this.#seq)};
24
+ for (const [key, value] of Object.entries(meta)) {
25
+ if (value === null) continue;
26
+ if (key === 'source' || key === 'kind' || key === 'seq') continue;
27
+ if (!META_KEY.test(key)) continue;
28
+ // `meta` is typed `string | null`, but a caller's payload passed through
29
+ // JSON.parse behind an unchecked cast (`ScanPayload`/`TicketPayload`), so
30
+ // a numeric or boolean field can reach here as its native type despite
31
+ // what the type checker believes at this point.
32
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-conversion
33
+ params[key] = String(value);
34
+ }
35
+ this.#emit({
36
+ jsonrpc: '2.0',
37
+ method: 'notifications/claude/channel',
38
+ params: {content, meta: params},
39
+ });
40
+ }
41
+ }