@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,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Attach decision tables for the agent-board attach flow (issue #70).
|
|
3
|
+
*
|
|
4
|
+
* Pure mapping functions extracted from the two near-duplicate attach()
|
|
5
|
+
* implementations (src/commands/attach-flow.ts and the private copy in
|
|
6
|
+
* src/commands/agent-board.ts) so the branching contract is node-testable.
|
|
7
|
+
* Both TS files consume these for their prelude and resolver-result
|
|
8
|
+
* branching; they must stay free of I/O, service calls, and UI.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/** Default message surfaced when the resolver reports pending without a reason. */
|
|
12
|
+
export const ATTACH_PENDING_DEFAULT_MESSAGE = "Session host is starting — try again shortly.";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Prelude facts as observed from a dashboard row.
|
|
16
|
+
* @typedef {{ rowAlive: boolean, rowHostActive: boolean, stopFirst: boolean }} AttachPreludeFacts
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Map the attach prelude guards to a plan.
|
|
21
|
+
*
|
|
22
|
+
* The prelude only guards live JSON-runner rows (alive with no host claim):
|
|
23
|
+
* a row with an active host (starting/alive/stopping) passes straight
|
|
24
|
+
* through to the async resolver — attaching must not block or warn while a
|
|
25
|
+
* host is starting (issue #70).
|
|
26
|
+
*
|
|
27
|
+
* @param {AttachPreludeFacts} facts
|
|
28
|
+
* @returns {{ plan: "warn-running" } | { plan: "stop-first" } | { plan: "proceed" }}
|
|
29
|
+
*/
|
|
30
|
+
export function planAttachPrelude({ rowAlive, rowHostActive, stopFirst }) {
|
|
31
|
+
if (rowAlive && !rowHostActive) {
|
|
32
|
+
return stopFirst ? { plan: "stop-first" } : { plan: "warn-running" };
|
|
33
|
+
}
|
|
34
|
+
return { plan: "proceed" };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Map a `resolveAttachTarget()` result to the attach action plan.
|
|
39
|
+
*
|
|
40
|
+
* `socketPath` is typed `string | null` upstream; a pty resolution without a
|
|
41
|
+
* usable path is a transient resolver inconsistency and maps to
|
|
42
|
+
* notify-pending (retry later) rather than a misleading "missing".
|
|
43
|
+
*
|
|
44
|
+
* @param {{ kind: "pty", socketPath: string | null, sessionFile?: string, instanceId?: string | null } |
|
|
45
|
+
* { kind: "session", sessionFile: string } |
|
|
46
|
+
* { kind: "pending", sessionFile?: string, reason?: string } |
|
|
47
|
+
* { kind: "missing" }} resolved
|
|
48
|
+
* @returns {{ plan: "open-pty", socketPath: string } |
|
|
49
|
+
* { plan: "session-switch", sessionFile: string } |
|
|
50
|
+
* { plan: "notify-pending", reason: string } |
|
|
51
|
+
* { plan: "notify-missing" }}
|
|
52
|
+
*/
|
|
53
|
+
export function planAttachResolved(resolved) {
|
|
54
|
+
switch (resolved?.kind) {
|
|
55
|
+
case "pty":
|
|
56
|
+
return resolved.socketPath != null
|
|
57
|
+
? { plan: "open-pty", socketPath: resolved.socketPath }
|
|
58
|
+
: { plan: "notify-pending", reason: ATTACH_PENDING_DEFAULT_MESSAGE };
|
|
59
|
+
case "session":
|
|
60
|
+
return { plan: "session-switch", sessionFile: resolved.sessionFile };
|
|
61
|
+
case "pending":
|
|
62
|
+
return { plan: "notify-pending", reason: resolved.reason ?? ATTACH_PENDING_DEFAULT_MESSAGE };
|
|
63
|
+
default:
|
|
64
|
+
return { plan: "notify-missing" };
|
|
65
|
+
}
|
|
66
|
+
}
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reusable attach-flow helpers shared by the `agent-board` and `bg` commands.
|
|
3
|
+
*
|
|
4
|
+
* Pure helpers (paths, prompts, current-view lookup) plus the resolver-driven
|
|
5
|
+
* attach flow: since issue #70 the attach path calls the async
|
|
6
|
+
* `resolveAttachTarget()` once and plans via `attach-decision.mjs`, instead of
|
|
7
|
+
* the old attachTarget-hint → ensureHost double path.
|
|
8
|
+
*/
|
|
9
|
+
import { existsSync } from "node:fs";
|
|
10
|
+
import { resolve } from "node:path";
|
|
11
|
+
import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
12
|
+
import { restoreChrome } from "../ui/chrome.ts";
|
|
13
|
+
import { Key, matchesKey, truncateToWidth } from "@earendil-works/pi-tui";
|
|
14
|
+
import { createService } from "../runtime/service.mjs";
|
|
15
|
+
import { planAttachPrelude, planAttachResolved } from "./attach-decision.mjs";
|
|
16
|
+
import { screenLogPath } from "../core/paths.mjs";
|
|
17
|
+
import { appendDiagnostic } from "../core/diagnostics.mjs";
|
|
18
|
+
import { PtyAttachComponent, type PtyAttachResult } from "../ui/pty-attach.js";
|
|
19
|
+
import type { DashboardResult } from "../ui/dashboard.js";
|
|
20
|
+
|
|
21
|
+
const sleep = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
|
|
22
|
+
|
|
23
|
+
export type AttachOutcome = { action: "detached" | "closed" | "switched" | "none" };
|
|
24
|
+
|
|
25
|
+
/** Open the PTY-attach UI for a live agent session. */
|
|
26
|
+
export async function openPtyAttach(
|
|
27
|
+
ctx: ExtensionCommandContext,
|
|
28
|
+
root: string,
|
|
29
|
+
viewId: string,
|
|
30
|
+
name: string,
|
|
31
|
+
socketPath: string,
|
|
32
|
+
): Promise<PtyAttachResult> {
|
|
33
|
+
ctx.ui.setWorkingVisible(false);
|
|
34
|
+
ctx.ui.setHeader(() => ({ render: () => [], invalidate() {} }));
|
|
35
|
+
ctx.ui.setFooter(() => ({ render: () => [], invalidate() {} }));
|
|
36
|
+
ctx.ui.setTitle(`agent-board: ${name}`);
|
|
37
|
+
try {
|
|
38
|
+
return await ctx.ui.custom<PtyAttachResult>(
|
|
39
|
+
(tui, theme, keybindings, done) =>
|
|
40
|
+
new PtyAttachComponent(tui, theme as never, keybindings, done, {
|
|
41
|
+
socketPath,
|
|
42
|
+
screenLogPath: root ? screenLogPath(root, viewId) : undefined,
|
|
43
|
+
title: name,
|
|
44
|
+
onDiagnostic: root
|
|
45
|
+
? (event) => {
|
|
46
|
+
// Best effort, like every other diagnostics writer.
|
|
47
|
+
try {
|
|
48
|
+
appendDiagnostic(root, viewId, event as never);
|
|
49
|
+
} catch {
|
|
50
|
+
/* diagnostics must never break attach */
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
: undefined,
|
|
54
|
+
}),
|
|
55
|
+
{
|
|
56
|
+
overlay: true,
|
|
57
|
+
overlayOptions: { anchor: "top-left", width: "100%", maxHeight: "100%", margin: 0 },
|
|
58
|
+
},
|
|
59
|
+
);
|
|
60
|
+
} finally {
|
|
61
|
+
restoreChrome(ctx);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Show a brief overlay while the session switch is being initiated. */
|
|
66
|
+
export async function showSwitchingOverlay(
|
|
67
|
+
ctx: ExtensionCommandContext,
|
|
68
|
+
name: string,
|
|
69
|
+
reason: string,
|
|
70
|
+
): Promise<{ hide(): void } | null> {
|
|
71
|
+
let handle: { hide(): void } | null = null;
|
|
72
|
+
void ctx.ui.custom<null>(
|
|
73
|
+
(tui, theme) => ({
|
|
74
|
+
render(width: number): string[] {
|
|
75
|
+
const height = tui.terminal?.rows ?? 24;
|
|
76
|
+
const out = Array.from({ length: Math.max(0, Math.floor(height / 2) - 2) }, () => "");
|
|
77
|
+
out.push(clipLine(theme.fg("accent", theme.bold(`Switching to "${name}"…`)), width));
|
|
78
|
+
out.push(clipLine(theme.fg("dim", `Starting fallback session switch (${reason})`), width));
|
|
79
|
+
while (out.length < height) out.push("");
|
|
80
|
+
return out;
|
|
81
|
+
},
|
|
82
|
+
invalidate() {},
|
|
83
|
+
}),
|
|
84
|
+
{
|
|
85
|
+
overlay: true,
|
|
86
|
+
overlayOptions: { anchor: "top-left", width: "100%", maxHeight: "100%", margin: 0 },
|
|
87
|
+
onHandle: (h) => {
|
|
88
|
+
handle = h;
|
|
89
|
+
},
|
|
90
|
+
},
|
|
91
|
+
);
|
|
92
|
+
await sleep(50);
|
|
93
|
+
return handle;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function clipLine(text: string, width: number): string {
|
|
97
|
+
return truncateToWidth(text, width);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Return the viewId of whichever agent session matches the current pi session file. */
|
|
101
|
+
export function currentViewId(
|
|
102
|
+
ctx: ExtensionCommandContext,
|
|
103
|
+
service: ReturnType<typeof createService>,
|
|
104
|
+
): string | null {
|
|
105
|
+
const currentSessionFile = ctx.sessionManager.getSessionFile();
|
|
106
|
+
if (!currentSessionFile) return null;
|
|
107
|
+
return service.rows().find((r) => samePath(r.meta.sessionFile, currentSessionFile))?.meta.id ?? null;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function samePath(a: string, b: string): boolean {
|
|
111
|
+
return resolve(a) === resolve(b);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Attach to an agent session through the single async resolver entry point.
|
|
116
|
+
* PTY readiness is decided by `resolveAttachTarget()` (real connect + hello
|
|
117
|
+
* probe, with bounded recovery); this function only maps its result to UI
|
|
118
|
+
* actions (issue #70 — no more attachTarget-hint → ensureHost double path).
|
|
119
|
+
*/
|
|
120
|
+
export async function attach(
|
|
121
|
+
ctx: ExtensionCommandContext,
|
|
122
|
+
service: ReturnType<typeof createService>,
|
|
123
|
+
root: string,
|
|
124
|
+
viewId: string,
|
|
125
|
+
stopFirst: boolean,
|
|
126
|
+
): Promise<AttachOutcome> {
|
|
127
|
+
const row = service.row(viewId);
|
|
128
|
+
if (!row) {
|
|
129
|
+
ctx.ui.notify("Session no longer exists.", "warning");
|
|
130
|
+
return { action: "none" };
|
|
131
|
+
}
|
|
132
|
+
const prelude = planAttachPrelude({ rowAlive: row.alive, rowHostActive: row.hostActive, stopFirst });
|
|
133
|
+
if (prelude.plan === "warn-running") {
|
|
134
|
+
ctx.ui.notify("Session is still running. Stop it before attaching, or confirm from the dashboard.", "warning");
|
|
135
|
+
return { action: "none" };
|
|
136
|
+
}
|
|
137
|
+
if (prelude.plan === "stop-first") {
|
|
138
|
+
service.stop(viewId);
|
|
139
|
+
// Give the runner a moment to terminate the worker and release the session file.
|
|
140
|
+
await sleep(500);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const plan = planAttachResolved(await service.resolveAttachTarget(viewId));
|
|
144
|
+
if (plan.plan === "open-pty") {
|
|
145
|
+
void service.markVisited?.(viewId)?.catch(() => {});
|
|
146
|
+
const result = await openPtyAttach(ctx, root, row.meta.id, row.meta.name, plan.socketPath);
|
|
147
|
+
void service.markVisited?.(viewId)?.catch(() => {});
|
|
148
|
+
if (result.action === "quit") {
|
|
149
|
+
ctx.shutdown();
|
|
150
|
+
return { action: "switched" }; // Nothing to come back to.
|
|
151
|
+
}
|
|
152
|
+
return { action: result.action === "closed" ? "closed" : "detached" };
|
|
153
|
+
}
|
|
154
|
+
if (plan.plan === "session-switch") {
|
|
155
|
+
const latest = service.row(viewId) ?? row;
|
|
156
|
+
if (!existsSync(latest.meta.sessionFile)) {
|
|
157
|
+
ctx.ui.notify("Session file isn't ready yet — try again once the run has started.", "warning");
|
|
158
|
+
return { action: "none" };
|
|
159
|
+
}
|
|
160
|
+
const name = latest.meta.name;
|
|
161
|
+
void service.markVisited?.(viewId)?.catch(() => {});
|
|
162
|
+
const switchingOverlay = await showSwitchingOverlay(ctx, name, "PTY unavailable");
|
|
163
|
+
const result = await ctx.switchSession(latest.meta.sessionFile, {
|
|
164
|
+
withSession: async (replaced) => {
|
|
165
|
+
replaced.ui.notify(`Attached to "${name}". Press ← on empty input to return to agent board.`, "info");
|
|
166
|
+
installBackToDashboard(replaced, service, openDashboardFn);
|
|
167
|
+
},
|
|
168
|
+
}).finally(() => {
|
|
169
|
+
try {
|
|
170
|
+
switchingOverlay?.hide();
|
|
171
|
+
} catch {}
|
|
172
|
+
});
|
|
173
|
+
if (result.cancelled) {
|
|
174
|
+
ctx.ui.notify("Attach cancelled.", "warning");
|
|
175
|
+
return { action: "none" };
|
|
176
|
+
}
|
|
177
|
+
return { action: "switched" };
|
|
178
|
+
}
|
|
179
|
+
if (plan.plan === "notify-pending") {
|
|
180
|
+
ctx.ui.notify(plan.reason, "info");
|
|
181
|
+
return { action: "none" };
|
|
182
|
+
}
|
|
183
|
+
ctx.ui.notify("Session no longer exists.", "warning");
|
|
184
|
+
return { action: "none" };
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* The openDashboard function reference used by installBackToDashboard when called
|
|
189
|
+
* from attach(). This is set at module initialisation time by agent-board.ts via
|
|
190
|
+
* setOpenDashboardFn so that attach-flow.ts does not need to import from agent-board.ts
|
|
191
|
+
* (which would create a circular dependency).
|
|
192
|
+
*/
|
|
193
|
+
type OpenDashboardFn = (
|
|
194
|
+
ctx: Pick<ExtensionCommandContext, "ui" | "cwd" | "modelRegistry" | "model">,
|
|
195
|
+
service: ReturnType<typeof createService>,
|
|
196
|
+
options?: { initialSelectedId?: string | null },
|
|
197
|
+
) => Promise<DashboardResult>;
|
|
198
|
+
|
|
199
|
+
let openDashboardFn: OpenDashboardFn = async () => {
|
|
200
|
+
throw new Error("openDashboardFn not initialised — call setOpenDashboardFn first");
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
/** Called once by agent-board.ts to wire up the openDashboard dependency. */
|
|
204
|
+
export function setOpenDashboardFn(fn: OpenDashboardFn): void {
|
|
205
|
+
openDashboardFn = fn;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Install the ← keybinding that opens the dashboard from inside an attached session.
|
|
210
|
+
* `openDashboard` is accepted as a parameter to avoid a circular import between
|
|
211
|
+
* agent-board.ts and attach-flow.ts.
|
|
212
|
+
*/
|
|
213
|
+
export function installBackToDashboard(
|
|
214
|
+
ctx: ExtensionCommandContext,
|
|
215
|
+
service: ReturnType<typeof createService>,
|
|
216
|
+
openDashboard: OpenDashboardFn,
|
|
217
|
+
): void {
|
|
218
|
+
ctx.ui.setStatus("agent-board.back", ctx.ui.theme.fg("muted", "← board"));
|
|
219
|
+
let opening = false;
|
|
220
|
+
ctx.ui.onTerminalInput((data: string) => {
|
|
221
|
+
if (opening || !matchesKey(data, Key.left)) return undefined;
|
|
222
|
+
// Do not steal normal cursor-left while the user is composing a message.
|
|
223
|
+
if (ctx.ui.getEditorText().length > 0) return undefined;
|
|
224
|
+
opening = true;
|
|
225
|
+
void (async () => {
|
|
226
|
+
try {
|
|
227
|
+
let selectedId = currentViewId(ctx, service);
|
|
228
|
+
while (true) {
|
|
229
|
+
if (selectedId) void service.markVisited?.(selectedId)?.catch(() => {});
|
|
230
|
+
void service.reconcile().catch(() => {});
|
|
231
|
+
const result = await openDashboard(ctx, service, { initialSelectedId: selectedId });
|
|
232
|
+
if (result.action !== "attach") return;
|
|
233
|
+
selectedId = result.viewId;
|
|
234
|
+
const target = service.row(result.viewId);
|
|
235
|
+
const currentSessionFile = ctx.sessionManager.getSessionFile();
|
|
236
|
+
if (target && currentSessionFile && samePath(target.meta.sessionFile, currentSessionFile)) {
|
|
237
|
+
ctx.ui.notify("Already attached to this session.", "info");
|
|
238
|
+
continue;
|
|
239
|
+
}
|
|
240
|
+
const outcome = await attach(ctx, service, service.root, result.viewId, result.stopFirst);
|
|
241
|
+
if (outcome.action === "switched") return;
|
|
242
|
+
}
|
|
243
|
+
} catch (err) {
|
|
244
|
+
ctx.ui.notify(`Couldn't open agent board: ${err instanceof Error ? err.message : String(err)}`, "error");
|
|
245
|
+
} finally {
|
|
246
|
+
opening = false;
|
|
247
|
+
}
|
|
248
|
+
})();
|
|
249
|
+
return { consume: true };
|
|
250
|
+
});
|
|
251
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/** `/bg` command: adopt the current interactive Pi session into Agent Board. */
|
|
2
|
+
import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import { createService } from "../runtime/service.mjs";
|
|
4
|
+
import { dashboardAttachLoop } from "./agent-board.js";
|
|
5
|
+
|
|
6
|
+
const NEW_SESSION = "new session";
|
|
7
|
+
|
|
8
|
+
export interface BgCommandOptions {
|
|
9
|
+
root: string;
|
|
10
|
+
runnerScript: string;
|
|
11
|
+
ptyRunnerScript?: string;
|
|
12
|
+
titleRunnerScript?: string;
|
|
13
|
+
piCommand: string;
|
|
14
|
+
piArgsPrefix: string[];
|
|
15
|
+
getThinkingLevel: () => "off" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max";
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function registerBgCommand(pi: ExtensionAPI, opts: BgCommandOptions): void {
|
|
19
|
+
pi.registerCommand("bg", {
|
|
20
|
+
description: "Adopt the current session into Agent Board and optionally queue a prompt",
|
|
21
|
+
handler: async (args, ctx) => {
|
|
22
|
+
await handleBgCommand(args, ctx, opts);
|
|
23
|
+
},
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async function handleBgCommand(args: string, ctx: ExtensionCommandContext, opts: BgCommandOptions): Promise<void> {
|
|
28
|
+
if (!ctx.hasUI) {
|
|
29
|
+
ctx.ui.notify("/bg requires interactive mode.", "warning");
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
const sessionFile = ctx.sessionManager.getSessionFile();
|
|
33
|
+
const service = createService({
|
|
34
|
+
root: opts.root,
|
|
35
|
+
runnerScript: opts.runnerScript,
|
|
36
|
+
ptyRunnerScript: opts.ptyRunnerScript,
|
|
37
|
+
titleRunnerScript: opts.titleRunnerScript,
|
|
38
|
+
piCommand: opts.piCommand,
|
|
39
|
+
piArgsPrefix: opts.piArgsPrefix,
|
|
40
|
+
defaultCwd: ctx.cwd,
|
|
41
|
+
// Stale-defaultModel launch guard (issue #90): live list, undefined
|
|
42
|
+
// when the registry is unavailable so validation conservatively skips.
|
|
43
|
+
availableModels: () => {
|
|
44
|
+
try {
|
|
45
|
+
return ctx.modelRegistry.getAvailable();
|
|
46
|
+
} catch {
|
|
47
|
+
return undefined;
|
|
48
|
+
}
|
|
49
|
+
},
|
|
50
|
+
});
|
|
51
|
+
// A session that isn't saved anywhere has nothing to come back to, but the board still opens.
|
|
52
|
+
if (!sessionFile) {
|
|
53
|
+
await dashboardAttachLoop(ctx, service, opts.root, null, opts.getThinkingLevel);
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
const model = modelRef(ctx.model as any);
|
|
57
|
+
// adoptSession is async (routes through the view-state coordinator, issue #91).
|
|
58
|
+
const adopted = await service.adoptSession({
|
|
59
|
+
sessionFile,
|
|
60
|
+
cwd: ctx.cwd,
|
|
61
|
+
model,
|
|
62
|
+
thinkingLevel: opts.getThinkingLevel(),
|
|
63
|
+
// A row that exists keeps its name, which may be one you gave it.
|
|
64
|
+
name: hasOwnName(service, sessionFile) ? undefined : sessionName(ctx),
|
|
65
|
+
});
|
|
66
|
+
if (!adopted.ok || !adopted.viewId) {
|
|
67
|
+
ctx.ui.notify(adopted.error ?? "Could not background current session.", "warning");
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
const prompt = String(args || "").trim();
|
|
71
|
+
if (prompt) {
|
|
72
|
+
const reply = service.queueFollowUp(adopted.viewId, prompt, { delivery: "queue", source: "bg-command" }) as { ok: boolean; error?: string };
|
|
73
|
+
if (!reply.ok) ctx.ui.notify(reply.error ?? "Could not queue background prompt.", "warning");
|
|
74
|
+
else ctx.ui.notify("Prompt queued for background session.", "info");
|
|
75
|
+
}
|
|
76
|
+
await dashboardAttachLoop(ctx, service, opts.root, adopted.viewId, opts.getThinkingLevel);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function hasOwnName(service: ReturnType<typeof createService>, sessionFile: string): boolean {
|
|
80
|
+
const row = service.rows().find((candidate) => candidate.meta.sessionFile === sessionFile);
|
|
81
|
+
return row !== undefined && row.meta.name !== NEW_SESSION;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Its name if it has one, then what you first asked. A session with neither is new. */
|
|
85
|
+
function sessionName(ctx: ExtensionCommandContext): string {
|
|
86
|
+
const named = ctx.sessionManager.getSessionName?.();
|
|
87
|
+
if (named) return named;
|
|
88
|
+
for (const entry of ctx.sessionManager.getEntries() as any[]) {
|
|
89
|
+
if (entry.type !== "message" || entry.message?.role !== "user") continue;
|
|
90
|
+
const content = entry.message.content;
|
|
91
|
+
const text = typeof content === "string" ? content : (content ?? []).map((part: any) => part.text ?? "").join(" ");
|
|
92
|
+
const line = String(text).trim().split("\n")[0];
|
|
93
|
+
if (line) return line.slice(0, 48);
|
|
94
|
+
}
|
|
95
|
+
return NEW_SESSION;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function modelRef(model: any): string | null {
|
|
99
|
+
if (!model || typeof model !== "object") return null;
|
|
100
|
+
if (typeof model.provider === "string" && typeof model.id === "string") return `${model.provider}/${model.id}`;
|
|
101
|
+
if (typeof model.id === "string") return model.id;
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/** `/board-model`: which model writes the board's titles, summaries and states. */
|
|
2
|
+
import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import { readConfig, setHelperModel } from "../core/config.mjs";
|
|
4
|
+
|
|
5
|
+
const OFF = "Off: use built-in rules, no model calls";
|
|
6
|
+
|
|
7
|
+
export function registerBoardModelCommand(pi: ExtensionAPI): void {
|
|
8
|
+
pi.registerCommand("board-model", {
|
|
9
|
+
description: "Choose the model for the board's titles, summaries and states, or turn it off",
|
|
10
|
+
handler: async (_args, ctx: ExtensionCommandContext) => {
|
|
11
|
+
if (!ctx.hasUI) return;
|
|
12
|
+
const refs = ctx.modelRegistry.getAvailable().map((model) => `${model.provider}/${model.id}`);
|
|
13
|
+
const current = readConfig().model;
|
|
14
|
+
const choice = await ctx.ui.select(`Board helper model (now: ${current ?? "off"})`, [OFF, ...refs]);
|
|
15
|
+
if (choice === undefined) return;
|
|
16
|
+
const model = choice === OFF ? null : choice;
|
|
17
|
+
setHelperModel(model);
|
|
18
|
+
ctx.ui.notify(model ? `The board's helper calls now use ${model}.` : "The board's helper model calls are off.", "info");
|
|
19
|
+
},
|
|
20
|
+
});
|
|
21
|
+
}
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Atomic + crash-tolerant file helpers used across the extension and the runner.
|
|
3
|
+
*
|
|
4
|
+
* Writes go to a temp sibling then `rename()` (atomic on the same filesystem) so a
|
|
5
|
+
* reader never observes a half-written JSON file. Reads tolerate missing/corrupt files.
|
|
6
|
+
*/
|
|
7
|
+
import { randomBytes } from "node:crypto";
|
|
8
|
+
import {
|
|
9
|
+
appendFileSync,
|
|
10
|
+
existsSync,
|
|
11
|
+
mkdirSync,
|
|
12
|
+
readFileSync,
|
|
13
|
+
renameSync,
|
|
14
|
+
unlinkSync,
|
|
15
|
+
writeFileSync,
|
|
16
|
+
} from "node:fs";
|
|
17
|
+
import * as path from "node:path";
|
|
18
|
+
|
|
19
|
+
/** Retry backoff (ms) for rename retries; index i is the delay after attempt i. */
|
|
20
|
+
export const RENAME_RETRY_BACKOFF_MS = [10, 50, 250];
|
|
21
|
+
/** Error codes that mean "transient sharing violation" — retryable on Windows. */
|
|
22
|
+
export const RENAME_RETRY_ERROR_CODES = new Set(["EPERM", "EBUSY", "EACCES"]);
|
|
23
|
+
|
|
24
|
+
/** @param {string} dir */
|
|
25
|
+
export function ensureDir(dir) {
|
|
26
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* rename() with retries for Windows sharing-violation races.
|
|
31
|
+
*
|
|
32
|
+
* On Windows, libuv opens files without FILE_SHARE_DELETE, so replacing an
|
|
33
|
+
* existing target while any other process holds it open for reading throws
|
|
34
|
+
* EPERM/EBUSY/EACCES. The reader window is microseconds, so a few retries
|
|
35
|
+
* with a short backoff succeed almost always. Non-whitelisted errors
|
|
36
|
+
* (e.g. EISDIR) are configuration errors and throw immediately.
|
|
37
|
+
* @param {string} tmp
|
|
38
|
+
* @param {string} file
|
|
39
|
+
* @param {{ rename?: (a: string, b: string) => void, delays?: number[], errorCodes?: Set<string> }} [opts]
|
|
40
|
+
*/
|
|
41
|
+
export function renameWithRetry(tmp, file, opts = {}) {
|
|
42
|
+
const rename = opts.rename ?? renameSync;
|
|
43
|
+
const delays = opts.delays ?? RENAME_RETRY_BACKOFF_MS;
|
|
44
|
+
const errorCodes = opts.errorCodes ?? RENAME_RETRY_ERROR_CODES;
|
|
45
|
+
for (let attempt = 0; ; attempt++) {
|
|
46
|
+
try {
|
|
47
|
+
rename(tmp, file);
|
|
48
|
+
return;
|
|
49
|
+
} catch (err) {
|
|
50
|
+
const code = /** @type {NodeJS.ErrnoException} */ (err).code;
|
|
51
|
+
if (!errorCodes.has(code) || attempt >= delays.length) {
|
|
52
|
+
// Exhausted or non-transient: leave no .tmp litter behind, then
|
|
53
|
+
// surface the original error so callers can degrade deliberately.
|
|
54
|
+
try { unlinkSync(tmp); } catch { /* best effort */ }
|
|
55
|
+
throw err;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
// Synchronous sleep (Atomics.wait) — atomicWrite is a sync API.
|
|
59
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, delays[attempt]);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Atomically write a string to `file` (creates parent dirs).
|
|
65
|
+
* @param {string} file
|
|
66
|
+
* @param {string} data
|
|
67
|
+
*/
|
|
68
|
+
export function atomicWrite(file, data) {
|
|
69
|
+
ensureDir(path.dirname(file));
|
|
70
|
+
const tmp = `${file}.${process.pid}.${randomBytes(4).toString("hex")}.tmp`;
|
|
71
|
+
writeFileSync(tmp, data, "utf8");
|
|
72
|
+
renameWithRetry(tmp, file);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Atomically write a value as pretty JSON.
|
|
77
|
+
* @param {string} file
|
|
78
|
+
* @param {unknown} value
|
|
79
|
+
*/
|
|
80
|
+
export function atomicWriteJson(file, value) {
|
|
81
|
+
atomicWrite(file, `${JSON.stringify(value, null, 2)}\n`);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Read and parse JSON, returning `fallback` when the file is missing or unparseable.
|
|
86
|
+
* @template T
|
|
87
|
+
* @param {string} file
|
|
88
|
+
* @param {T} fallback
|
|
89
|
+
* @returns {T}
|
|
90
|
+
*/
|
|
91
|
+
export function readJson(file, fallback) {
|
|
92
|
+
try {
|
|
93
|
+
if (!existsSync(file)) return fallback;
|
|
94
|
+
const raw = readFileSync(file, "utf8");
|
|
95
|
+
if (!raw.trim()) return fallback;
|
|
96
|
+
return /** @type {T} */ (JSON.parse(raw));
|
|
97
|
+
} catch {
|
|
98
|
+
return fallback;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Append one line (a trailing newline is added) to a log/jsonl file.
|
|
104
|
+
* @param {string} file
|
|
105
|
+
* @param {string} line
|
|
106
|
+
*/
|
|
107
|
+
export function appendLine(file, line) {
|
|
108
|
+
ensureDir(path.dirname(file));
|
|
109
|
+
appendFileSync(file, line.endsWith("\n") ? line : `${line}\n`, "utf8");
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Append one JSON value as a JSONL record.
|
|
114
|
+
* @param {string} file
|
|
115
|
+
* @param {unknown} value
|
|
116
|
+
*/
|
|
117
|
+
export function appendJsonl(file, value) {
|
|
118
|
+
appendLine(file, JSON.stringify(value));
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Read a JSONL file into parsed objects, skipping blank/corrupt lines.
|
|
123
|
+
* @param {string} file
|
|
124
|
+
* @returns {any[]}
|
|
125
|
+
*/
|
|
126
|
+
export function readJsonl(file) {
|
|
127
|
+
try {
|
|
128
|
+
if (!existsSync(file)) return [];
|
|
129
|
+
const out = [];
|
|
130
|
+
for (const line of readFileSync(file, "utf8").split("\n")) {
|
|
131
|
+
const t = line.trim();
|
|
132
|
+
if (!t) continue;
|
|
133
|
+
try {
|
|
134
|
+
out.push(JSON.parse(t));
|
|
135
|
+
} catch {
|
|
136
|
+
/* skip corrupt line */
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
return out;
|
|
140
|
+
} catch {
|
|
141
|
+
return [];
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Read the newest `limit` JSONL records. Corrupt lines are skipped.
|
|
147
|
+
* @param {string} file
|
|
148
|
+
* @param {number} [limit]
|
|
149
|
+
* @returns {any[]}
|
|
150
|
+
*/
|
|
151
|
+
export function readJsonlTail(file, limit = 50) {
|
|
152
|
+
const all = readJsonl(file);
|
|
153
|
+
const n = Math.max(0, Math.floor(Number(limit) || 0));
|
|
154
|
+
return n > 0 ? all.slice(-n) : all;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Best-effort delete helper used by cleanup actions.
|
|
159
|
+
* @param {string} file
|
|
160
|
+
* @returns {boolean}
|
|
161
|
+
*/
|
|
162
|
+
export function removeFile(file) {
|
|
163
|
+
try {
|
|
164
|
+
if (!existsSync(file)) return false;
|
|
165
|
+
unlinkSync(file);
|
|
166
|
+
return true;
|
|
167
|
+
} catch {
|
|
168
|
+
return false;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Read a file's text, returning `fallback` if missing.
|
|
174
|
+
* @param {string} file
|
|
175
|
+
* @param {string} [fallback]
|
|
176
|
+
* @returns {string}
|
|
177
|
+
*/
|
|
178
|
+
export function readText(file, fallback = "") {
|
|
179
|
+
try {
|
|
180
|
+
return existsSync(file) ? readFileSync(file, "utf8") : fallback;
|
|
181
|
+
} catch {
|
|
182
|
+
return fallback;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { Key, matchesKey } from "@earendil-works/pi-tui";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Issue #147: key resolution for the in-attach delete gesture. Pure — never
|
|
5
|
+
* reads socket/connection/editor state, so the gesture works even while the
|
|
6
|
+
* host is down. Second ctrl+x is swallowed (cancel-swallow, user adjudication
|
|
7
|
+
* 2026-10-10): the chord is a confirm toggle here, and forwarding it would
|
|
8
|
+
* inject one message.copy into the child Pi.
|
|
9
|
+
* @param {{ confirmArmed: boolean }} state
|
|
10
|
+
* @param {string} data raw key chunk
|
|
11
|
+
* @returns {{ action: "arm" | "confirm" | "cancel" | "cancel-swallow" | "forward", confirmArmed: boolean }}
|
|
12
|
+
*/
|
|
13
|
+
export function resolveAttachDeleteKey(state, data) {
|
|
14
|
+
if (matchesKey(data, Key.ctrl("x"))) {
|
|
15
|
+
return state.confirmArmed ? { action: "cancel-swallow", confirmArmed: false } : { action: "arm", confirmArmed: true };
|
|
16
|
+
}
|
|
17
|
+
if (state.confirmArmed) {
|
|
18
|
+
if (data === "y" || data === "Y" || matchesKey(data, Key.enter)) {
|
|
19
|
+
return { action: "confirm", confirmArmed: false };
|
|
20
|
+
}
|
|
21
|
+
return { action: "cancel", confirmArmed: false };
|
|
22
|
+
}
|
|
23
|
+
return { action: "forward", confirmArmed: false };
|
|
24
|
+
}
|