@huseynsnmz/pi-suite 0.0.0-stage → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +28 -0
- package/LICENSE +21 -0
- package/README.md +88 -2
- package/SECURITY.md +5 -0
- package/package.json +112 -4
- package/packages/board/CHANGELOG.md +340 -0
- package/packages/board/CONTRIBUTING.md +7 -0
- package/packages/board/LICENSE +21 -0
- package/packages/board/README.md +88 -0
- package/packages/board/SECURITY.md +19 -0
- package/packages/board/docs/how-it-works.md +31 -0
- package/packages/board/docs/reference.md +278 -0
- package/packages/board/package.json +81 -0
- package/packages/board/runner/job-runner-legacy.mjs +68 -0
- package/packages/board/runner/job-runner.mjs +834 -0
- package/packages/board/runner/pty-runner-legacy.mjs +50 -0
- package/packages/board/runner/pty-runner.mjs +2175 -0
- package/packages/board/runner/state-coordinator.mjs +673 -0
- package/packages/board/runner/state-runner.mjs +203 -0
- package/packages/board/runner/title-runner.mjs +80 -0
- package/packages/board/src/commands/agent-board.ts +394 -0
- package/packages/board/src/commands/attach-decision.mjs +66 -0
- package/packages/board/src/commands/attach-flow.ts +251 -0
- package/packages/board/src/commands/bg.ts +103 -0
- package/packages/board/src/commands/board-model.ts +21 -0
- package/packages/board/src/core/atomic.mjs +184 -0
- package/packages/board/src/core/attach-delete.mjs +24 -0
- package/packages/board/src/core/attach-frame-proof.mjs +870 -0
- package/packages/board/src/core/attach-frame-reporter.mjs +648 -0
- package/packages/board/src/core/attach-frame-stream.mjs +266 -0
- package/packages/board/src/core/attach-view-proof.mjs +709 -0
- package/packages/board/src/core/auto-state.mjs +377 -0
- package/packages/board/src/core/background-runs.mjs +273 -0
- package/packages/board/src/core/code-refs-store.mjs +315 -0
- package/packages/board/src/core/code-refs.mjs +913 -0
- package/packages/board/src/core/config.mjs +77 -0
- package/packages/board/src/core/control-protocol.mjs +482 -0
- package/packages/board/src/core/coordinator-client.mjs +633 -0
- package/packages/board/src/core/coordinator-journal.mjs +282 -0
- package/packages/board/src/core/coordinator-protocol.mjs +14 -0
- package/packages/board/src/core/cwd-stats.mjs +114 -0
- package/packages/board/src/core/dashboard-render.mjs +10 -0
- package/packages/board/src/core/derive.mjs +117 -0
- package/packages/board/src/core/diagnostics.mjs +133 -0
- package/packages/board/src/core/double-press.mjs +26 -0
- package/packages/board/src/core/editor-state-reporter.mjs +112 -0
- package/packages/board/src/core/events.mjs +286 -0
- package/packages/board/src/core/evidence.mjs +245 -0
- package/packages/board/src/core/follow-up-queue.mjs +211 -0
- package/packages/board/src/core/foreground-preview-cache.mjs +117 -0
- package/packages/board/src/core/foreground-projection-cache.mjs +99 -0
- package/packages/board/src/core/frame-witness-update.mjs +35 -0
- package/packages/board/src/core/heuristics.mjs +313 -0
- package/packages/board/src/core/hold-input.mjs +27 -0
- package/packages/board/src/core/host-coordination.mjs +300 -0
- package/packages/board/src/core/host-crash.mjs +79 -0
- package/packages/board/src/core/host-probe.mjs +207 -0
- package/packages/board/src/core/host-protocol.mjs +73 -0
- package/packages/board/src/core/ids.mjs +35 -0
- package/packages/board/src/core/ime-cursor-coalesce.mjs +193 -0
- package/packages/board/src/core/invocation.mjs +43 -0
- package/packages/board/src/core/launch-options.mjs +418 -0
- package/packages/board/src/core/launch.mjs +157 -0
- package/packages/board/src/core/load-config.mjs +4 -0
- package/packages/board/src/core/locks.mjs +530 -0
- package/packages/board/src/core/paths.mjs +183 -0
- package/packages/board/src/core/pid.mjs +75 -0
- package/packages/board/src/core/prewarm-schedule.mjs +41 -0
- package/packages/board/src/core/process-identity-probe.mjs +228 -0
- package/packages/board/src/core/prompt-transport.mjs +13 -0
- package/packages/board/src/core/pty-attach-jiggle-controller.mjs +356 -0
- package/packages/board/src/core/pty-attach-jiggle-retry.mjs +109 -0
- package/packages/board/src/core/pty-attach-reconnect.mjs +61 -0
- package/packages/board/src/core/pty-attach-render.mjs +229 -0
- package/packages/board/src/core/pty-links.mjs +81 -0
- package/packages/board/src/core/pty-scroll.mjs +522 -0
- package/packages/board/src/core/pty-support.mjs +327 -0
- package/packages/board/src/core/repo.mjs +103 -0
- package/packages/board/src/core/rows.mjs +414 -0
- package/packages/board/src/core/screen-log-gc.mjs +198 -0
- package/packages/board/src/core/screen-log.mjs +160 -0
- package/packages/board/src/core/session-view.mjs +174 -0
- package/packages/board/src/core/slash-commands.mjs +52 -0
- package/packages/board/src/core/state-commands.mjs +757 -0
- package/packages/board/src/core/status-consistency.mjs +98 -0
- package/packages/board/src/core/steering-prompts.mjs +34 -0
- package/packages/board/src/core/steering.mjs +133 -0
- package/packages/board/src/core/stop-latch.mjs +41 -0
- package/packages/board/src/core/store.mjs +523 -0
- package/packages/board/src/core/suspend.mjs +36 -0
- package/packages/board/src/core/terminal-attach-client.mjs +981 -0
- package/packages/board/src/core/terminal-attach-protocol.mjs +460 -0
- package/packages/board/src/core/terminal-model.mjs +222 -0
- package/packages/board/src/core/terminal-query-sequences.mjs +239 -0
- package/packages/board/src/core/terminal-reply-guard.mjs +222 -0
- package/packages/board/src/core/terminal-snapshot.mjs +440 -0
- package/packages/board/src/core/test-knobs.mjs +28 -0
- package/packages/board/src/core/title.mjs +43 -0
- package/packages/board/src/core/types.mjs +414 -0
- package/packages/board/src/core/warm-host-sweeper.mjs +168 -0
- package/packages/board/src/core/worktree.mjs +65 -0
- package/packages/board/src/index.ts +303 -0
- package/packages/board/src/runtime/service.mjs +3222 -0
- package/packages/board/src/ui/chrome.ts +20 -0
- package/packages/board/src/ui/dashboard-decisions.mjs +55 -0
- package/packages/board/src/ui/dashboard-evidence.mjs +85 -0
- package/packages/board/src/ui/dashboard.ts +2237 -0
- package/packages/board/src/ui/double-left.ts +30 -0
- package/packages/board/src/ui/external-editor.ts +25 -0
- package/packages/board/src/ui/focus.ts +32 -0
- package/packages/board/src/ui/pty-attach.ts +3184 -0
- package/packages/board/src/ui/waiting.ts +30 -0
- package/packages/commands/LICENSE +21 -0
- package/packages/commands/README.md +19 -0
- package/packages/commands/package.json +22 -0
- package/packages/commands/src/index.ts +43 -0
- package/packages/commands/src/security-review.ts +109 -0
- package/packages/frame/CHANGELOG.md +113 -0
- package/packages/frame/CONTRIBUTING.md +7 -0
- package/packages/frame/LICENSE +22 -0
- package/packages/frame/README.md +105 -0
- package/packages/frame/SECURITY.md +16 -0
- package/packages/frame/docs/how-it-works.md +53 -0
- package/packages/frame/docs/reference.md +78 -0
- package/packages/frame/package.json +72 -0
- package/packages/frame/src/data/git.ts +90 -0
- package/packages/frame/src/data/github.ts +140 -0
- package/packages/frame/src/data/gitlab.ts +63 -0
- package/packages/frame/src/data/mode.ts +49 -0
- package/packages/frame/src/data/past-prompts.ts +57 -0
- package/packages/frame/src/data/runner.ts +32 -0
- package/packages/frame/src/data/snapshot.ts +223 -0
- package/packages/frame/src/data/start-notes.ts +104 -0
- package/packages/frame/src/data/telemetry.ts +337 -0
- package/packages/frame/src/data/todos.ts +162 -0
- package/packages/frame/src/data/totals.ts +94 -0
- package/packages/frame/src/data/visits.ts +45 -0
- package/packages/frame/src/index.ts +696 -0
- package/packages/frame/src/kit/LICENSE +21 -0
- package/packages/frame/src/kit/app/attribution.ts +30 -0
- package/packages/frame/src/kit/app/builder.ts +186 -0
- package/packages/frame/src/kit/app/claim.ts +27 -0
- package/packages/frame/src/kit/app/feature.ts +91 -0
- package/packages/frame/src/kit/app/host.ts +214 -0
- package/packages/frame/src/kit/app/renderers.ts +62 -0
- package/packages/frame/src/kit/app/scope.ts +125 -0
- package/packages/frame/src/kit/board/model.ts +337 -0
- package/packages/frame/src/kit/contract.ts +79 -0
- package/packages/frame/src/kit/contracts/accounts.ts +33 -0
- package/packages/frame/src/kit/contracts/ask.ts +120 -0
- package/packages/frame/src/kit/contracts/board.ts +6 -0
- package/packages/frame/src/kit/contracts/by.ts +22 -0
- package/packages/frame/src/kit/contracts/calls.ts +46 -0
- package/packages/frame/src/kit/contracts/events.ts +89 -0
- package/packages/frame/src/kit/contracts/mode.ts +26 -0
- package/packages/frame/src/kit/contracts/names.ts +12 -0
- package/packages/frame/src/kit/contracts/peers.ts +77 -0
- package/packages/frame/src/kit/contracts/prompts.ts +8 -0
- package/packages/frame/src/kit/contracts/screen.ts +75 -0
- package/packages/frame/src/kit/contracts/skills.ts +81 -0
- package/packages/frame/src/kit/contracts/sources.ts +81 -0
- package/packages/frame/src/kit/contracts/views.ts +42 -0
- package/packages/frame/src/kit/control.ts +59 -0
- package/packages/frame/src/kit/dark-theme.ts +41 -0
- package/packages/frame/src/kit/decode.ts +210 -0
- package/packages/frame/src/kit/events/log.ts +313 -0
- package/packages/frame/src/kit/format.ts +25 -0
- package/packages/frame/src/kit/host.ts +4 -0
- package/packages/frame/src/kit/index.ts +25 -0
- package/packages/frame/src/kit/legacy-tasks.ts +39 -0
- package/packages/frame/src/kit/overlay-images.ts +36 -0
- package/packages/frame/src/kit/paths.ts +16 -0
- package/packages/frame/src/kit/screen/client.ts +74 -0
- package/packages/frame/src/kit/screen/feature.ts +64 -0
- package/packages/frame/src/kit/screen/model.ts +182 -0
- package/packages/frame/src/kit/screen/view.ts +838 -0
- package/packages/frame/src/kit/settings/files.ts +90 -0
- package/packages/frame/src/kit/settings/setting.ts +57 -0
- package/packages/frame/src/kit/settings/store.ts +192 -0
- package/packages/frame/src/kit/sources/file.ts +129 -0
- package/packages/frame/src/kit/sources/follow.ts +155 -0
- package/packages/frame/src/kit/testing.ts +260 -0
- package/packages/frame/src/kit/totals.ts +72 -0
- package/packages/frame/src/kit/ui/entry.ts +75 -0
- package/packages/frame/src/kit/ui/frame.ts +212 -0
- package/packages/frame/src/kit/ui/glyphs.ts +132 -0
- package/packages/frame/src/kit/ui/scroll.ts +14 -0
- package/packages/frame/src/render/diff.ts +157 -0
- package/packages/frame/src/render/fit.ts +86 -0
- package/packages/frame/src/render/format.ts +108 -0
- package/packages/frame/src/render/frame.ts +106 -0
- package/packages/frame/src/render/glyphs.ts +189 -0
- package/packages/frame/src/render/keys.ts +43 -0
- package/packages/frame/src/render/lead.ts +37 -0
- package/packages/frame/src/render/paint.ts +135 -0
- package/packages/frame/src/render/safe.ts +19 -0
- package/packages/frame/src/render/segments.ts +735 -0
- package/packages/frame/src/render/streaming.ts +223 -0
- package/packages/frame/src/render/timing.ts +106 -0
- package/packages/frame/src/render/tools.ts +167 -0
- package/packages/frame/src/settings.ts +105 -0
- package/packages/frame/src/ui/editor.ts +272 -0
- package/packages/frame/src/ui/effort.ts +36 -0
- package/packages/frame/src/ui/footer.ts +165 -0
- package/packages/frame/src/ui/header.ts +362 -0
- package/packages/frame/src/ui/history-search.ts +23 -0
- package/packages/frame/src/ui/interrupt.ts +21 -0
- package/packages/frame/src/ui/outputs.ts +170 -0
- package/packages/frame/src/ui/pi-entries.ts +298 -0
- package/packages/frame/src/ui/previews.ts +72 -0
- package/packages/frame/src/ui/queue.ts +75 -0
- package/packages/frame/src/ui/screen.ts +59 -0
- package/packages/frame/src/ui/timings.ts +48 -0
- package/packages/frame/src/ui/todos.ts +205 -0
- package/packages/frame/src/ui/tool-frame.ts +595 -0
- package/packages/frame/src/ui/working.ts +25 -0
- package/packages/frame/src/view/state.ts +97 -0
- package/packages/frame/src/view/transcript.ts +175 -0
- package/packages/frame/src/view/view.ts +162 -0
- package/packages/memory/CONTRIBUTING.md +7 -0
- package/packages/memory/LICENSE +21 -0
- package/packages/memory/README.md +44 -0
- package/packages/memory/SECURITY.md +20 -0
- package/packages/memory/package.json +55 -0
- package/packages/memory/skills/review-memory/SKILL.md +48 -0
- package/packages/memory/src/index.ts +163 -0
- package/packages/memory/src/prompt.ts +25 -0
- package/packages/memory/src/review.ts +150 -0
- package/packages/memory/src/secrets.ts +22 -0
- package/packages/memory/src/store.ts +173 -0
- package/packages/modes/CHANGELOG.md +104 -0
- package/packages/modes/CONTRIBUTING.md +7 -0
- package/packages/modes/LICENSE +21 -0
- package/packages/modes/README.md +174 -0
- package/packages/modes/SECURITY.md +17 -0
- package/packages/modes/package.json +55 -0
- package/packages/modes/skills/review-permissions/SKILL.md +54 -0
- package/packages/modes/src/bash.ts +218 -0
- package/packages/modes/src/deny-off.ts +62 -0
- package/packages/modes/src/git-safety.ts +72 -0
- package/packages/modes/src/index.ts +338 -0
- package/packages/modes/src/interview.ts +206 -0
- package/packages/modes/src/judge-gate.ts +113 -0
- package/packages/modes/src/modes.ts +121 -0
- package/packages/modes/src/review/apply.mjs +156 -0
- package/packages/modes/src/review/collect.mjs +208 -0
- package/packages/modes/src/review/tools.ts +71 -0
- package/packages/modes/src/sandbox.ts +37 -0
- package/packages/router/CHANGELOG.md +8 -0
- package/packages/router/LICENSE +21 -0
- package/packages/router/README.md +90 -0
- package/packages/router/package.json +51 -0
- package/packages/router/src/config.ts +65 -0
- package/packages/router/src/index.ts +172 -0
- package/packages/router/src/judge.ts +51 -0
- package/packages/router/src/route.ts +67 -0
- package/packages/web/CHANGELOG.md +36 -0
- package/packages/web/LICENSE +21 -0
- package/packages/web/README.md +87 -0
- package/packages/web/SECURITY.md +13 -0
- package/packages/web/package.json +59 -0
- package/packages/web/src/config.ts +109 -0
- package/packages/web/src/dnsfilter.ts +97 -0
- package/packages/web/src/extract.ts +65 -0
- package/packages/web/src/index.ts +217 -0
- package/packages/web/src/markdown.ts +122 -0
- package/packages/web/src/net.ts +109 -0
- package/packages/web/src/reader.ts +73 -0
- package/packages/web/src/sanitize.ts +50 -0
- package/packages/web/src/search.ts +76 -0
- package/packages/web/src/secrets.ts +22 -0
- package/packages/web/src/validate.ts +186 -0
- package/src/index.ts +156 -0
- package/src/settings.ts +79 -0
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure text heuristics: extracting assistant text, detecting questions/blockers,
|
|
3
|
+
* summarizing tool calls, and formatting relative times. No I/O, fully unit-tested.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Join the text content blocks of an assistant message into a single string.
|
|
8
|
+
* Accepts the message object as emitted in JSON mode (`message.content` is an array
|
|
9
|
+
* of `{type,...}` blocks, or, defensively, a plain string).
|
|
10
|
+
* @param {any} message
|
|
11
|
+
* @returns {string}
|
|
12
|
+
*/
|
|
13
|
+
export function assistantText(message) {
|
|
14
|
+
if (!message) return "";
|
|
15
|
+
const content = message.content;
|
|
16
|
+
if (typeof content === "string") return content;
|
|
17
|
+
if (!Array.isArray(content)) return "";
|
|
18
|
+
return content
|
|
19
|
+
.filter((b) => b && b.type === "text" && typeof b.text === "string")
|
|
20
|
+
.map((b) => b.text)
|
|
21
|
+
.join("\n")
|
|
22
|
+
.trim();
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Extract text from a pi AgentToolResult object
|
|
27
|
+
* ({ content: [{ type: "text", text }, ...], details, ... }), or, defensively,
|
|
28
|
+
* a plain string. Unknown-shaped objects yield "" — never "[object Object]"
|
|
29
|
+
* (issue #41; mirrors pi's own convertToolResultOutput join("\n") semantics).
|
|
30
|
+
* @param {any} result
|
|
31
|
+
* @returns {string}
|
|
32
|
+
*/
|
|
33
|
+
export function toolResultText(result) {
|
|
34
|
+
if (result == null) return "";
|
|
35
|
+
if (typeof result === "string") return result;
|
|
36
|
+
if (typeof result !== "object") return String(result);
|
|
37
|
+
const content = result.content;
|
|
38
|
+
if (!Array.isArray(content)) return "";
|
|
39
|
+
return content
|
|
40
|
+
.filter((b) => b && b.type === "text" && typeof b.text === "string")
|
|
41
|
+
.map((b) => b.text)
|
|
42
|
+
.join("\n")
|
|
43
|
+
.trim();
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Collect tool-call blocks from an assistant message.
|
|
48
|
+
* @param {any} message
|
|
49
|
+
* @returns {Array<{name:string, arguments:Record<string,any>}>}
|
|
50
|
+
*/
|
|
51
|
+
export function toolCalls(message) {
|
|
52
|
+
const content = message?.content;
|
|
53
|
+
if (!Array.isArray(content)) return [];
|
|
54
|
+
return content
|
|
55
|
+
.filter((b) => b && b.type === "toolCall")
|
|
56
|
+
.map((b) => ({ name: b.name, arguments: b.arguments ?? {} }));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const QUESTION_PHRASES = [
|
|
60
|
+
"need your input",
|
|
61
|
+
"need some input",
|
|
62
|
+
"which option",
|
|
63
|
+
"should i",
|
|
64
|
+
"shall i",
|
|
65
|
+
"please confirm",
|
|
66
|
+
"could you confirm",
|
|
67
|
+
"can you confirm",
|
|
68
|
+
"let me know",
|
|
69
|
+
"do you want",
|
|
70
|
+
"would you like",
|
|
71
|
+
"how would you like",
|
|
72
|
+
"what would you like",
|
|
73
|
+
"which one",
|
|
74
|
+
"please clarify",
|
|
75
|
+
"can you clarify",
|
|
76
|
+
"waiting for your",
|
|
77
|
+
];
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Decide whether the latest assistant text is asking the user a question / is blocked,
|
|
81
|
+
* and extract the question sentence if so.
|
|
82
|
+
* @param {string} text
|
|
83
|
+
* @returns {{ needsInput: boolean, question: string|null }}
|
|
84
|
+
*/
|
|
85
|
+
export function detectNeedsInput(text) {
|
|
86
|
+
const trimmed = (text || "").trim();
|
|
87
|
+
if (!trimmed) return { needsInput: false, question: null };
|
|
88
|
+
|
|
89
|
+
const lower = trimmed.toLowerCase();
|
|
90
|
+
const sentences = splitSentences(trimmed);
|
|
91
|
+
const last = sentences[sentences.length - 1] ?? trimmed;
|
|
92
|
+
|
|
93
|
+
// Strongest signal: the message ends on a question mark.
|
|
94
|
+
if (/\?\s*$/.test(trimmed)) {
|
|
95
|
+
const q = [...sentences].reverse().find((s) => s.includes("?")) ?? last;
|
|
96
|
+
return { needsInput: true, question: q.trim() };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
for (const phrase of QUESTION_PHRASES) {
|
|
100
|
+
if (lower.includes(phrase)) {
|
|
101
|
+
const hit = sentences.find((s) => s.toLowerCase().includes(phrase)) ?? last;
|
|
102
|
+
return { needsInput: true, question: hit.trim() };
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return { needsInput: false, question: null };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Split text into sentences (rough; good enough for previews/blocker extraction).
|
|
111
|
+
* @param {string} text
|
|
112
|
+
* @returns {string[]}
|
|
113
|
+
*/
|
|
114
|
+
export function splitSentences(text) {
|
|
115
|
+
return (text || "")
|
|
116
|
+
.replace(/\s+/g, " ")
|
|
117
|
+
.trim()
|
|
118
|
+
.split(/(?<=[.!?])\s+/)
|
|
119
|
+
.map((s) => s.trim())
|
|
120
|
+
.filter(Boolean);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* First sentence (or first line) of an assistant message, for compact previews.
|
|
125
|
+
* @param {string} text
|
|
126
|
+
* @returns {string}
|
|
127
|
+
*/
|
|
128
|
+
export function firstSentence(text) {
|
|
129
|
+
const sentences = splitSentences(text);
|
|
130
|
+
return sentences[0] ?? "";
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const PATH_KEYS = ["file_path", "path", "filePath", "file", "filename"];
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Best-effort target path from a tool call's arguments.
|
|
137
|
+
* @param {Record<string,any>} args
|
|
138
|
+
* @returns {string|null}
|
|
139
|
+
*/
|
|
140
|
+
export function toolPath(args) {
|
|
141
|
+
if (!args || typeof args !== "object") return null;
|
|
142
|
+
for (const k of PATH_KEYS) {
|
|
143
|
+
if (typeof args[k] === "string" && args[k]) return args[k];
|
|
144
|
+
}
|
|
145
|
+
if (typeof args.pattern === "string" && args.pattern) return args.pattern;
|
|
146
|
+
return null;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Human one-liner for an in-progress tool call, e.g. "Editing src/auth.ts".
|
|
151
|
+
* @param {string} name
|
|
152
|
+
* @param {Record<string,any>} args
|
|
153
|
+
* @returns {string}
|
|
154
|
+
*/
|
|
155
|
+
export function toolSummary(name, args) {
|
|
156
|
+
const p = toolPath(args);
|
|
157
|
+
const base = p ? baseName(p) : null;
|
|
158
|
+
switch (name) {
|
|
159
|
+
case "edit":
|
|
160
|
+
return base ? `Editing ${base}` : "Editing files";
|
|
161
|
+
case "write":
|
|
162
|
+
return base ? `Writing ${base}` : "Writing files";
|
|
163
|
+
case "read":
|
|
164
|
+
return base ? `Reading ${base}` : "Reading files";
|
|
165
|
+
case "ls":
|
|
166
|
+
return base ? `Listing ${base}` : "Listing files";
|
|
167
|
+
case "find":
|
|
168
|
+
return p ? `Finding ${p}` : "Finding files";
|
|
169
|
+
case "grep":
|
|
170
|
+
return p ? `Searching /${p}/` : "Searching";
|
|
171
|
+
case "bash": {
|
|
172
|
+
const cmd = typeof args?.command === "string" ? args.command.trim() : "";
|
|
173
|
+
if (!cmd) return "Running command";
|
|
174
|
+
const kind = classifyCommand(cmd);
|
|
175
|
+
if (kind === "test") return "Running tests";
|
|
176
|
+
if (kind === "build") return "Building";
|
|
177
|
+
if (kind === "lint") return "Linting";
|
|
178
|
+
if (kind === "git") return `git ${cmd.replace(/^git\s+/, "").split(/\s+/)[0] ?? ""}`.trim();
|
|
179
|
+
return `Running ${truncate(cmd, 32)}`;
|
|
180
|
+
}
|
|
181
|
+
default:
|
|
182
|
+
return base ? `${capitalize(name)} ${base}` : capitalize(name);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Classify a shell command for review evidence.
|
|
188
|
+
* @param {string} command
|
|
189
|
+
* @returns {"test"|"build"|"lint"|"git"|"install"|"other"}
|
|
190
|
+
*/
|
|
191
|
+
export function classifyCommand(command) {
|
|
192
|
+
const cmd = String(command || "").trim();
|
|
193
|
+
if (!cmd) return "other";
|
|
194
|
+
if (/\b(test|vitest|jest|pytest|go test|cargo test|npm test|pnpm test|yarn test)\b/.test(cmd)) return "test";
|
|
195
|
+
if (/\b(build|tsc|webpack|vite build|make|cargo build|go build)\b/.test(cmd)) return "build";
|
|
196
|
+
if (/\b(lint|eslint|biome|ruff|clippy)\b/.test(cmd)) return "lint";
|
|
197
|
+
if (/^(git|gh)\b/.test(cmd)) return "git";
|
|
198
|
+
if (/\b(npm install|npm ci|pnpm install|yarn install|bun install)\b/.test(cmd)) return "install";
|
|
199
|
+
return "other";
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** @param {string} command @param {number} [n] */
|
|
203
|
+
export function commandPreview(command, n = 120) {
|
|
204
|
+
return truncate(String(command || "").replace(/\s+/g, " ").trim(), n);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Detect file mutation operations from tool name/args.
|
|
209
|
+
* @param {string} name
|
|
210
|
+
* @param {Record<string,any>} args
|
|
211
|
+
* @returns {{ path:string, action:"edited"|"written"|"deleted"|"unknown" }|null}
|
|
212
|
+
*/
|
|
213
|
+
export function toolFileOperation(name, args) {
|
|
214
|
+
const p = toolPath(args);
|
|
215
|
+
if (!p) return null;
|
|
216
|
+
if (["edit", "multi_edit", "apply_patch"].includes(name)) return { path: p, action: "edited" };
|
|
217
|
+
if (["write", "create_file"].includes(name)) return { path: p, action: "written" };
|
|
218
|
+
if (["delete", "rm", "remove_file"].includes(name)) return { path: p, action: "deleted" };
|
|
219
|
+
return null;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** @param {string} p */
|
|
223
|
+
export function baseName(p) {
|
|
224
|
+
const parts = String(p).split(/[\\/]/).filter(Boolean);
|
|
225
|
+
return parts[parts.length - 1] ?? p;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** @param {string} s */
|
|
229
|
+
function capitalize(s) {
|
|
230
|
+
return s ? s.charAt(0).toUpperCase() + s.slice(1) : s;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const LONE_HIGH_SURROGATE_RE = /[\uD800-\uDBFF](?![\uDC00-\uDFFF])/g;
|
|
234
|
+
const LONE_LOW_SURROGATE_RE = /(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/g;
|
|
235
|
+
|
|
236
|
+
/** @param {string} s */
|
|
237
|
+
function stripLoneSurrogates(s) {
|
|
238
|
+
if (!s) return s;
|
|
239
|
+
return s.replace(LONE_HIGH_SURROGATE_RE, "").replace(LONE_LOW_SURROGATE_RE, "");
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Truncate to `n` chars with an ellipsis (counts characters, not display width).
|
|
244
|
+
* The budget `n` is in UTF-16 units. The cut never splits a surrogate pair
|
|
245
|
+
* (it backs off one unit when it would), and lone surrogates in the input are
|
|
246
|
+
* stripped from the result — a lone surrogate renders as U+FFFD, whose
|
|
247
|
+
* terminal width can disagree with the computed width and misalign rows.
|
|
248
|
+
* @param {string} s
|
|
249
|
+
* @param {number} n
|
|
250
|
+
* @returns {string}
|
|
251
|
+
*/
|
|
252
|
+
export function truncate(s, n) {
|
|
253
|
+
const str = String(s ?? "");
|
|
254
|
+
if (str.length <= n) return stripLoneSurrogates(str);
|
|
255
|
+
let end = Math.max(0, n - 1);
|
|
256
|
+
const lastUnit = str.charCodeAt(end - 1);
|
|
257
|
+
const nextUnit = str.charCodeAt(end);
|
|
258
|
+
if (lastUnit >= 0xd800 && lastUnit <= 0xdbff && nextUnit >= 0xdc00 && nextUnit <= 0xdfff) end -= 1;
|
|
259
|
+
return `${stripLoneSurrogates(str.slice(0, end))}…`;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
const OSC_SEQUENCE_RE = /\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g;
|
|
263
|
+
const CSI_SEQUENCE_RE = /\x1b\[[0-9;?]*[ -/]*[@-~]/g;
|
|
264
|
+
const OTHER_ESCAPE_RE = /\x1b./g;
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Last non-empty visible line of a raw terminal log chunk: strips OSC/CSI/ESC
|
|
268
|
+
* escape sequences, resolves per-line carriage-return overwrites (progress
|
|
269
|
+
* bars / boot spinners), and returns the final remaining line truncated to
|
|
270
|
+
* `maxLen`. Used to attribute a failed child's exit reason from screen.log
|
|
271
|
+
* into host.json (issue #90). Returns null for empty/all-invisible input.
|
|
272
|
+
* @param {string|null|undefined} text
|
|
273
|
+
* @param {number} [maxLen]
|
|
274
|
+
* @returns {string|null}
|
|
275
|
+
*/
|
|
276
|
+
export function lastVisibleLogLine(text, maxLen = 200) {
|
|
277
|
+
if (!text) return null;
|
|
278
|
+
const visible = String(text)
|
|
279
|
+
.replace(OSC_SEQUENCE_RE, "")
|
|
280
|
+
.replace(CSI_SEQUENCE_RE, "")
|
|
281
|
+
.replace(OTHER_ESCAPE_RE, "")
|
|
282
|
+
// A \r directly before \n is a line terminator (CRLF), not an overwrite
|
|
283
|
+
// marker — normalize it away so the line content survives.
|
|
284
|
+
.replace(/\r\n/g, "\n");
|
|
285
|
+
const lines = visible.split("\n");
|
|
286
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
287
|
+
// Trailing \r chars are line-terminator junk (PTY ONLCR + the program's own
|
|
288
|
+
// CRLF stack up as \r\r\n) — drop them, then resolve interior \r overwrites.
|
|
289
|
+
const cleaned = lines[i].replace(/\r+$/, "");
|
|
290
|
+
const cr = cleaned.lastIndexOf("\r");
|
|
291
|
+
const line = (cr >= 0 ? cleaned.slice(cr + 1) : cleaned).trim();
|
|
292
|
+
if (line) return truncate(line, maxLen);
|
|
293
|
+
}
|
|
294
|
+
return null;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* Compact relative age, e.g. "10s", "2m", "3h", "4d".
|
|
299
|
+
* @param {number} fromMs
|
|
300
|
+
* @param {number} [nowMs]
|
|
301
|
+
* @returns {string}
|
|
302
|
+
*/
|
|
303
|
+
export function relativeTime(fromMs, nowMs = Date.now()) {
|
|
304
|
+
const diff = Math.max(0, nowMs - fromMs);
|
|
305
|
+
const s = Math.floor(diff / 1000);
|
|
306
|
+
if (s < 60) return `${s}s`;
|
|
307
|
+
const m = Math.floor(s / 60);
|
|
308
|
+
if (m < 60) return `${m}m`;
|
|
309
|
+
const h = Math.floor(m / 60);
|
|
310
|
+
if (h < 24) return `${h}h`;
|
|
311
|
+
const d = Math.floor(h / 24);
|
|
312
|
+
return `${d}d`;
|
|
313
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* While the board or an attached session is on screen, it owns the keyboard. Pi offers extensions'
|
|
3
|
+
* key listeners every key first, so without this a listener in the Pi behind the overlay can take a
|
|
4
|
+
* key: pi-modes' Shift+Tab, for one, changed that Pi's permission mode and never reached the session.
|
|
5
|
+
*
|
|
6
|
+
* Fullscreen Pi's mouse handling is a listener too, and has to keep running. It's added when the TUI
|
|
7
|
+
* is built, so it's first in the set. Listeners added or removed meanwhile, as a session change does,
|
|
8
|
+
* still apply once released. Does nothing if pi-tui changes shape, which leaves today's behavior.
|
|
9
|
+
*
|
|
10
|
+
* @param {any} tui
|
|
11
|
+
* @returns {() => void} gives the keys back
|
|
12
|
+
*/
|
|
13
|
+
export function holdInputListeners(tui) {
|
|
14
|
+
const listeners = tui?.inputListeners;
|
|
15
|
+
if (!(listeners instanceof Set)) return () => {};
|
|
16
|
+
const kept = typeof tui.handleViewportInput === "function" ? [...listeners].slice(0, 1) : [];
|
|
17
|
+
const held = {
|
|
18
|
+
size: kept.length,
|
|
19
|
+
add: (listener) => listeners.add(listener),
|
|
20
|
+
delete: (listener) => listeners.delete(listener),
|
|
21
|
+
[Symbol.iterator]: () => kept[Symbol.iterator](),
|
|
22
|
+
};
|
|
23
|
+
tui.inputListeners = held;
|
|
24
|
+
return () => {
|
|
25
|
+
if (tui.inputListeners === held) tui.inputListeners = listeners;
|
|
26
|
+
};
|
|
27
|
+
}
|
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure host-lifecycle decision functions (issue #70).
|
|
3
|
+
*
|
|
4
|
+
* Every function here takes plain snapshots (HostStatus-shaped objects, probe
|
|
5
|
+
* results, process observations) and returns a decision — no fs/net/process
|
|
6
|
+
* I/O. Callers (locks, store, service, runner) own all side effects; this
|
|
7
|
+
* module is the single source of truth for the coordination rules so they can
|
|
8
|
+
* be unit-tested exhaustively.
|
|
9
|
+
*
|
|
10
|
+
* @typedef {import("./types.mjs").HostStatus} HostStatus
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Whether `host` is owned by the given fencing token. A null/missing
|
|
15
|
+
* instanceId never matches — legacy hosts have no owner token.
|
|
16
|
+
* @param {HostStatus|null|undefined} host
|
|
17
|
+
* @param {string|null|undefined} expectedInstanceId
|
|
18
|
+
* @returns {boolean}
|
|
19
|
+
*/
|
|
20
|
+
export function sameHostOwner(host, expectedInstanceId) {
|
|
21
|
+
if (!host || !expectedInstanceId) return false;
|
|
22
|
+
return host.instanceId === expectedInstanceId;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Whether a `starting` claim is still inside the launch grace window.
|
|
27
|
+
* A missing runnerPid or socket does NOT make a fresh claim stale (issue #70:
|
|
28
|
+
* provisional claims are normal while the runner boots).
|
|
29
|
+
* @param {HostStatus|null|undefined} host
|
|
30
|
+
* @param {number} now Epoch ms.
|
|
31
|
+
* @param {number} graceMs
|
|
32
|
+
* @returns {boolean}
|
|
33
|
+
*/
|
|
34
|
+
export function isStartingWithinGrace(host, now, graceMs) {
|
|
35
|
+
if (!host || host.state !== "starting" || host.claimAt == null) return false;
|
|
36
|
+
return now - host.claimAt < graceMs;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Classify a recorded process identity against a live observation.
|
|
41
|
+
* Five states — `not_started` (never spawned) must never be conflated with
|
|
42
|
+
* `dead`, and a missing stable token must never be guessed as alive-owned:
|
|
43
|
+
* it degrades to `unknown` so recovery can only refuse, never misfire.
|
|
44
|
+
*
|
|
45
|
+
* `not_started` requires BOTH the identity and `spawnedAt` to be absent: a
|
|
46
|
+
* recorded identity is proof the spawn happened, so such a record is judged by
|
|
47
|
+
* the observation. Calling it `not_started` (a releasable state) on a
|
|
48
|
+
* spawned-but-incomplete record could release a still-live host and let a new
|
|
49
|
+
* host double-write the session file.
|
|
50
|
+
* @param {{pid:number,startToken:string|null}|null|undefined} identity The recorded identity.
|
|
51
|
+
* @param {{alive:boolean,startToken:string|null}|null|undefined} observed The observation.
|
|
52
|
+
* @param {number|null|undefined} spawnedAt Non-null only after spawn was confirmed.
|
|
53
|
+
* @returns {"not_started"|"dead"|"owned"|"foreign"|"unknown"}
|
|
54
|
+
*/
|
|
55
|
+
export function processIdentityState(identity, observed, spawnedAt) {
|
|
56
|
+
if (spawnedAt == null && identity == null) return "not_started";
|
|
57
|
+
if (observed?.alive === false) return "dead";
|
|
58
|
+
if (identity?.startToken == null || observed?.startToken == null) return "unknown";
|
|
59
|
+
return identity.startToken === observed.startToken ? "owned" : "foreign";
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Characters that may abut a path fragment in a command line without being part of it. */
|
|
63
|
+
const COMMAND_LINE_BOUNDARIES = /[\s"']/;
|
|
64
|
+
|
|
65
|
+
/** Windows path semantics: separators and case are not significant. */
|
|
66
|
+
function normalizeCommandLine(text) {
|
|
67
|
+
return text.replace(/\\/g, "/").toLowerCase();
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function isCommandLineBoundary(text, index, length) {
|
|
71
|
+
const before = index === 0 ? null : text[index - 1];
|
|
72
|
+
const after = index + length >= text.length ? null : text[index + length];
|
|
73
|
+
return (before == null || COMMAND_LINE_BOUNDARIES.test(before)) && (after == null || COMMAND_LINE_BOUNDARIES.test(after));
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Whether a process command line mentions `fragment` as a whole path, not as a
|
|
78
|
+
* prefix of a longer one (`view_abc` must not match `view_abcd`, nor
|
|
79
|
+
* `session.jsonl` match `session-2.jsonl`). Comparison is slash- and
|
|
80
|
+
* case-insensitive because Windows reports `C:\x\y` inside quotes while the
|
|
81
|
+
* recorded fragment is a plain path (issue #217). Empty/missing input is never
|
|
82
|
+
* a match: a blank fragment would otherwise corroborate any process.
|
|
83
|
+
* @param {string|null|undefined} commandLine
|
|
84
|
+
* @param {string|null|undefined} fragment
|
|
85
|
+
* @returns {boolean}
|
|
86
|
+
*/
|
|
87
|
+
export function commandLineMentions(commandLine, fragment) {
|
|
88
|
+
if (!commandLine || !fragment) return false;
|
|
89
|
+
const haystack = normalizeCommandLine(commandLine);
|
|
90
|
+
const needle = normalizeCommandLine(fragment);
|
|
91
|
+
let index = haystack.indexOf(needle);
|
|
92
|
+
while (index !== -1) {
|
|
93
|
+
if (isCommandLineBoundary(haystack, index, needle.length)) return true;
|
|
94
|
+
index = haystack.indexOf(needle, index + 1);
|
|
95
|
+
}
|
|
96
|
+
return false;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Refine a host-role observation that the token path could only call `unknown`
|
|
101
|
+
* (platforms without a start token) using deterministic probe evidence
|
|
102
|
+
* (issue #217). The command line is authoritative because every host role we
|
|
103
|
+
* spawn carries its own unique path argument; process creation time only
|
|
104
|
+
* corroborates when the command line is unreadable, and the tolerance absorbs
|
|
105
|
+
* spawn→record skew. A missing probe stays `unknown` — the #70 contract: an
|
|
106
|
+
* unverifiable process is never guessed as foreign.
|
|
107
|
+
* @param {{
|
|
108
|
+
* spawnedAt: number|null,
|
|
109
|
+
* probe: {creationTimeMs: number|null, commandLine: string|null}|null,
|
|
110
|
+
* expectedCommandFragment: string|null,
|
|
111
|
+
* toleranceMs: number,
|
|
112
|
+
* }} input
|
|
113
|
+
* @returns {"owned"|"foreign"|"unknown"}
|
|
114
|
+
*/
|
|
115
|
+
export function classifyUnverifiableProcess({ spawnedAt, probe, expectedCommandFragment, toleranceMs }) {
|
|
116
|
+
if (!probe) return "unknown";
|
|
117
|
+
if (probe.commandLine && expectedCommandFragment) {
|
|
118
|
+
return commandLineMentions(probe.commandLine, expectedCommandFragment) ? "owned" : "foreign";
|
|
119
|
+
}
|
|
120
|
+
if (spawnedAt == null || probe.creationTimeMs == null) return "unknown";
|
|
121
|
+
return Math.abs(probe.creationTimeMs - spawnedAt) <= toleranceMs ? "owned" : "foreign";
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Role observations that mean "no live process can belong to the old instance". */
|
|
125
|
+
const SAFE_TO_RELEASE = new Set(["not_started", "dead", "foreign"]);
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Whether an exited/failed host can be replaced by a new claim. The runner and
|
|
129
|
+
* child roles must be provably gone; any `unknown` observation or an active
|
|
130
|
+
* launch lease blocks replacement. The claim role does NOT participate: claim
|
|
131
|
+
* protection (a launcher mid-transaction between claim and spawn) only matters
|
|
132
|
+
* while the host is `starting`, and this gate only ever sees terminal hosts —
|
|
133
|
+
* a terminal host cannot still be being launched (issue #99: a live claimPid —
|
|
134
|
+
* the dashboard process that wrote the claim — must not block re-attach).
|
|
135
|
+
* @param {{
|
|
136
|
+
* host: HostStatus|null|undefined,
|
|
137
|
+
* runnerObservation: string,
|
|
138
|
+
* childObservation: string,
|
|
139
|
+
* launchLeaseActive: boolean,
|
|
140
|
+
* }} input
|
|
141
|
+
* @returns {boolean}
|
|
142
|
+
*/
|
|
143
|
+
export function canReplaceHost({ host, runnerObservation, childObservation, launchLeaseActive }) {
|
|
144
|
+
if (!host || (host.state !== "exited" && host.state !== "failed")) return false;
|
|
145
|
+
if (launchLeaseActive) return false;
|
|
146
|
+
return (
|
|
147
|
+
SAFE_TO_RELEASE.has(runnerObservation) &&
|
|
148
|
+
SAFE_TO_RELEASE.has(childObservation)
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Whether the endpoint file at `current` is the one this instance bound.
|
|
154
|
+
* Compares dev+ino so a replaced (rebound) socket path is never unlinked by
|
|
155
|
+
* its former owner.
|
|
156
|
+
* @param {{dev:number,ino:number}|null|undefined} bound Identity recorded at bind time.
|
|
157
|
+
* @param {{dev:number,ino:number}|null|undefined} current Identity observed at cleanup time.
|
|
158
|
+
* @returns {boolean}
|
|
159
|
+
*/
|
|
160
|
+
export function ownsEndpoint(bound, current) {
|
|
161
|
+
return Boolean(bound && current && bound.dev === current.dev && bound.ino === current.ino);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Whether this instance may unlink the endpoint path now.
|
|
166
|
+
* Restores the #70 cleanup contract (docs/superpowers/specs/
|
|
167
|
+
* 2026-09-05-pty-host-stacking-sock-race-design.md:418): the path must still be
|
|
168
|
+
* the inode this instance bound AND this instance must still own the lease.
|
|
169
|
+
* `compromised` lets the supervised tick veto its own cleanup after detecting
|
|
170
|
+
* that the endpoint is gone or replaced (issue #255).
|
|
171
|
+
* @param {{
|
|
172
|
+
* stillOwner: boolean,
|
|
173
|
+
* compromised: boolean,
|
|
174
|
+
* bound: {dev:number,ino:number}|null|undefined,
|
|
175
|
+
* current: {dev:number,ino:number}|null|undefined,
|
|
176
|
+
* }} input
|
|
177
|
+
* @returns {boolean}
|
|
178
|
+
*/
|
|
179
|
+
export function shouldUnlinkEndpoint({ stillOwner, compromised, bound, current }) {
|
|
180
|
+
if (!stillOwner || compromised) return false;
|
|
181
|
+
return ownsEndpoint(bound, current);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Map one supervised stat attempt to the endpoint observation discriminator.
|
|
186
|
+
* A transient stat failure (EACCES/EBUSY/...) must never be read as "replaced":
|
|
187
|
+
* only ENOENT means the endpoint is actually gone (issue #255, mirrors the
|
|
188
|
+
* rootVanishedOrReplaced rule of issue #241).
|
|
189
|
+
* @param {{dev:number,ino:number}|null} stat The successful stat snapshot, or null.
|
|
190
|
+
* @param {string|null} errorCode The stat error code, or null on success.
|
|
191
|
+
* @returns {{kind:"ok",dev:number,ino:number}|{kind:"missing"}|{kind:"unknown"}}
|
|
192
|
+
*/
|
|
193
|
+
export function socketIdentityObservation(stat, errorCode) {
|
|
194
|
+
if (stat) return { kind: "ok", dev: stat.dev, ino: stat.ino };
|
|
195
|
+
return errorCode === "ENOENT" ? { kind: "missing" } : { kind: "unknown" };
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Classify the supervised endpoint against the identity bound at boot.
|
|
200
|
+
* `unknown` never steps a holder down: without a bound identity (win32, or a
|
|
201
|
+
* failed bind-time stat) the supervisor degrades to a no-op.
|
|
202
|
+
* @param {{dev:number,ino:number}|null|undefined} bound
|
|
203
|
+
* @param {{kind:"ok",dev:number,ino:number}|{kind:"missing"}|{kind:"unknown"}|null|undefined} observed
|
|
204
|
+
* @returns {"ok"|"missing"|"replaced"|"unknown"}
|
|
205
|
+
*/
|
|
206
|
+
export function classifyEndpointHealth(bound, observed) {
|
|
207
|
+
if (!bound || !observed || observed.kind === "unknown") return "unknown";
|
|
208
|
+
if (observed.kind === "missing") return "missing";
|
|
209
|
+
return ownsEndpoint(bound, observed) ? "ok" : "replaced";
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Classify a connect+hello probe snapshot into the coordination enum.
|
|
214
|
+
* Order: connection errors first (they carry the errorCode), then protocol
|
|
215
|
+
* validity, then ownership mismatch (`occupied` — never touch), then
|
|
216
|
+
* readiness. A valid matching host that is not ready yet is `starting`, which
|
|
217
|
+
* callers treat as "wait", never as stale.
|
|
218
|
+
* @param {{
|
|
219
|
+
* connected?: boolean,
|
|
220
|
+
* protocolValid?: boolean,
|
|
221
|
+
* viewMatch?: boolean,
|
|
222
|
+
* instanceMatch?: boolean,
|
|
223
|
+
* state?: string|null,
|
|
224
|
+
* readyAt?: number|null,
|
|
225
|
+
* errorCode?: string|null,
|
|
226
|
+
* isSocket?: boolean,
|
|
227
|
+
* }} result Probe snapshot from host-probe.
|
|
228
|
+
* @returns {"ready"|"starting"|"stale"|"occupied"|"missing"|"unknown"}
|
|
229
|
+
*/
|
|
230
|
+
export function classifyProbeResult(result) {
|
|
231
|
+
if (!result) return "unknown";
|
|
232
|
+
if (!result.connected) {
|
|
233
|
+
if (result.errorCode === "ENOENT") return "missing";
|
|
234
|
+
if (result.errorCode === "ECONNREFUSED" && result.isSocket) return "stale";
|
|
235
|
+
return "unknown";
|
|
236
|
+
}
|
|
237
|
+
if (!result.protocolValid) return "unknown";
|
|
238
|
+
if (result.viewMatch === false || result.instanceMatch === false) return "occupied";
|
|
239
|
+
if (result.state === "alive" && result.readyAt != null) return "ready";
|
|
240
|
+
return "starting";
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Whether a legacy (pre-instanceId) host record can be safely finalized as
|
|
245
|
+
* `exited` so the next attach claims a fresh host. Spec §10.1 keeps legacy
|
|
246
|
+
* hosts unrecovered because their identity is unverifiable — but a provably
|
|
247
|
+
* dead runner pid IS a certain identity (a reused pid reads alive, which
|
|
248
|
+
* conservatively lands here as false), and an unreachable endpoint (missing
|
|
249
|
+
* socket file / refused socket) rules out a merely slow host. All four
|
|
250
|
+
* conditions must hold at once (issue #87).
|
|
251
|
+
* @param {{
|
|
252
|
+
* host: HostStatus|null|undefined,
|
|
253
|
+
* hostPid: number|null|undefined,
|
|
254
|
+
* hostPidAlive: boolean,
|
|
255
|
+
* probeClassification: string,
|
|
256
|
+
* }} input
|
|
257
|
+
* @returns {boolean}
|
|
258
|
+
*/
|
|
259
|
+
export function canFinalizeLegacyHost({ host, hostPid, hostPidAlive, probeClassification }) {
|
|
260
|
+
if (!host || host.instanceId != null) return false;
|
|
261
|
+
if (host.state !== "starting" && host.state !== "alive") return false;
|
|
262
|
+
if (hostPid == null || hostPidAlive) return false;
|
|
263
|
+
return probeClassification === "missing" || probeClassification === "stale";
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/** Host states in which a claim exists and must not be duplicated. */
|
|
267
|
+
const ACTIVE_STATES = new Set(["starting", "alive", "stopping"]);
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Whether a runner starting for `instanceId` must yield because the host
|
|
271
|
+
* record belongs to another still-active instance.
|
|
272
|
+
* @param {{host: HostStatus|null|undefined, instanceId: string|null|undefined}} input
|
|
273
|
+
* @returns {boolean}
|
|
274
|
+
*/
|
|
275
|
+
export function shouldYieldRunner({ host, instanceId }) {
|
|
276
|
+
if (!host || host.instanceId === instanceId) return false;
|
|
277
|
+
return ACTIVE_STATES.has(host.state);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Whether a host is ready to accept service-generated input: alive, ready,
|
|
282
|
+
* and not revoked.
|
|
283
|
+
* @param {HostStatus|null|undefined} host
|
|
284
|
+
* @returns {boolean}
|
|
285
|
+
*/
|
|
286
|
+
export function shouldAcceptInput(host) {
|
|
287
|
+
if (!host || host.state !== "alive" || host.readyAt == null) return false;
|
|
288
|
+
return host.stopRequestedAt == null;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* Whether a failed `listen` with this error may be retried (only EADDRINUSE,
|
|
293
|
+
* only the single configured retry).
|
|
294
|
+
* @param {string|null|undefined} errorCode
|
|
295
|
+
* @param {number} attempt Zero-based retry attempt already made.
|
|
296
|
+
* @returns {boolean}
|
|
297
|
+
*/
|
|
298
|
+
export function shouldRetryBind(errorCode, attempt) {
|
|
299
|
+
return errorCode === "EADDRINUSE" && attempt < 1;
|
|
300
|
+
}
|