@timurproko/a1 0.2.7-dev.746 → 0.2.7-dev.767
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/bin/tab-launch-context.d.ts +6 -1
- package/bin/tab-launch-context.js +11 -2
- package/bin/ui.js +7 -3
- package/dist/app/session-shell/session-shell-root.d.ts +23 -0
- package/dist/app/session-shell/session-shell.js +112 -6
- package/dist/composition/owned-ui.d.ts +4 -1
- package/dist/composition/owned-ui.js +54 -8
- package/dist/contracts/owned-ui/session-backend.d.ts +1 -0
- package/dist/features/owned-ui/settings-app.js +8 -35
- package/dist/foundation/release/bootstrap.d.ts +1 -1
- package/dist/foundation/release/bootstrap.js +15 -4
- package/dist/foundation/release/dependency-certification.js +54 -19
- package/dist/foundation/release/progress-palette.generated.d.ts +2 -2
- package/dist/foundation/release/progress-palette.generated.js +2 -2
- package/dist/foundation/resident-tabs/bridge-client.d.ts +7 -2
- package/dist/foundation/resident-tabs/bridge-client.js +58 -16
- package/dist/foundation/resident-tabs/draft-checkpoint.d.ts +27 -0
- package/dist/foundation/resident-tabs/draft-checkpoint.js +145 -0
- package/dist/foundation/resident-tabs/ensure-terminal-host.d.ts +2 -0
- package/dist/foundation/resident-tabs/ensure-terminal-host.js +7 -1
- package/dist/foundation/resident-tabs/fault-injection.d.ts +8 -0
- package/dist/foundation/resident-tabs/fault-injection.js +26 -0
- package/dist/foundation/resident-tabs/index.d.ts +2 -1
- package/dist/foundation/resident-tabs/index.js +1 -1
- package/dist/foundation/resident-tabs/prompt-journal.d.ts +65 -0
- package/dist/foundation/resident-tabs/prompt-journal.js +214 -0
- package/dist/foundation/resident-tabs/recovery.d.ts +26 -0
- package/dist/foundation/resident-tabs/recovery.js +205 -0
- package/dist/foundation/resident-tabs/session-sync.d.ts +14 -0
- package/dist/foundation/resident-tabs/session-sync.js +37 -0
- package/dist/foundation/resident-tabs/settings.d.ts +9 -2
- package/dist/foundation/resident-tabs/settings.js +55 -7
- package/dist/foundation/session-writer-lock/index.d.ts +27 -0
- package/dist/foundation/session-writer-lock/index.js +197 -0
- package/dist/foundation/startup/startup-descriptor.js +2 -2
- package/dist/integrations/pi/components/resources/builtin-themes.d.ts +4 -4
- package/dist/integrations/pi/components/resources/builtin-themes.js +4 -4
- package/dist/integrations/pi/components/upstream/adjacent/core/keybindings.d.ts +1 -1
- package/dist/integrations/pi/components/upstream/adjacent/core/keybindings.js +17 -4
- package/dist/integrations/pi/components/upstream/assets/earendil-image.json +2 -2
- package/dist/integrations/pi/components/upstream/components/countdown-timer.d.ts +1 -1
- package/dist/integrations/pi/components/upstream/components/earendil-announcement.js +1 -1
- package/dist/integrations/pi/components/upstream/components/extension-editor.d.ts +1 -1
- package/dist/integrations/pi/components/upstream/components/extension-editor.js +1 -1
- package/dist/integrations/pi/components/upstream/components/extension-input.js +1 -1
- package/dist/integrations/pi/components/upstream/components/extension-selector.js +1 -1
- package/dist/integrations/pi/components/upstream/components/mermaid.js +1 -1
- package/dist/integrations/pi/components/upstream/components/owned-editor.d.ts +1 -1
- package/dist/integrations/pi/components/upstream/components/owned-editor.js +1 -1
- package/dist/integrations/pi/components/upstream/components/pi-logo.js +1 -1
- package/dist/integrations/pi/components/upstream/components/scoped-models-selector.d.ts +1 -1
- package/dist/integrations/pi/components/upstream/components/session-footer.js +1 -1
- package/dist/integrations/pi/components/upstream/components/session-selector-search.js +1 -1
- package/dist/integrations/pi/components/upstream/components/session-selector.js +1 -1
- package/dist/integrations/pi/components/upstream/components/skill-invocation-message.d.ts +1 -1
- package/dist/integrations/pi/components/upstream/components/skill-invocation-message.js +1 -1
- package/dist/integrations/pi/components/upstream/components/status-indicator.d.ts +2 -2
- package/dist/integrations/pi/components/upstream/components/status-indicator.js +3 -3
- package/dist/integrations/pi/components/upstream/components/themed-text.d.ts +1 -1
- package/dist/integrations/pi/components/upstream/components/themed-text.js +1 -1
- package/dist/integrations/pi/components/upstream/components/thinking-selector.d.ts +1 -1
- package/dist/integrations/pi/components/upstream/components/thinking-selector.js +1 -1
- package/dist/integrations/pi/components/upstream/components/tool-execution.d.ts +0 -2
- package/dist/integrations/pi/components/upstream/components/tool-execution.js +41 -20
- package/dist/integrations/pi/components/upstream/components/tree-selector.d.ts +1 -1
- package/dist/integrations/pi/components/upstream/components/tree-selector.js +1 -1
- package/dist/integrations/pi/components/upstream/components/trust-selector.js +1 -1
- package/dist/integrations/pi/components/upstream/external-editor.js +1 -1
- package/dist/integrations/pi/components/upstream/history/editor-core.d.ts +2 -2
- package/dist/integrations/pi/components/upstream/history/editor-core.js +5 -5
- package/dist/integrations/pi/components/upstream/history/kill-ring.d.ts +1 -1
- package/dist/integrations/pi/components/upstream/history/kill-ring.js +1 -1
- package/dist/integrations/pi/components/upstream/history/printable-key.js +1 -1
- package/dist/integrations/pi/components/upstream/history/text-helpers.js +1 -1
- package/dist/integrations/pi/components/upstream/history/undo-stack.d.ts +1 -1
- package/dist/integrations/pi/components/upstream/history/undo-stack.js +1 -1
- package/dist/integrations/pi/components/upstream/history/word-navigation.js +1 -1
- package/dist/integrations/pi/components/upstream/model-search.d.ts +1 -1
- package/dist/integrations/pi/components/upstream/program-status-reporter.d.ts +1 -1
- package/dist/integrations/pi/components/upstream/theme/theme-controller.d.ts +1 -1
- package/dist/integrations/pi/components/upstream/theme/theme.d.ts +3 -0
- package/dist/integrations/pi/components/upstream/theme/theme.js +24 -1
- package/dist/integrations/pi/engine/adapter.js +1 -0
- package/dist/integrations/pi/engine/pi-settings-metadata.json +2 -2
- package/dist/integrations/pi/engine/resources/changelog.json +4 -4
- package/dist/integrations/pi/engine/runtime-integration.js +102 -29
- package/dist/integrations/pi/engine/session-runtime.d.ts +0 -6
- package/dist/integrations/pi/engine/session-runtime.js +8 -3
- package/dist/integrations/pi/engine/session-selection.d.ts +3 -0
- package/dist/integrations/pi/engine/session-selection.js +45 -9
- package/dist/integrations/pi/engine/workflow-contexts.js +10 -1
- package/dist/integrations/pi/startup-public.js +152 -222
- package/dist/integrations/pi/startup-public.manifest.json +266 -177
- package/dist/integrations/pi/tui-runtime/damage-aware-terminal.d.ts +1 -1
- package/dist/integrations/pi/tui-runtime/damage-aware-terminal.js +1 -1
- package/dist/native/darwin-arm64/manifest.json +1 -1
- package/dist/native/linux-x64/manifest.json +1 -1
- package/dist/native/win32-x64/manifest.json +2 -2
- package/dist/native/win32-x64/process-guardian.exe +0 -0
- package/dist/runtime-payload-inventory.json +5349 -6035
- package/dist/ui/components/line-input.d.ts +1 -1
- package/dist/ui/components/prompt-input.d.ts +7 -5
- package/dist/ui/components/prompt-input.js +7 -7
- package/dist/ui/settings/declarations.d.ts +38 -2
- package/dist/ui/settings/declarations.js +29 -2
- package/dist/ui/settings/index.d.ts +4 -2
- package/dist/ui/settings/index.js +2 -1
- package/dist/ui/settings/manager.d.ts +9 -3
- package/dist/ui/settings/manager.js +81 -29
- package/dist/ui/settings/migrations.d.ts +6 -0
- package/dist/ui/settings/migrations.js +25 -0
- package/dist/ui/settings/profile-document.d.ts +26 -0
- package/dist/ui/settings/profile-document.js +164 -0
- package/dist/ui/settings/sections.js +4 -0
- package/docs/architecture/resident-tabs-requirements.md +1 -234
- package/docs/architecture/resident-tabs.md +30 -10
- package/docs/openspec-archive-automation.md +7 -7
- package/package.json +6 -4
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { chmod, mkdir, open, readFile, rename, stat, unlink } from "node:fs/promises";
|
|
3
|
+
import { dirname } from "node:path";
|
|
4
|
+
import { ResidentTabFaultInjection } from "./fault-injection.js";
|
|
5
|
+
const MAX_JOURNAL_BYTES = 8 * 1024 * 1024;
|
|
6
|
+
const MAX_RECORD_BYTES = 2 * 1024 * 1024;
|
|
7
|
+
export class PromptJournal {
|
|
8
|
+
path;
|
|
9
|
+
#faults;
|
|
10
|
+
#maximumBytes;
|
|
11
|
+
#operation = Promise.resolve();
|
|
12
|
+
#retired = new Set();
|
|
13
|
+
constructor(path, options = {}) {
|
|
14
|
+
this.path = path;
|
|
15
|
+
this.#faults = options.faults ?? new ResidentTabFaultInjection();
|
|
16
|
+
this.#maximumBytes = options.maximumBytes ?? MAX_JOURNAL_BYTES;
|
|
17
|
+
}
|
|
18
|
+
async initialize() {
|
|
19
|
+
await secureDirectory(dirname(this.path));
|
|
20
|
+
const handle = await open(this.path, "a", 0o600);
|
|
21
|
+
await handle.close();
|
|
22
|
+
if (process.platform !== "win32")
|
|
23
|
+
await chmod(this.path, 0o600);
|
|
24
|
+
for (const record of await this.read())
|
|
25
|
+
if (record.type === "retired")
|
|
26
|
+
this.#retired.add(record.id);
|
|
27
|
+
}
|
|
28
|
+
async submit(submission) {
|
|
29
|
+
const record = {
|
|
30
|
+
type: "submitted",
|
|
31
|
+
id: randomUUID(),
|
|
32
|
+
...submission,
|
|
33
|
+
...(submission.images === undefined ? {} : { images: submission.images }),
|
|
34
|
+
at: new Date().toISOString(),
|
|
35
|
+
};
|
|
36
|
+
await this.#append(record, "journal-before-append", "journal-after-append", "journal-after-sync");
|
|
37
|
+
return record;
|
|
38
|
+
}
|
|
39
|
+
async adopt(record) {
|
|
40
|
+
if ((await this.read()).some(existing => existing.type === "submitted" && existing.id === record.id))
|
|
41
|
+
return false;
|
|
42
|
+
await this.#append(record, "journal-before-append", "journal-after-append", "journal-after-sync");
|
|
43
|
+
return true;
|
|
44
|
+
}
|
|
45
|
+
async correlate(id, entryId) {
|
|
46
|
+
await this.#append({ type: "correlated", id, entryId }, "journal-before-correlation", "journal-after-correlation");
|
|
47
|
+
}
|
|
48
|
+
async settle(id, entryId) {
|
|
49
|
+
await this.#append({ type: "settled", id, entryId }, "journal-before-settled-record", "journal-after-settled-record");
|
|
50
|
+
}
|
|
51
|
+
async retire(id) {
|
|
52
|
+
if (this.#retired.has(id))
|
|
53
|
+
return;
|
|
54
|
+
await this.#append({ type: "retired", id }, "journal-during-retirement");
|
|
55
|
+
this.#retired.add(id);
|
|
56
|
+
}
|
|
57
|
+
async read() {
|
|
58
|
+
let text;
|
|
59
|
+
try {
|
|
60
|
+
text = await readFile(this.path, "utf8");
|
|
61
|
+
}
|
|
62
|
+
catch (error) {
|
|
63
|
+
if (error.code === "ENOENT")
|
|
64
|
+
return [];
|
|
65
|
+
throw error;
|
|
66
|
+
}
|
|
67
|
+
const records = [];
|
|
68
|
+
const lines = text.split("\n");
|
|
69
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
70
|
+
const line = lines[index];
|
|
71
|
+
if (!line)
|
|
72
|
+
continue;
|
|
73
|
+
try {
|
|
74
|
+
const value = JSON.parse(line);
|
|
75
|
+
records.push(decodeRecord(value));
|
|
76
|
+
}
|
|
77
|
+
catch (error) {
|
|
78
|
+
if (index === lines.length - 1 && !text.endsWith("\n"))
|
|
79
|
+
break;
|
|
80
|
+
throw error;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return records;
|
|
84
|
+
}
|
|
85
|
+
async pending(sessionEntries = []) {
|
|
86
|
+
const states = new Map();
|
|
87
|
+
const settled = new Set();
|
|
88
|
+
const retired = new Set();
|
|
89
|
+
for (const record of await this.read()) {
|
|
90
|
+
if (record.type === "submitted")
|
|
91
|
+
states.set(record.id, { submitted: record });
|
|
92
|
+
else if (record.type === "correlated") {
|
|
93
|
+
const state = states.get(record.id);
|
|
94
|
+
if (state !== undefined)
|
|
95
|
+
states.set(record.id, { ...state, correlatedEntryId: record.entryId });
|
|
96
|
+
}
|
|
97
|
+
else if (record.type === "settled")
|
|
98
|
+
settled.add(record.id);
|
|
99
|
+
else
|
|
100
|
+
retired.add(record.id);
|
|
101
|
+
}
|
|
102
|
+
const byId = new Map(sessionEntries.map(entry => [entry.entryId, entry]));
|
|
103
|
+
const pending = [];
|
|
104
|
+
const claimedEntries = new Set();
|
|
105
|
+
for (const [id, state] of states) {
|
|
106
|
+
if (retired.has(id) || settled.has(id)) {
|
|
107
|
+
await this.retire(id);
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
const correlated = state.correlatedEntryId === undefined ? undefined : byId.get(state.correlatedEntryId);
|
|
111
|
+
const textMatch = correlated ?? sessionEntries.find(entry => entry.text === state.submitted.text && !claimedEntries.has(entry.entryId));
|
|
112
|
+
if (textMatch !== undefined)
|
|
113
|
+
claimedEntries.add(textMatch.entryId);
|
|
114
|
+
if (textMatch?.settled === true)
|
|
115
|
+
await this.retire(id);
|
|
116
|
+
else
|
|
117
|
+
pending.push(state);
|
|
118
|
+
}
|
|
119
|
+
return pending;
|
|
120
|
+
}
|
|
121
|
+
async compactIfRetired() {
|
|
122
|
+
if ((await this.pending()).length !== 0)
|
|
123
|
+
return false;
|
|
124
|
+
await this.#serialize(async () => {
|
|
125
|
+
this.#faults.at("journal-during-compaction");
|
|
126
|
+
const temporary = `${this.path}.tmp-${process.pid}-${randomUUID()}`;
|
|
127
|
+
const handle = await open(temporary, "wx", 0o600);
|
|
128
|
+
try {
|
|
129
|
+
await handle.sync();
|
|
130
|
+
}
|
|
131
|
+
finally {
|
|
132
|
+
await handle.close();
|
|
133
|
+
}
|
|
134
|
+
await rename(temporary, this.path);
|
|
135
|
+
await syncDirectory(dirname(this.path));
|
|
136
|
+
});
|
|
137
|
+
return true;
|
|
138
|
+
}
|
|
139
|
+
async #append(record, ...points) {
|
|
140
|
+
const line = `${JSON.stringify(record)}\n`;
|
|
141
|
+
const bytes = Buffer.byteLength(line);
|
|
142
|
+
if (bytes > MAX_RECORD_BYTES)
|
|
143
|
+
throw new Error("resident prompt journal record exceeds its size limit");
|
|
144
|
+
await this.#serialize(async () => {
|
|
145
|
+
for (const point of points.slice(0, 1))
|
|
146
|
+
this.#faults.at(point);
|
|
147
|
+
const current = await stat(this.path).then(value => value.size, error => error.code === "ENOENT" ? 0 : Promise.reject(error));
|
|
148
|
+
if (current + bytes > this.#maximumBytes)
|
|
149
|
+
throw new Error("resident prompt journal is full");
|
|
150
|
+
const handle = await open(this.path, "a", 0o600);
|
|
151
|
+
try {
|
|
152
|
+
await handle.write(line, undefined, "utf8");
|
|
153
|
+
if (points[1] !== undefined)
|
|
154
|
+
this.#faults.at(points[1]);
|
|
155
|
+
await handle.sync();
|
|
156
|
+
if (points[2] !== undefined)
|
|
157
|
+
this.#faults.at(points[2]);
|
|
158
|
+
}
|
|
159
|
+
finally {
|
|
160
|
+
await handle.close();
|
|
161
|
+
}
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
async #serialize(operation) {
|
|
165
|
+
const next = this.#operation.then(operation, operation);
|
|
166
|
+
this.#operation = next.catch(() => undefined);
|
|
167
|
+
await next;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
export function classifyMissingSession(record, sessionExists) {
|
|
171
|
+
if (sessionExists)
|
|
172
|
+
return "available";
|
|
173
|
+
return record.fileExisted ? "missing-transcript" : "first-turn";
|
|
174
|
+
}
|
|
175
|
+
function decodeRecord(value) {
|
|
176
|
+
if (typeof value !== "object" || value === null)
|
|
177
|
+
throw new Error("invalid resident prompt journal record");
|
|
178
|
+
const record = value;
|
|
179
|
+
if (typeof record.id !== "string" || record.id.length === 0)
|
|
180
|
+
throw new Error("invalid resident prompt journal id");
|
|
181
|
+
if (record.type === "submitted") {
|
|
182
|
+
if (typeof record.sessionId !== "string" || !(typeof record.sessionFile === "string" || record.sessionFile === null)
|
|
183
|
+
|| typeof record.incarnation !== "number" || typeof record.fileExisted !== "boolean"
|
|
184
|
+
|| !["prompt", "steer", "follow-up"].includes(String(record.kind)) || typeof record.text !== "string" || typeof record.at !== "string") {
|
|
185
|
+
throw new Error("invalid submitted prompt journal record");
|
|
186
|
+
}
|
|
187
|
+
return record;
|
|
188
|
+
}
|
|
189
|
+
if ((record.type === "correlated" || record.type === "settled") && typeof record.entryId === "string")
|
|
190
|
+
return record;
|
|
191
|
+
if (record.type === "retired")
|
|
192
|
+
return record;
|
|
193
|
+
throw new Error("invalid resident prompt journal record type");
|
|
194
|
+
}
|
|
195
|
+
async function secureDirectory(path) {
|
|
196
|
+
await mkdir(path, { recursive: true, mode: 0o700 });
|
|
197
|
+
if (process.platform !== "win32")
|
|
198
|
+
await chmod(path, 0o700);
|
|
199
|
+
}
|
|
200
|
+
async function syncDirectory(path) {
|
|
201
|
+
if (process.platform === "win32")
|
|
202
|
+
return;
|
|
203
|
+
const handle = await open(path, "r");
|
|
204
|
+
try {
|
|
205
|
+
await handle.sync();
|
|
206
|
+
}
|
|
207
|
+
finally {
|
|
208
|
+
await handle.close();
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
export async function removeJournalTemporary(path) {
|
|
212
|
+
await unlink(path).catch(error => { if (error.code !== "ENOENT")
|
|
213
|
+
throw error; });
|
|
214
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { ResidentTabFaultInjection } from "./fault-injection.js";
|
|
2
|
+
import { type PromptSubmission, type SessionJournalEntry, type SubmittedPromptRecord } from "./prompt-journal.js";
|
|
3
|
+
export interface ResidentTabRecoveredInput {
|
|
4
|
+
readonly prompts: readonly SubmittedPromptRecord[];
|
|
5
|
+
readonly draft: string | null;
|
|
6
|
+
}
|
|
7
|
+
export interface ResidentTabAdmission extends Omit<PromptSubmission, "fileExisted" | "incarnation"> {
|
|
8
|
+
readonly incarnation: number;
|
|
9
|
+
}
|
|
10
|
+
export declare class ResidentTabRecovery {
|
|
11
|
+
#private;
|
|
12
|
+
constructor(dataDir: string, options?: {
|
|
13
|
+
readonly faults?: ResidentTabFaultInjection;
|
|
14
|
+
readonly onDegraded?: (degraded: boolean) => void;
|
|
15
|
+
});
|
|
16
|
+
get recovered(): ResidentTabRecoveredInput;
|
|
17
|
+
initialize(currentSessionFile?: string | null, currentSessionId?: string | null): Promise<ResidentTabRecoveredInput>;
|
|
18
|
+
admit(input: ResidentTabAdmission, timeoutMs?: number): Promise<SubmittedPromptRecord>;
|
|
19
|
+
dispatched(): void;
|
|
20
|
+
correlated(submissionId: string, entryId: string): Promise<void>;
|
|
21
|
+
checkpoint(text: string): void;
|
|
22
|
+
settled(sessionFile: string | null): Promise<void>;
|
|
23
|
+
gracefulStop(sessionFile: string | null): Promise<void>;
|
|
24
|
+
}
|
|
25
|
+
/** Reconcile only durable user/assistant ancestry; a torn final JSONL record is not authority. */
|
|
26
|
+
export declare function readSessionJournal(path: string): Promise<readonly SessionJournalEntry[]>;
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import { readFile, readdir, stat } from "node:fs/promises";
|
|
2
|
+
import { dirname, join, resolve } from "node:path";
|
|
3
|
+
import { DraftCheckpointer } from "./draft-checkpoint.js";
|
|
4
|
+
import { ResidentTabFaultInjection } from "./fault-injection.js";
|
|
5
|
+
import { PromptJournal } from "./prompt-journal.js";
|
|
6
|
+
import { SettledTurnSynchronizer, synchronizeSessionFile } from "./session-sync.js";
|
|
7
|
+
export class ResidentTabRecovery {
|
|
8
|
+
#journal;
|
|
9
|
+
#draft;
|
|
10
|
+
#synchronizer;
|
|
11
|
+
#faults;
|
|
12
|
+
#onDegraded;
|
|
13
|
+
#active = [];
|
|
14
|
+
#recovered = { prompts: [], draft: null };
|
|
15
|
+
constructor(dataDir, options = {}) {
|
|
16
|
+
const faults = options.faults ?? new ResidentTabFaultInjection();
|
|
17
|
+
this.#faults = faults;
|
|
18
|
+
this.#onDegraded = options.onDegraded ?? (() => { });
|
|
19
|
+
this.#journal = new PromptJournal(join(dataDir, "journal.jsonl"), { faults });
|
|
20
|
+
this.#draft = new DraftCheckpointer(join(dataDir, "draft.json"), { faults, onDegraded: this.#onDegraded });
|
|
21
|
+
this.#synchronizer = new SettledTurnSynchronizer(this.#journal, faults);
|
|
22
|
+
}
|
|
23
|
+
get recovered() { return this.#recovered; }
|
|
24
|
+
async initialize(currentSessionFile = null, currentSessionId = null) {
|
|
25
|
+
await this.#journal.initialize();
|
|
26
|
+
await adoptHistoricalJournals(dirname(dirname(this.#journal.path)), this.#journal, currentSessionFile, currentSessionId);
|
|
27
|
+
const records = await this.#journal.read();
|
|
28
|
+
const sessionFiles = new Set(records.flatMap(record => record.type === "submitted" && record.sessionFile !== null ? [record.sessionFile] : []));
|
|
29
|
+
if (currentSessionFile !== null)
|
|
30
|
+
sessionFiles.add(currentSessionFile);
|
|
31
|
+
const sessionEntries = (await Promise.all([...sessionFiles].map(readSessionJournal))).flat();
|
|
32
|
+
const [pending, draft] = await Promise.all([
|
|
33
|
+
this.#journal.pending(sessionEntries),
|
|
34
|
+
this.#draft.restore(),
|
|
35
|
+
]);
|
|
36
|
+
this.#recovered = { prompts: pending.map(value => value.submitted), draft: draft?.text ?? null };
|
|
37
|
+
return this.#recovered;
|
|
38
|
+
}
|
|
39
|
+
async admit(input, timeoutMs = 2_000) {
|
|
40
|
+
const fileExisted = input.sessionFile === null ? false : await stat(input.sessionFile).then(() => true, error => {
|
|
41
|
+
if (error.code === "ENOENT")
|
|
42
|
+
return false;
|
|
43
|
+
throw error;
|
|
44
|
+
});
|
|
45
|
+
let timer;
|
|
46
|
+
try {
|
|
47
|
+
return await Promise.race([
|
|
48
|
+
this.#journal.submit({ ...input, fileExisted }),
|
|
49
|
+
new Promise((_, reject) => {
|
|
50
|
+
timer = setTimeout(() => reject(new Error("resident prompt journal admission timed out")), timeoutMs);
|
|
51
|
+
timer.unref();
|
|
52
|
+
}),
|
|
53
|
+
]);
|
|
54
|
+
}
|
|
55
|
+
finally {
|
|
56
|
+
if (timer !== undefined)
|
|
57
|
+
clearTimeout(timer);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
dispatched() { this.#faults.at("journal-after-dispatch"); }
|
|
61
|
+
async correlated(submissionId, entryId) {
|
|
62
|
+
await this.#journal.correlate(submissionId, entryId);
|
|
63
|
+
this.#active.push({ id: submissionId, entryId });
|
|
64
|
+
}
|
|
65
|
+
checkpoint(text) { this.#draft.change(text); }
|
|
66
|
+
async settled(sessionFile) {
|
|
67
|
+
const active = this.#active.splice(0);
|
|
68
|
+
if (active.length === 0 || sessionFile === null)
|
|
69
|
+
return;
|
|
70
|
+
await this.#synchronizer.settleMany(sessionFile, active);
|
|
71
|
+
for (const entry of active)
|
|
72
|
+
await this.#journal.retire(entry.id);
|
|
73
|
+
await this.#journal.compactIfRetired();
|
|
74
|
+
}
|
|
75
|
+
async gracefulStop(sessionFile) {
|
|
76
|
+
await this.#draft.flush();
|
|
77
|
+
if (sessionFile !== null)
|
|
78
|
+
await synchronizeSessionFile(sessionFile);
|
|
79
|
+
await this.#synchronizer.flush();
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
const MAX_SESSION_RECONCILE_BYTES = 64 * 1024 * 1024;
|
|
83
|
+
const MAX_ADOPTION_AGE_MS = 7 * 24 * 60 * 60 * 1_000;
|
|
84
|
+
async function adoptHistoricalJournals(profileTabsRoot, target, currentSessionFile, currentSessionId) {
|
|
85
|
+
if (currentSessionFile === null && currentSessionId === null)
|
|
86
|
+
return;
|
|
87
|
+
const history = join(profileTabsRoot, "registry-history");
|
|
88
|
+
const names = await readdir(history).catch(() => []);
|
|
89
|
+
for (const name of names.filter(value => /^tabset-.+\.json$/.test(value)).sort().reverse()) {
|
|
90
|
+
const path = join(history, name);
|
|
91
|
+
const metadata = await stat(path).catch(() => null);
|
|
92
|
+
if (metadata === null || Date.now() - metadata.mtimeMs > MAX_ADOPTION_AGE_MS)
|
|
93
|
+
continue;
|
|
94
|
+
let document;
|
|
95
|
+
try {
|
|
96
|
+
document = JSON.parse(await readFile(path, "utf8"));
|
|
97
|
+
}
|
|
98
|
+
catch {
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
const tabs = typeof document === "object" && document !== null && Array.isArray(document.tabs)
|
|
102
|
+
? document.tabs : [];
|
|
103
|
+
for (const value of tabs) {
|
|
104
|
+
if (typeof value !== "object" || value === null)
|
|
105
|
+
continue;
|
|
106
|
+
const tab = value;
|
|
107
|
+
if (typeof tab.tabId !== "string")
|
|
108
|
+
continue;
|
|
109
|
+
const fileMatches = currentSessionFile !== null && typeof tab.sessionFile === "string"
|
|
110
|
+
&& resolve(tab.sessionFile) === resolve(currentSessionFile);
|
|
111
|
+
const sourcePath = join(profileTabsRoot, tab.tabId, "journal.jsonl");
|
|
112
|
+
if (resolve(sourcePath) === resolve(target.path))
|
|
113
|
+
continue;
|
|
114
|
+
if (!await stat(sourcePath).then(() => true, () => false))
|
|
115
|
+
continue;
|
|
116
|
+
const source = new PromptJournal(sourcePath);
|
|
117
|
+
await source.initialize();
|
|
118
|
+
const records = await source.read();
|
|
119
|
+
const submitted = records.filter((record) => record.type === "submitted");
|
|
120
|
+
const idMatches = currentSessionId !== null && submitted.some(record => record.sessionId === currentSessionId);
|
|
121
|
+
if (!fileMatches && !idMatches)
|
|
122
|
+
continue;
|
|
123
|
+
const pending = await source.pending(currentSessionFile === null ? [] : await readSessionJournal(currentSessionFile));
|
|
124
|
+
for (const entry of pending) {
|
|
125
|
+
await target.adopt(entry.submitted);
|
|
126
|
+
await source.retire(entry.submitted.id);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
/** Reconcile only durable user/assistant ancestry; a torn final JSONL record is not authority. */
|
|
132
|
+
export async function readSessionJournal(path) {
|
|
133
|
+
let text;
|
|
134
|
+
try {
|
|
135
|
+
const metadata = await stat(path);
|
|
136
|
+
if (metadata.size > MAX_SESSION_RECONCILE_BYTES)
|
|
137
|
+
throw new Error("resident session exceeds the recovery scan limit");
|
|
138
|
+
text = await readFile(path, "utf8");
|
|
139
|
+
}
|
|
140
|
+
catch (error) {
|
|
141
|
+
if (error.code === "ENOENT")
|
|
142
|
+
return [];
|
|
143
|
+
throw error;
|
|
144
|
+
}
|
|
145
|
+
const raw = [];
|
|
146
|
+
const lines = text.split("\n");
|
|
147
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
148
|
+
const line = lines[index];
|
|
149
|
+
if (line.length === 0)
|
|
150
|
+
continue;
|
|
151
|
+
let value;
|
|
152
|
+
try {
|
|
153
|
+
value = JSON.parse(line);
|
|
154
|
+
}
|
|
155
|
+
catch (error) {
|
|
156
|
+
if (index === lines.length - 1 && !text.endsWith("\n"))
|
|
157
|
+
break;
|
|
158
|
+
throw error;
|
|
159
|
+
}
|
|
160
|
+
if (typeof value !== "object" || value === null)
|
|
161
|
+
continue;
|
|
162
|
+
const record = value;
|
|
163
|
+
const message = typeof record.message === "object" && record.message !== null
|
|
164
|
+
? record.message
|
|
165
|
+
: null;
|
|
166
|
+
if (record.type !== "message" || typeof record.id !== "string" || message === null
|
|
167
|
+
|| typeof message.role !== "string")
|
|
168
|
+
continue;
|
|
169
|
+
const content = message.content;
|
|
170
|
+
const promptText = typeof content === "string" ? content : Array.isArray(content)
|
|
171
|
+
? content.flatMap(part => typeof part === "object" && part !== null
|
|
172
|
+
&& part.type === "text"
|
|
173
|
+
&& typeof part.text === "string"
|
|
174
|
+
? [part.text] : []).join("")
|
|
175
|
+
: "";
|
|
176
|
+
raw.push({
|
|
177
|
+
id: record.id,
|
|
178
|
+
parentId: typeof record.parentId === "string" ? record.parentId : null,
|
|
179
|
+
role: message.role,
|
|
180
|
+
text: promptText,
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
const byId = new Map(raw.map(record => [record.id, record]));
|
|
184
|
+
const settled = new Set();
|
|
185
|
+
for (const record of raw) {
|
|
186
|
+
if (record.role !== "assistant")
|
|
187
|
+
continue;
|
|
188
|
+
let parent = record.parentId;
|
|
189
|
+
const visited = new Set();
|
|
190
|
+
while (parent !== null && !visited.has(parent)) {
|
|
191
|
+
visited.add(parent);
|
|
192
|
+
const ancestor = byId.get(parent);
|
|
193
|
+
if (ancestor === undefined)
|
|
194
|
+
break;
|
|
195
|
+
if (ancestor.role === "user") {
|
|
196
|
+
settled.add(ancestor.id);
|
|
197
|
+
break;
|
|
198
|
+
}
|
|
199
|
+
parent = ancestor.parentId;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
return raw
|
|
203
|
+
.filter(record => record.role === "user")
|
|
204
|
+
.map(record => ({ entryId: record.id, text: record.text, settled: settled.has(record.id) }));
|
|
205
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { PromptJournal } from "./prompt-journal.js";
|
|
2
|
+
import { ResidentTabFaultInjection } from "./fault-injection.js";
|
|
3
|
+
export declare function synchronizeSessionFile(path: string, faults?: ResidentTabFaultInjection): Promise<void>;
|
|
4
|
+
/** Serializes settled-turn sync and journal ordering off the input path. */
|
|
5
|
+
export declare class SettledTurnSynchronizer {
|
|
6
|
+
#private;
|
|
7
|
+
constructor(journal: PromptJournal, faults?: ResidentTabFaultInjection);
|
|
8
|
+
settle(sessionFile: string, submissionId: string, entryId: string): Promise<void>;
|
|
9
|
+
settleMany(sessionFile: string, entries: readonly {
|
|
10
|
+
readonly id: string;
|
|
11
|
+
readonly entryId: string;
|
|
12
|
+
}[]): Promise<void>;
|
|
13
|
+
flush(): Promise<void>;
|
|
14
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { open } from "node:fs/promises";
|
|
2
|
+
import { ResidentTabFaultInjection } from "./fault-injection.js";
|
|
3
|
+
export async function synchronizeSessionFile(path, faults = new ResidentTabFaultInjection()) {
|
|
4
|
+
const handle = await open(path, "r+");
|
|
5
|
+
try {
|
|
6
|
+
faults.at("session-before-sync");
|
|
7
|
+
await handle.sync();
|
|
8
|
+
faults.at("session-after-sync");
|
|
9
|
+
}
|
|
10
|
+
finally {
|
|
11
|
+
await handle.close();
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
/** Serializes settled-turn sync and journal ordering off the input path. */
|
|
15
|
+
export class SettledTurnSynchronizer {
|
|
16
|
+
#journal;
|
|
17
|
+
#faults;
|
|
18
|
+
#queue = Promise.resolve();
|
|
19
|
+
constructor(journal, faults = new ResidentTabFaultInjection()) {
|
|
20
|
+
this.#journal = journal;
|
|
21
|
+
this.#faults = faults;
|
|
22
|
+
}
|
|
23
|
+
settle(sessionFile, submissionId, entryId) {
|
|
24
|
+
return this.settleMany(sessionFile, [{ id: submissionId, entryId }]);
|
|
25
|
+
}
|
|
26
|
+
settleMany(sessionFile, entries) {
|
|
27
|
+
const operation = this.#queue.then(async () => {
|
|
28
|
+
await synchronizeSessionFile(sessionFile, this.#faults);
|
|
29
|
+
this.#faults.at("journal-after-settle-sync");
|
|
30
|
+
for (const entry of entries)
|
|
31
|
+
await this.#journal.settle(entry.id, entry.entryId);
|
|
32
|
+
});
|
|
33
|
+
this.#queue = operation.catch(() => undefined);
|
|
34
|
+
return operation;
|
|
35
|
+
}
|
|
36
|
+
flush() { return this.#queue; }
|
|
37
|
+
}
|
|
@@ -1,2 +1,9 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
1
|
+
export interface ResidentTabLaunchSettings {
|
|
2
|
+
readonly residentTabs: boolean;
|
|
3
|
+
readonly maxConcurrentStarts: number;
|
|
4
|
+
readonly unresponsiveRestartSeconds: number;
|
|
5
|
+
readonly stallNoticeSeconds: number;
|
|
6
|
+
}
|
|
7
|
+
/** Read only resident-launch values before any UI or extension module is loaded. */
|
|
8
|
+
export declare function readResidentTabLaunchSettings(profileDir: string, namespace?: string, legacyConfigDir?: string, profileId?: string): Promise<ResidentTabLaunchSettings>;
|
|
9
|
+
export declare function readResidentTabsSetting(profileDir: string, namespace?: string, legacyConfigDir?: string, profileId?: string): Promise<boolean>;
|
|
@@ -1,17 +1,65 @@
|
|
|
1
1
|
import { readFile } from "node:fs/promises";
|
|
2
2
|
import { resolve } from "node:path";
|
|
3
|
-
import { parseOwnedUiSettingsDocument,
|
|
3
|
+
import { OWNED_SETTING_DECLARATIONS, OWNED_UI_SETTINGS_MIGRATIONS, OWNED_UI_SETTINGS_VERSION, normalizeOwnedUiSettingsValues, parseOwnedUiSettingsDocument, readProfileSettingsNamespace, repairProfileSettingsDocument, resolveOwnedUiSettings, settingValue, } from "../../ui/settings/index.js";
|
|
4
4
|
const MAX_SETTINGS_BYTES = 256 * 1024;
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
5
|
+
const DEFAULTS = Object.freeze({
|
|
6
|
+
residentTabs: false,
|
|
7
|
+
maxConcurrentStarts: 2,
|
|
8
|
+
unresponsiveRestartSeconds: 120,
|
|
9
|
+
stallNoticeSeconds: 300,
|
|
10
|
+
});
|
|
11
|
+
/** Read only resident-launch values before any UI or extension module is loaded. */
|
|
12
|
+
export async function readResidentTabLaunchSettings(profileDir, namespace = "a1", legacyConfigDir, profileId = namespace) {
|
|
13
|
+
const sharedFile = resolve(profileDir, "settings.json");
|
|
14
|
+
try {
|
|
15
|
+
repairProfileSettingsDocument(sharedFile);
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
// Compatibility: recovery is opportunistic; resident launch falls back when bytes stay invalid.
|
|
19
|
+
}
|
|
20
|
+
const shared = readProfileSettingsNamespace(sharedFile, namespace);
|
|
21
|
+
if (shared.status === "invalid")
|
|
22
|
+
return DEFAULTS;
|
|
23
|
+
if (shared.status === "available") {
|
|
24
|
+
const compatibility = normalizeOwnedUiSettingsValues(shared.value);
|
|
25
|
+
return compatibility.values === null
|
|
26
|
+
? DEFAULTS
|
|
27
|
+
: resolveLaunchValues({ version: OWNED_UI_SETTINGS_VERSION, values: compatibility.values }, []);
|
|
28
|
+
}
|
|
29
|
+
const file = resolve(legacyConfigDir ?? profileDir, "settings", `${profileId}.json`);
|
|
8
30
|
try {
|
|
9
31
|
const raw = await readFile(file, "utf8");
|
|
10
32
|
if (Buffer.byteLength(raw, "utf8") > MAX_SETTINGS_BYTES)
|
|
11
|
-
return
|
|
12
|
-
|
|
33
|
+
return DEFAULTS;
|
|
34
|
+
const document = parseOwnedUiSettingsDocument(raw);
|
|
35
|
+
return document === null ? DEFAULTS : resolveLaunchValues(document, OWNED_UI_SETTINGS_MIGRATIONS);
|
|
13
36
|
}
|
|
14
37
|
catch {
|
|
15
|
-
return
|
|
38
|
+
return DEFAULTS;
|
|
16
39
|
}
|
|
17
40
|
}
|
|
41
|
+
export async function readResidentTabsSetting(profileDir, namespace = "a1", legacyConfigDir, profileId = namespace) {
|
|
42
|
+
return (await readResidentTabLaunchSettings(profileDir, namespace, legacyConfigDir, profileId)).residentTabs;
|
|
43
|
+
}
|
|
44
|
+
function resolveLaunchValues(document, migrations) {
|
|
45
|
+
const resolution = resolveOwnedUiSettings({
|
|
46
|
+
declarations: [
|
|
47
|
+
OWNED_SETTING_DECLARATIONS.residentTabs,
|
|
48
|
+
OWNED_SETTING_DECLARATIONS.tabsMaxConcurrentStarts,
|
|
49
|
+
OWNED_SETTING_DECLARATIONS.tabsUnresponsiveRestartSeconds,
|
|
50
|
+
OWNED_SETTING_DECLARATIONS.tabsStallNoticeSeconds,
|
|
51
|
+
],
|
|
52
|
+
migrations,
|
|
53
|
+
document,
|
|
54
|
+
currentVersion: OWNED_UI_SETTINGS_VERSION,
|
|
55
|
+
});
|
|
56
|
+
const maximum = settingValue(resolution, "tabsMaxConcurrentStarts");
|
|
57
|
+
const unresponsive = settingValue(resolution, "tabsUnresponsiveRestartSeconds");
|
|
58
|
+
const stall = settingValue(resolution, "tabsStallNoticeSeconds");
|
|
59
|
+
return {
|
|
60
|
+
residentTabs: settingValue(resolution, "residentTabs") === true,
|
|
61
|
+
maxConcurrentStarts: typeof maximum === "number" ? maximum : DEFAULTS.maxConcurrentStarts,
|
|
62
|
+
unresponsiveRestartSeconds: typeof unresponsive === "number" ? unresponsive : DEFAULTS.unresponsiveRestartSeconds,
|
|
63
|
+
stallNoticeSeconds: typeof stall === "number" ? stall : DEFAULTS.stallNoticeSeconds,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
export interface NativeLock {
|
|
2
|
+
readonly bound: boolean;
|
|
3
|
+
bindCreated(path: string): void;
|
|
4
|
+
release(): void;
|
|
5
|
+
}
|
|
6
|
+
export interface LockBackend {
|
|
7
|
+
acquire(path: string): NativeLock;
|
|
8
|
+
}
|
|
9
|
+
/** Reports that a canonical session identity already has a live writer. */
|
|
10
|
+
export declare class SessionWriterConflict extends Error {
|
|
11
|
+
readonly path: string;
|
|
12
|
+
readonly holderKind: string;
|
|
13
|
+
constructor(path: string, holderKind?: string);
|
|
14
|
+
}
|
|
15
|
+
export interface SessionWriterHandle {
|
|
16
|
+
readonly path: string;
|
|
17
|
+
readonly bound: boolean;
|
|
18
|
+
bind(createdPath?: string): void;
|
|
19
|
+
release(): void;
|
|
20
|
+
}
|
|
21
|
+
export declare function acquireExisting(path: string): SessionWriterHandle;
|
|
22
|
+
export declare function reserveNew(parent: string, name: string): SessionWriterHandle;
|
|
23
|
+
export declare function acquireSessionWriter(path: string): SessionWriterHandle;
|
|
24
|
+
export declare function bind(handle: SessionWriterHandle, createdPath?: string): void;
|
|
25
|
+
export declare function release(handle: SessionWriterHandle): void;
|
|
26
|
+
/** Test-only backend seam; passing undefined restores native discovery. */
|
|
27
|
+
export declare function setSessionWriterLockBackendForTest(value: LockBackend | undefined): void;
|