pi-squire 0.14.0 → 0.16.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 +438 -1
- package/README.md +1062 -152
- package/package.json +4 -3
- package/src/agents.ts +395 -124
- package/src/child-tasks.ts +221 -0
- package/src/child.ts +107 -62
- package/src/completion-holds.ts +104 -0
- package/src/dashboard.ts +144 -0
- package/src/git.ts +6 -1
- package/src/identity.ts +152 -0
- package/src/index.ts +585 -107
- package/src/local-state.ts +21 -0
- package/src/navigator-view.ts +186 -0
- package/src/navigator.ts +152 -107
- package/src/new-delegate.ts +17 -8
- package/src/poll.ts +1 -0
- package/src/render.ts +4 -1
- package/src/result-close.ts +37 -0
- package/src/result-restore.ts +95 -0
- package/src/result-sessions.ts +39 -0
- package/src/result-types.ts +29 -1
- package/src/results-presentation.ts +47 -0
- package/src/results.ts +176 -108
- package/src/resume-checks.ts +59 -14
- package/src/resume-picker.ts +9 -2
- package/src/resume.ts +69 -15
- package/src/session-owner.ts +279 -0
- package/src/task-replies.ts +247 -0
- package/src/task-settlement.ts +81 -0
- package/src/task-store.ts +26 -0
- package/src/tmux.ts +134 -54
- package/src/tool-ceiling.ts +125 -0
- package/src/tracking.ts +72 -65
- package/src/tree-close.ts +236 -0
- package/src/tree-fence.ts +51 -0
- package/src/tree-lock.ts +77 -0
- package/src/tree-notice.ts +124 -0
- package/src/tree-registry.ts +365 -0
- package/src/tree-settings.ts +31 -0
- package/src/tree-types.ts +32 -0
- package/src/tree-view.ts +246 -0
- package/src/trust-control.ts +214 -0
- package/src/trust-prompt.ts +163 -0
- package/src/trust-wire.ts +203 -0
- package/src/widget-activity-join.ts +24 -0
- package/src/widget-activity-runtime.ts +104 -0
- package/src/widget-activity.ts +104 -0
- package/src/widget-ancestors.ts +142 -0
- package/src/widget-current.ts +41 -0
- package/src/widget-data.ts +147 -0
- package/src/widget-focus.ts +133 -0
- package/src/widget-live-process.ts +31 -0
- package/src/widget-live.ts +356 -0
- package/src/widget-render.ts +181 -0
- package/src/widget-selection.ts +58 -0
- package/src/widget-settings.ts +29 -0
- package/src/widget-summary.ts +51 -0
- package/src/widget-title-file.ts +127 -0
- package/src/widget-title.ts +118 -0
- package/src/widget.ts +272 -0
- package/src/window-placement.ts +30 -5
- package/src/workspace.ts +38 -90
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import { stripVTControlCharacters } from "node:util";
|
|
2
|
+
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
4
|
+
import type { AncestorRow } from "./widget-ancestors.ts";
|
|
5
|
+
import type { DelegateRow } from "./widget-data.ts";
|
|
6
|
+
|
|
7
|
+
export type WidgetTheme = Pick<Theme, "fg" | "bold">;
|
|
8
|
+
|
|
9
|
+
/** Foreign names and labels are text, not terminal commands or multiline layout. */
|
|
10
|
+
export function widgetText(value: string): string {
|
|
11
|
+
return [...stripVTControlCharacters(value).replace(/\s+/g, " ")]
|
|
12
|
+
.filter((character) => {
|
|
13
|
+
const code: number = character.codePointAt(0) ?? 0;
|
|
14
|
+
return code >= 32 && (code < 127 || code > 159);
|
|
15
|
+
})
|
|
16
|
+
.join("")
|
|
17
|
+
.trim()
|
|
18
|
+
.slice(0, 2048);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function age(timestamp: number, now: number): string {
|
|
22
|
+
const minutes: number = Math.floor(Math.max(0, now - timestamp) / 60_000);
|
|
23
|
+
if (minutes < 1) return "just now";
|
|
24
|
+
if (minutes < 60) return `${minutes}m ago`;
|
|
25
|
+
return `${Math.floor(minutes / 60)}h ago`;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function appearance(row: DelegateRow): { icon: string; color: "accent" | "success" | "warning" | "error" } {
|
|
29
|
+
if (row.running) return { icon: "◌", color: "accent" };
|
|
30
|
+
if (row.state === "done") return { icon: "✓", color: "success" };
|
|
31
|
+
if (row.state === "failed") return { icon: "✗", color: "error" };
|
|
32
|
+
return { icon: "!", color: "warning" };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function metadata(row: DelegateRow, width: number, coreWidth: number, now: number): string {
|
|
36
|
+
const model: string = widgetText(row.model.split("/").slice(1).join("/") || row.model);
|
|
37
|
+
const seen: string = row.lastActive === undefined ? "" : `file ${age(row.lastActive, now)}`;
|
|
38
|
+
const columns: string[] = [seen, model].filter(Boolean);
|
|
39
|
+
// Remove model, then file-age, before sacrificing identity/state/title.
|
|
40
|
+
while (columns.length && coreWidth + visibleWidth(` · ${columns.join(" · ")}`) > width) columns.pop();
|
|
41
|
+
return columns.length ? ` · ${columns.join(" · ")}` : "";
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function entry(row: DelegateRow, width: number, connector: string, theme: WidgetTheme, now: number): string {
|
|
45
|
+
const { icon, color } = appearance(row);
|
|
46
|
+
const state: string = widgetText(row.state);
|
|
47
|
+
const name: string = widgetText(row.identity);
|
|
48
|
+
if (width < 16) return truncateToWidth(`${theme.fg(color, icon)} ${theme.bold(name)}`, width);
|
|
49
|
+
const prefix: string = `${theme.fg("dim", connector)} ${theme.fg(color, icon)} `;
|
|
50
|
+
const suffix: string = ` · ${theme.fg(color, state)}`;
|
|
51
|
+
const title: string = widgetText(row.title ?? "");
|
|
52
|
+
const preferred: number = visibleWidth(prefix + name + suffix) + (title ? Math.min(24, visibleWidth(title)) + 2 : 0);
|
|
53
|
+
const extra: string = metadata(row, width, preferred, now);
|
|
54
|
+
const room: number = Math.max(1, width - visibleWidth(prefix + suffix + extra));
|
|
55
|
+
const identity: string = truncateToWidth(name, room);
|
|
56
|
+
const titleRoom: number = room - visibleWidth(identity) - 2;
|
|
57
|
+
const description: string = title && title !== name && titleRoom >= 4 ? ` ${truncateToWidth(title, titleRoom)}` : "";
|
|
58
|
+
return truncateToWidth(`${prefix}${theme.bold(identity)}${description}${suffix}${theme.fg("dim", extra)}`, width);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** One secondary line; descendant task counts take priority when both do not fit. */
|
|
62
|
+
export function activityLine(
|
|
63
|
+
row: DelegateRow,
|
|
64
|
+
width: number,
|
|
65
|
+
last: boolean,
|
|
66
|
+
theme: WidgetTheme,
|
|
67
|
+
summary = "",
|
|
68
|
+
): string | undefined {
|
|
69
|
+
const activity: string =
|
|
70
|
+
row.running && row.activity
|
|
71
|
+
? `${row.activity.kind === "response" ? "response: " : ""}${widgetText(row.activity.text)}`
|
|
72
|
+
: "";
|
|
73
|
+
const facts: string = widgetText(summary);
|
|
74
|
+
if (!activity && !facts) return;
|
|
75
|
+
const prefix: string = last ? " └─ " : "│ └─ ";
|
|
76
|
+
let text: string = [activity, facts].filter(Boolean).join(" · ");
|
|
77
|
+
if (facts && visibleWidth(prefix + text) > width) text = facts;
|
|
78
|
+
return truncateToWidth(theme.fg("dim", prefix + text), width);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export type WidgetRenderOptions = {
|
|
82
|
+
offset?: number;
|
|
83
|
+
selectedKey?: string;
|
|
84
|
+
focused?: boolean;
|
|
85
|
+
ancestors?: readonly AncestorRow[];
|
|
86
|
+
ancestryError?: string;
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
function selectableEntry(
|
|
90
|
+
row: DelegateRow,
|
|
91
|
+
width: number,
|
|
92
|
+
last: boolean,
|
|
93
|
+
theme: WidgetTheme,
|
|
94
|
+
now: number,
|
|
95
|
+
options: WidgetRenderOptions,
|
|
96
|
+
): string {
|
|
97
|
+
const gutter: string = options.focused ? " " : "";
|
|
98
|
+
const marker: string = options.focused && row.key === options.selectedKey ? theme.fg("accent", "▸ ") : gutter;
|
|
99
|
+
return marker + entry(row, width - visibleWidth(marker), last ? "└─" : "├─", theme, now);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function ancestorLines(ancestors: readonly AncestorRow[], options: WidgetRenderOptions, theme: WidgetTheme): string[] {
|
|
103
|
+
return ancestors.map((ancestor, index) => {
|
|
104
|
+
const gutter: string = options.focused ? " " : "";
|
|
105
|
+
const marker: string = options.focused && ancestor.key === options.selectedKey ? theme.fg("accent", "▸ ") : gutter;
|
|
106
|
+
const connector: string = index ? `${" ".repeat(index - 1)}└─ ` : "";
|
|
107
|
+
return (
|
|
108
|
+
marker +
|
|
109
|
+
connector +
|
|
110
|
+
"↑ " +
|
|
111
|
+
theme.bold(widgetText(ancestor.identity)) +
|
|
112
|
+
(ancestor.parent ? " (parent)" : "") +
|
|
113
|
+
(ancestor.title ? ` ${widgetText(ancestor.title)}` : "")
|
|
114
|
+
);
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function more(count: number): string {
|
|
119
|
+
return `${count} more direct delegate${count === 1 ? "" : "s"}`;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function childLines(
|
|
123
|
+
rows: readonly DelegateRow[],
|
|
124
|
+
cap: number,
|
|
125
|
+
width: number,
|
|
126
|
+
theme: WidgetTheme,
|
|
127
|
+
now: number,
|
|
128
|
+
options: WidgetRenderOptions,
|
|
129
|
+
): string[] {
|
|
130
|
+
const depth: number = options.ancestors?.length ?? 0;
|
|
131
|
+
const indent: string = " ".repeat(depth);
|
|
132
|
+
const gutter: string = options.focused ? " " : "";
|
|
133
|
+
const branch: string = depth ? `${" ".repeat(depth - 1)}└─ ` : "";
|
|
134
|
+
const lines: string[] = [gutter + branch + theme.fg("accent", theme.bold("Delegates"))];
|
|
135
|
+
const start: number = Math.max(0, Math.min(options.offset ?? 0, rows.length - cap));
|
|
136
|
+
const visible: readonly DelegateRow[] = rows.slice(start, start + cap);
|
|
137
|
+
const hidden: number = rows.length - start - visible.length;
|
|
138
|
+
if (start) lines.push(gutter + indent + theme.fg("dim", `├─ ↑ ${more(start)}`));
|
|
139
|
+
for (const [index, row] of visible.entries()) {
|
|
140
|
+
const last: boolean = index === visible.length - 1 && hidden === 0;
|
|
141
|
+
lines.push(indent + selectableEntry(row, width - indent.length, last, theme, now, options));
|
|
142
|
+
const secondary: string | undefined = activityLine(
|
|
143
|
+
row,
|
|
144
|
+
width - gutter.length - indent.length,
|
|
145
|
+
last,
|
|
146
|
+
theme,
|
|
147
|
+
row.summary,
|
|
148
|
+
);
|
|
149
|
+
if (secondary) lines.push(gutter + indent + secondary);
|
|
150
|
+
}
|
|
151
|
+
if (hidden) lines.push(gutter + indent + theme.fg("dim", `└─ +${more(hidden)}`));
|
|
152
|
+
return lines;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Pure layout: the cap counts child entries, not physical lines or hidden children. */
|
|
156
|
+
export function renderDelegateWidget(
|
|
157
|
+
rows: readonly DelegateRow[],
|
|
158
|
+
cap: number,
|
|
159
|
+
width: number,
|
|
160
|
+
theme: WidgetTheme,
|
|
161
|
+
now: number = Date.now(),
|
|
162
|
+
options: WidgetRenderOptions = {},
|
|
163
|
+
): string[] {
|
|
164
|
+
if (width <= 0) return [];
|
|
165
|
+
const ancestors: readonly AncestorRow[] = options.ancestors ?? [];
|
|
166
|
+
if (!ancestors.length && (cap === 0 || !rows.length) && !options.ancestryError) return [];
|
|
167
|
+
const gutter: string = options.focused ? " " : "";
|
|
168
|
+
const lines: string[] = ancestorLines(ancestors, options, theme);
|
|
169
|
+
if (options.ancestryError) lines.push(gutter + theme.fg("warning", widgetText(options.ancestryError)));
|
|
170
|
+
if (cap > 0 && rows.length) lines.push(...childLines(rows, cap, width, theme, now, options));
|
|
171
|
+
if (options.focused)
|
|
172
|
+
lines.push(
|
|
173
|
+
theme.fg(
|
|
174
|
+
"dim",
|
|
175
|
+
ancestors.some((ancestor) => ancestor.key === options.selectedKey)
|
|
176
|
+
? "↑↓ select · enter switch · esc prompt"
|
|
177
|
+
: "↑↓ select · enter details · esc prompt",
|
|
178
|
+
),
|
|
179
|
+
);
|
|
180
|
+
return lines.map((line) => truncateToWidth(line, width));
|
|
181
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/** Compact-list state only. Stable keys never confer action authority. */
|
|
2
|
+
export class WidgetSelection {
|
|
3
|
+
private keys: readonly string[] = [];
|
|
4
|
+
private ancestors = 0;
|
|
5
|
+
private cap = 0;
|
|
6
|
+
private index = 0;
|
|
7
|
+
private start = 0;
|
|
8
|
+
active = false;
|
|
9
|
+
|
|
10
|
+
get selectedKey(): string | undefined {
|
|
11
|
+
return this.keys[this.index];
|
|
12
|
+
}
|
|
13
|
+
get offset(): number {
|
|
14
|
+
return this.start;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
update(children: readonly string[], cap: number, ancestors: readonly string[] = []): void {
|
|
18
|
+
const selected: string | undefined = this.selectedKey;
|
|
19
|
+
const keys: readonly string[] = [...ancestors, ...(cap > 0 ? children : [])];
|
|
20
|
+
this.keys = keys;
|
|
21
|
+
this.ancestors = ancestors.length;
|
|
22
|
+
this.cap = cap;
|
|
23
|
+
const retained: number = selected === undefined ? -1 : keys.indexOf(selected);
|
|
24
|
+
this.index = retained >= 0 ? retained : Math.max(0, Math.min(this.index, keys.length - 1));
|
|
25
|
+
if (!keys.length) this.leave();
|
|
26
|
+
this.scroll();
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
enter(): boolean {
|
|
30
|
+
if (!this.keys.length) return false;
|
|
31
|
+
this.active = true;
|
|
32
|
+
this.index = Math.max(0, this.ancestors - 1);
|
|
33
|
+
this.scroll();
|
|
34
|
+
return true;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
leave(): void {
|
|
38
|
+
this.active = false;
|
|
39
|
+
this.start = 0;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
move(direction: -1 | 1): void {
|
|
43
|
+
if (direction === -1 && this.index === 0) this.leave();
|
|
44
|
+
else this.index = Math.max(0, Math.min(this.keys.length - 1, this.index + direction));
|
|
45
|
+
this.scroll();
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
private scroll(): void {
|
|
49
|
+
const child: number = this.index - this.ancestors;
|
|
50
|
+
if (!this.active || child < 0 || this.cap === 0) {
|
|
51
|
+
this.start = 0;
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
if (child < this.start) this.start = child;
|
|
55
|
+
if (child >= this.start + this.cap) this.start = child - this.cap + 1;
|
|
56
|
+
this.start = Math.max(0, Math.min(this.start, this.keys.length - this.ancestors - this.cap));
|
|
57
|
+
}
|
|
58
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
|
|
5
|
+
export type WidgetSettings = { placement: "aboveEditor" | "belowEditor"; maxEntries: number };
|
|
6
|
+
export const DEFAULT_WIDGET_SETTINGS: WidgetSettings = { placement: "aboveEditor", maxEntries: 3 };
|
|
7
|
+
|
|
8
|
+
/** Cosmetic settings never block delegation; the caller reports errors and uses defaults. */
|
|
9
|
+
export function widgetSettings(): WidgetSettings {
|
|
10
|
+
const file: string = join(getAgentDir(), "squire", "settings.json");
|
|
11
|
+
let value: unknown;
|
|
12
|
+
try {
|
|
13
|
+
value = JSON.parse(readFileSync(file, "utf8"));
|
|
14
|
+
} catch (error) {
|
|
15
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return { ...DEFAULT_WIDGET_SETTINGS };
|
|
16
|
+
throw new Error(`could not read widget settings in ${file}: ${String(error)}`);
|
|
17
|
+
}
|
|
18
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
19
|
+
throw new Error(`invalid widget settings in ${file}: expected an object`);
|
|
20
|
+
const settings: Record<string, unknown> = value as Record<string, unknown>;
|
|
21
|
+
const placement: unknown =
|
|
22
|
+
settings.delegateWidgetPlacement === undefined ? "above" : settings.delegateWidgetPlacement;
|
|
23
|
+
const cap: unknown = settings.delegateWidgetMaxEntries === undefined ? 3 : settings.delegateWidgetMaxEntries;
|
|
24
|
+
if (placement !== "above" && placement !== "below")
|
|
25
|
+
throw new Error(`invalid delegateWidgetPlacement in ${file}: expected above or below`);
|
|
26
|
+
if (typeof cap !== "number" || !Number.isSafeInteger(cap) || cap < 0)
|
|
27
|
+
throw new Error(`invalid delegateWidgetMaxEntries in ${file}: expected a nonnegative integer`);
|
|
28
|
+
return { placement: placement === "above" ? "aboveEditor" : "belowEditor", maxEntries: cap };
|
|
29
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// Counts delegated tasks (launch ids), not stable sessions or registry cap charges.
|
|
2
|
+
import { isStarted, type Recorded, sessionIdOf } from "./results.ts";
|
|
3
|
+
import type { TmuxWindow } from "./tmux.ts";
|
|
4
|
+
import type { VisibleFact, VisibleFacts } from "./tree-view.ts";
|
|
5
|
+
import { windowState } from "./windows.ts";
|
|
6
|
+
|
|
7
|
+
type Outcome = "done" | "failed" | "stopped" | "missing" | "running" | "unknown";
|
|
8
|
+
function outcome(fact: VisibleFact, windows: TmuxWindow[] | undefined): Outcome {
|
|
9
|
+
const record: Recorded | undefined = fact.semantic;
|
|
10
|
+
if (!record || !isStarted(record) || record.accountingOnly) return "unknown";
|
|
11
|
+
if (record.result) {
|
|
12
|
+
const status: string = record.result.status;
|
|
13
|
+
return status === "done" || status === "failed" || status === "stopped" ? status : "unknown";
|
|
14
|
+
}
|
|
15
|
+
if (record.closed || record.gone) return "missing";
|
|
16
|
+
if (fact.entry?.finished || fact.entry?.closed || fact.entry?.closing || fact.entry?.gone) return "unknown";
|
|
17
|
+
return windowState(windows, record) === "open" ? "running" : "unknown";
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function below(facts: VisibleFact[], sessionId: string): VisibleFact[] {
|
|
21
|
+
const sessions: Set<string> = new Set([sessionId]);
|
|
22
|
+
let changed = true;
|
|
23
|
+
while (changed) {
|
|
24
|
+
changed = false;
|
|
25
|
+
for (const { record } of facts) {
|
|
26
|
+
if (!isStarted(record) || !record.identity || !sessions.has(record.identity.parent)) continue;
|
|
27
|
+
const id: string = sessionIdOf(record);
|
|
28
|
+
if (!sessions.has(id)) {
|
|
29
|
+
sessions.add(id);
|
|
30
|
+
changed = true;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return facts.filter(({ record }) => isStarted(record) && record.identity && sessions.has(record.identity.parent));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function taskSummary(view: VisibleFacts, sessionId: string, windows: TmuxWindow[] | undefined): string {
|
|
38
|
+
const descendants: VisibleFact[] = below(view.facts, sessionId);
|
|
39
|
+
if (!descendants.length) return view.complete ? "" : "tasks: coverage unavailable";
|
|
40
|
+
const counts: Map<Outcome, number> = new Map();
|
|
41
|
+
const seen: Set<string> = new Set();
|
|
42
|
+
for (const fact of descendants) {
|
|
43
|
+
if (seen.has(fact.record.id)) continue;
|
|
44
|
+
seen.add(fact.record.id);
|
|
45
|
+
const state: Outcome = outcome(fact, windows);
|
|
46
|
+
counts.set(state, (counts.get(state) ?? 0) + 1);
|
|
47
|
+
}
|
|
48
|
+
const order: Outcome[] = ["failed", "stopped", "missing", "running", "unknown", "done"];
|
|
49
|
+
const labels: string[] = order.flatMap((state) => (counts.has(state) ? [`${counts.get(state)} ${state}`] : []));
|
|
50
|
+
return `${seen.size} task${seen.size === 1 ? "" : "s"}: ${labels.join(" · ")}${view.complete ? "" : " · partial coverage"}`;
|
|
51
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
// Read foreign native metadata without opening (and potentially migrating) a SessionManager.
|
|
2
|
+
import { closeSync, fstatSync, openSync, readSync } from "node:fs";
|
|
3
|
+
|
|
4
|
+
type Title = { name: string | undefined; modified: number };
|
|
5
|
+
type Cursor = {
|
|
6
|
+
ino: number;
|
|
7
|
+
size: number;
|
|
8
|
+
modified: number;
|
|
9
|
+
offset: number;
|
|
10
|
+
pending: Buffer;
|
|
11
|
+
skipLine: boolean;
|
|
12
|
+
header: boolean;
|
|
13
|
+
valid: boolean;
|
|
14
|
+
name: string | undefined;
|
|
15
|
+
};
|
|
16
|
+
const CHUNK_BYTES = 64 * 1024;
|
|
17
|
+
const SCAN_BYTES = 1024 * 1024;
|
|
18
|
+
|
|
19
|
+
/** Bounded incremental reader. Replacement, truncation and same-size edits restart the scan. */
|
|
20
|
+
export class WidgetTitleFiles {
|
|
21
|
+
private readonly files = new Map<string, Cursor>();
|
|
22
|
+
|
|
23
|
+
clear(): void {
|
|
24
|
+
this.files.clear();
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
read(path: string | undefined, sessionId: string): Title | undefined {
|
|
28
|
+
if (!path) return undefined;
|
|
29
|
+
let fd: number;
|
|
30
|
+
try {
|
|
31
|
+
fd = openSync(path, "r");
|
|
32
|
+
} catch {
|
|
33
|
+
this.files.delete(path);
|
|
34
|
+
return undefined;
|
|
35
|
+
}
|
|
36
|
+
try {
|
|
37
|
+
if (!this.matchesHeader(fd, sessionId)) {
|
|
38
|
+
this.files.delete(path);
|
|
39
|
+
return undefined;
|
|
40
|
+
}
|
|
41
|
+
const stat = fstatSync(fd);
|
|
42
|
+
let cursor: Cursor | undefined = this.files.get(path);
|
|
43
|
+
if (
|
|
44
|
+
!cursor ||
|
|
45
|
+
cursor.ino !== stat.ino ||
|
|
46
|
+
stat.size < cursor.size ||
|
|
47
|
+
(stat.size === cursor.size && stat.mtimeMs !== cursor.modified)
|
|
48
|
+
) {
|
|
49
|
+
cursor = {
|
|
50
|
+
ino: stat.ino,
|
|
51
|
+
size: stat.size,
|
|
52
|
+
modified: stat.mtimeMs,
|
|
53
|
+
offset: 0,
|
|
54
|
+
pending: Buffer.alloc(0),
|
|
55
|
+
skipLine: false,
|
|
56
|
+
header: false,
|
|
57
|
+
valid: false,
|
|
58
|
+
name: undefined,
|
|
59
|
+
};
|
|
60
|
+
this.files.set(path, cursor);
|
|
61
|
+
}
|
|
62
|
+
cursor.size = stat.size;
|
|
63
|
+
cursor.modified = stat.mtimeMs;
|
|
64
|
+
this.scan(fd, cursor, sessionId);
|
|
65
|
+
return cursor.valid ? { name: cursor.name, modified: cursor.modified } : undefined;
|
|
66
|
+
} catch {
|
|
67
|
+
this.files.delete(path);
|
|
68
|
+
return undefined;
|
|
69
|
+
} finally {
|
|
70
|
+
closeSync(fd);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
private matchesHeader(fd: number, sessionId: string): boolean {
|
|
75
|
+
const buffer: Buffer = Buffer.alloc(CHUNK_BYTES);
|
|
76
|
+
const count: number = readSync(fd, buffer, 0, buffer.length, 0);
|
|
77
|
+
const newline: number = buffer.subarray(0, count).indexOf(10);
|
|
78
|
+
if (newline < 0) return false;
|
|
79
|
+
const header: { type?: unknown; id?: unknown } = JSON.parse(buffer.subarray(0, newline).toString("utf8"));
|
|
80
|
+
return header?.type === "session" && header.id === sessionId;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
private scan(fd: number, cursor: Cursor, sessionId: string): void {
|
|
84
|
+
let budget: number = SCAN_BYTES;
|
|
85
|
+
while (cursor.offset < cursor.size && budget > 0) {
|
|
86
|
+
const buffer: Buffer = Buffer.alloc(Math.min(CHUNK_BYTES, cursor.size - cursor.offset, budget));
|
|
87
|
+
const count: number = readSync(fd, buffer, 0, buffer.length, cursor.offset);
|
|
88
|
+
if (!count) return;
|
|
89
|
+
cursor.offset += count;
|
|
90
|
+
budget -= count;
|
|
91
|
+
this.consume(cursor, buffer.subarray(0, count), sessionId);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
private consume(cursor: Cursor, bytes: Buffer, sessionId: string): void {
|
|
96
|
+
const combined: Buffer = Buffer.concat([cursor.pending, bytes]);
|
|
97
|
+
let start = 0;
|
|
98
|
+
for (let end: number = combined.indexOf(10); end !== -1; end = combined.indexOf(10, start)) {
|
|
99
|
+
if (!cursor.skipLine && end - start <= CHUNK_BYTES)
|
|
100
|
+
this.line(cursor, combined.subarray(start, end).toString("utf8"), sessionId);
|
|
101
|
+
cursor.skipLine = false;
|
|
102
|
+
start = end + 1;
|
|
103
|
+
}
|
|
104
|
+
cursor.pending = combined.subarray(start);
|
|
105
|
+
if (cursor.pending.length > CHUNK_BYTES) {
|
|
106
|
+
cursor.pending = Buffer.alloc(0);
|
|
107
|
+
cursor.skipLine = true;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
private line(cursor: Cursor, text: string, sessionId: string): void {
|
|
112
|
+
if (cursor.header && !text.includes('"session_info"')) return;
|
|
113
|
+
let entry: { type?: unknown; id?: unknown; name?: unknown };
|
|
114
|
+
try {
|
|
115
|
+
entry = JSON.parse(text);
|
|
116
|
+
} catch {
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
if (!entry || typeof entry !== "object") return;
|
|
120
|
+
if (!cursor.header) {
|
|
121
|
+
cursor.header = true;
|
|
122
|
+
cursor.valid = entry.type === "session" && entry.id === sessionId;
|
|
123
|
+
} else if (cursor.valid && entry.type === "session_info" && typeof entry.name === "string") {
|
|
124
|
+
cursor.name = entry.name;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
// Disposable native-title mirror for unflushed / --no-session runtimes.
|
|
2
|
+
// No membership, hierarchy, task state, ownership lock or activity lives here.
|
|
3
|
+
import { execFileSync } from "node:child_process";
|
|
4
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
5
|
+
import { readFileSync, statSync, unlinkSync } from "node:fs";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
8
|
+
import { writeState } from "./local-state.ts";
|
|
9
|
+
|
|
10
|
+
type Mirror = { version: 1; sessionId: string; generation: string; pid: number; start: string; name: string };
|
|
11
|
+
const MAX_BYTES = 16 * 1024;
|
|
12
|
+
|
|
13
|
+
function processStart(pid: number): string | undefined {
|
|
14
|
+
try {
|
|
15
|
+
const value: string = execFileSync("ps", ["-o", "lstart=", "-p", String(pid)], {
|
|
16
|
+
encoding: "utf8",
|
|
17
|
+
timeout: 1000,
|
|
18
|
+
// Compare identical formatting even across differently configured tmux environments.
|
|
19
|
+
env: { ...process.env, LC_ALL: "C", TZ: "UTC" },
|
|
20
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
21
|
+
}).trim();
|
|
22
|
+
return value || undefined;
|
|
23
|
+
} catch {
|
|
24
|
+
return undefined;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function titleMirrorPath(sessionId: string): string {
|
|
29
|
+
const key: string = createHash("sha256").update(sessionId).digest("hex");
|
|
30
|
+
return join(getAgentDir(), "squire", "session-titles", `${key}.json`);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function readMirror(sessionId: string): Mirror | undefined {
|
|
34
|
+
try {
|
|
35
|
+
const file: string = titleMirrorPath(sessionId);
|
|
36
|
+
if (statSync(file).size > MAX_BYTES) return undefined;
|
|
37
|
+
const value: Partial<Mirror> = JSON.parse(readFileSync(file, "utf8"));
|
|
38
|
+
if (
|
|
39
|
+
value.version !== 1 ||
|
|
40
|
+
value.sessionId !== sessionId ||
|
|
41
|
+
typeof value.name !== "string" ||
|
|
42
|
+
typeof value.generation !== "string" ||
|
|
43
|
+
!Number.isSafeInteger(value.pid) ||
|
|
44
|
+
typeof value.start !== "string"
|
|
45
|
+
)
|
|
46
|
+
return undefined;
|
|
47
|
+
return value as Mirror;
|
|
48
|
+
} catch {
|
|
49
|
+
return undefined;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Positive checks expire without sliding on reads; title edits themselves are never cached. */
|
|
54
|
+
export const TITLE_LIVENESS_CACHE_MS = 5000;
|
|
55
|
+
type Verdict = { matches: boolean; expires: number };
|
|
56
|
+
type MirrorChecks = { now: () => number; start: (pid: number) => string | undefined };
|
|
57
|
+
|
|
58
|
+
export class WidgetTitleMirrors {
|
|
59
|
+
private readonly checks: MirrorChecks;
|
|
60
|
+
private readonly verdicts = new Map<string, Verdict>();
|
|
61
|
+
|
|
62
|
+
constructor(checks: Partial<MirrorChecks> = {}) {
|
|
63
|
+
this.checks = { now: (): number => performance.now(), start: processStart, ...checks };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
clear(): void {
|
|
67
|
+
this.verdicts.clear();
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
read(sessionId: string): string | undefined {
|
|
71
|
+
const value: Mirror | undefined = readMirror(sessionId);
|
|
72
|
+
// An empty mirror can only fall back to recorded identity; never spawn ps for it.
|
|
73
|
+
if (!value?.name) return undefined;
|
|
74
|
+
const key: string = JSON.stringify([value.pid, value.start]);
|
|
75
|
+
const now: number = this.checks.now();
|
|
76
|
+
let verdict: Verdict | undefined = this.verdicts.get(key);
|
|
77
|
+
if (!verdict || now >= verdict.expires) {
|
|
78
|
+
verdict = { matches: this.checks.start(value.pid) === value.start, expires: now + TITLE_LIVENESS_CACHE_MS };
|
|
79
|
+
this.verdicts.set(key, verdict);
|
|
80
|
+
}
|
|
81
|
+
return verdict.matches ? value.name : undefined;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const titleMirrors = new WidgetTitleMirrors();
|
|
86
|
+
export function mirroredTitle(sessionId: string): string | undefined {
|
|
87
|
+
return titleMirrors.read(sessionId);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Best effort only. Runtime generations prevent old shutdown from removing a replacement. */
|
|
91
|
+
export function titlePublisher(sessionId: string): { publish(name: string | undefined): void; stop(): void } {
|
|
92
|
+
const generation: string = randomUUID();
|
|
93
|
+
const start: string | undefined = processStart(process.pid);
|
|
94
|
+
return {
|
|
95
|
+
publish(name: string | undefined): void {
|
|
96
|
+
if (!start) return;
|
|
97
|
+
try {
|
|
98
|
+
writeState(titleMirrorPath(sessionId), {
|
|
99
|
+
version: 1,
|
|
100
|
+
sessionId,
|
|
101
|
+
generation,
|
|
102
|
+
pid: process.pid,
|
|
103
|
+
start,
|
|
104
|
+
name: (name ?? "").slice(0, 2048),
|
|
105
|
+
} satisfies Mirror);
|
|
106
|
+
} catch {
|
|
107
|
+
/* Title availability must never affect a delegated task. */
|
|
108
|
+
}
|
|
109
|
+
},
|
|
110
|
+
stop(): void {
|
|
111
|
+
try {
|
|
112
|
+
if (readMirror(sessionId)?.generation === generation) unlinkSync(titleMirrorPath(sessionId));
|
|
113
|
+
} catch {
|
|
114
|
+
/* Already absent or unavailable. */
|
|
115
|
+
}
|
|
116
|
+
},
|
|
117
|
+
};
|
|
118
|
+
}
|