inplan 0.1.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/LICENSE +661 -0
- package/app/main/index.js +769 -0
- package/app/preload/index.mjs +92 -0
- package/app/renderer/assets/index-BqLfQmz0.js +52343 -0
- package/app/renderer/assets/index-C67b7qA1.css +272 -0
- package/app/renderer/index.html +13 -0
- package/bin/cli.js +1409 -0
- package/package.json +29 -0
- package/skill/SKILL.md +149 -0
|
@@ -0,0 +1,769 @@
|
|
|
1
|
+
import { app, BrowserWindow, ipcMain, dialog, shell } from "electron";
|
|
2
|
+
import { execFile } from "node:child_process";
|
|
3
|
+
import { mkdirSync as mkdirSync$1, readdirSync, unlinkSync, readFileSync as readFileSync$1, existsSync as existsSync$1, writeFileSync as writeFileSync$1 } from "node:fs";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { join as join$1, dirname as dirname$1, resolve as resolve$1 } from "node:path";
|
|
6
|
+
import { appendFileSync, existsSync, readFileSync, mkdirSync, writeFileSync, watch, watchFile, unwatchFile, statSync, openSync, readSync, closeSync } from "fs";
|
|
7
|
+
import { resolve, basename, relative, dirname, join } from "path";
|
|
8
|
+
import { createHash } from "crypto";
|
|
9
|
+
import { homedir } from "os";
|
|
10
|
+
import { homedir as homedir$1 } from "node:os";
|
|
11
|
+
import __cjs_mod__ from "node:module";
|
|
12
|
+
const __filename = import.meta.filename;
|
|
13
|
+
const __dirname = import.meta.dirname;
|
|
14
|
+
const require2 = __cjs_mod__.createRequire(import.meta.url);
|
|
15
|
+
var CONTROL_LOG_VERSION = 1;
|
|
16
|
+
var LogEventType = {
|
|
17
|
+
EditorPid: "editor_pid",
|
|
18
|
+
ModeChanged: "mode_changed",
|
|
19
|
+
DocumentEdited: "document_edited",
|
|
20
|
+
TurnEnded: "turn_ended",
|
|
21
|
+
AgentRevised: "agent_revised",
|
|
22
|
+
AgentRevisionProposed: "agent_revision_proposed",
|
|
23
|
+
SettingsChanged: "settings_changed",
|
|
24
|
+
AgentDoneSuggested: "agent_done_suggested",
|
|
25
|
+
ReloadSuggested: "reload_suggested",
|
|
26
|
+
/** In-window navigation: the editor followed a Markdown link to a sibling doc.
|
|
27
|
+
* Payload `{ path }` is the new doc; the attached agent's `wait` steps down and
|
|
28
|
+
* the human's agent re-attaches there (the local analogue of save-locally). */
|
|
29
|
+
NavigatedTo: "navigated_to",
|
|
30
|
+
SessionClosed: "session_closed"
|
|
31
|
+
};
|
|
32
|
+
function serializeLogEntry(entry) {
|
|
33
|
+
return JSON.stringify(entry);
|
|
34
|
+
}
|
|
35
|
+
function parseLogLine(line) {
|
|
36
|
+
return JSON.parse(line);
|
|
37
|
+
}
|
|
38
|
+
function parseLog(text) {
|
|
39
|
+
return text.split("\n").map((l) => l.trim()).filter((l) => l.length > 0).map(parseLogLine);
|
|
40
|
+
}
|
|
41
|
+
function readLog(path) {
|
|
42
|
+
if (!existsSync(path)) return [];
|
|
43
|
+
return parseLog(readFileSync(path, "utf8"));
|
|
44
|
+
}
|
|
45
|
+
function readLogIncrement(path, byteOffset) {
|
|
46
|
+
if (!existsSync(path)) return { entries: [], offset: 0, reset: byteOffset !== 0 };
|
|
47
|
+
const size = statSync(path).size;
|
|
48
|
+
if (size < byteOffset) return { entries: [], offset: 0, reset: true };
|
|
49
|
+
if (size === byteOffset) return { entries: [], offset: byteOffset, reset: false };
|
|
50
|
+
const len = size - byteOffset;
|
|
51
|
+
const fd = openSync(path, "r");
|
|
52
|
+
try {
|
|
53
|
+
const buf = Buffer.allocUnsafe(len);
|
|
54
|
+
const got = readSync(fd, buf, 0, len, byteOffset);
|
|
55
|
+
const chunk = buf.toString("utf8", 0, got);
|
|
56
|
+
const lastNl = chunk.lastIndexOf("\n");
|
|
57
|
+
if (lastNl === -1) return { entries: [], offset: byteOffset, reset: false };
|
|
58
|
+
const complete = chunk.slice(0, lastNl + 1);
|
|
59
|
+
return { entries: parseLog(complete), offset: byteOffset + Buffer.byteLength(complete, "utf8"), reset: false };
|
|
60
|
+
} finally {
|
|
61
|
+
closeSync(fd);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
function appendLog(path, entry) {
|
|
65
|
+
const existing = readLog(path);
|
|
66
|
+
const seq = existing.length ? existing[existing.length - 1].seq + 1 : 1;
|
|
67
|
+
const full = {
|
|
68
|
+
seq,
|
|
69
|
+
ts: entry.ts ?? (/* @__PURE__ */ new Date()).toISOString(),
|
|
70
|
+
actor: entry.actor,
|
|
71
|
+
type: entry.type,
|
|
72
|
+
...entry.payload !== void 0 ? { payload: entry.payload } : {}
|
|
73
|
+
};
|
|
74
|
+
appendFileSync(path, serializeLogEntry(full) + "\n");
|
|
75
|
+
return full;
|
|
76
|
+
}
|
|
77
|
+
function isProcessAlive(pid) {
|
|
78
|
+
try {
|
|
79
|
+
process.kill(pid, 0);
|
|
80
|
+
return true;
|
|
81
|
+
} catch (err) {
|
|
82
|
+
return err.code === "EPERM";
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
var FsControlChannel = class {
|
|
86
|
+
constructor(paths) {
|
|
87
|
+
this.paths = paths;
|
|
88
|
+
}
|
|
89
|
+
paths;
|
|
90
|
+
// Incremental-read state: bytes already consumed + the entries parsed so far.
|
|
91
|
+
// Lets readSince parse only newly-appended bytes (O(new)) rather than re-reading
|
|
92
|
+
// the whole log each poll, while still seeing appends from other processes
|
|
93
|
+
// (the editor) since it re-stats to the current size every call.
|
|
94
|
+
byteOffset = 0;
|
|
95
|
+
parsed = [];
|
|
96
|
+
append(event) {
|
|
97
|
+
return Promise.resolve(appendLog(this.paths.logPath, event));
|
|
98
|
+
}
|
|
99
|
+
readSince(cursor) {
|
|
100
|
+
let inc = readLogIncrement(this.paths.logPath, this.byteOffset);
|
|
101
|
+
if (inc.reset) {
|
|
102
|
+
this.parsed = [];
|
|
103
|
+
this.byteOffset = 0;
|
|
104
|
+
inc = readLogIncrement(this.paths.logPath, 0);
|
|
105
|
+
}
|
|
106
|
+
if (inc.entries.length) this.parsed.push(...inc.entries);
|
|
107
|
+
this.byteOffset = inc.offset;
|
|
108
|
+
const entries = this.parsed.filter((e) => e.seq > cursor);
|
|
109
|
+
const next = this.parsed.length ? this.parsed[this.parsed.length - 1].seq : cursor;
|
|
110
|
+
return Promise.resolve({ entries, cursor: next });
|
|
111
|
+
}
|
|
112
|
+
subscribe(onChange) {
|
|
113
|
+
let timer = null;
|
|
114
|
+
const fire = () => {
|
|
115
|
+
if (timer) clearTimeout(timer);
|
|
116
|
+
timer = setTimeout(onChange, 50);
|
|
117
|
+
};
|
|
118
|
+
try {
|
|
119
|
+
const watcher = watch(this.paths.logPath, fire);
|
|
120
|
+
return () => {
|
|
121
|
+
if (timer) clearTimeout(timer);
|
|
122
|
+
watcher.close();
|
|
123
|
+
};
|
|
124
|
+
} catch {
|
|
125
|
+
const listener = () => fire();
|
|
126
|
+
watchFile(this.paths.logPath, { interval: 200 }, listener);
|
|
127
|
+
return () => {
|
|
128
|
+
if (timer) clearTimeout(timer);
|
|
129
|
+
unwatchFile(this.paths.logPath, listener);
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
getCursor() {
|
|
134
|
+
if (!existsSync(this.paths.cursorPath)) return Promise.resolve(0);
|
|
135
|
+
const n = Number(readFileSync(this.paths.cursorPath, "utf8").trim());
|
|
136
|
+
return Promise.resolve(Number.isFinite(n) ? n : 0);
|
|
137
|
+
}
|
|
138
|
+
setCursor(seq) {
|
|
139
|
+
writeFileSync(this.paths.cursorPath, String(seq));
|
|
140
|
+
return Promise.resolve();
|
|
141
|
+
}
|
|
142
|
+
claimLock(token) {
|
|
143
|
+
writeFileSync(this.paths.waitLockPath, token);
|
|
144
|
+
return Promise.resolve();
|
|
145
|
+
}
|
|
146
|
+
isSuperseded(token) {
|
|
147
|
+
if (!existsSync(this.paths.waitLockPath)) return Promise.resolve(false);
|
|
148
|
+
return Promise.resolve(readFileSync(this.paths.waitLockPath, "utf8").trim() !== token);
|
|
149
|
+
}
|
|
150
|
+
presence() {
|
|
151
|
+
const log = readLog(this.paths.logPath);
|
|
152
|
+
for (let i = log.length - 1; i >= 0; i--) {
|
|
153
|
+
if (log[i].type === LogEventType.EditorPid) {
|
|
154
|
+
const pid = log[i].payload?.pid;
|
|
155
|
+
return Promise.resolve(typeof pid === "number" && isProcessAlive(pid));
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
return Promise.resolve(false);
|
|
159
|
+
}
|
|
160
|
+
};
|
|
161
|
+
function sidecarRoot() {
|
|
162
|
+
if (process.env.INPLAN_SIDECAR_DIR) return process.env.INPLAN_SIDECAR_DIR;
|
|
163
|
+
const home = process.env.INPLAN_HOME || join(homedir(), ".inplan");
|
|
164
|
+
return join(home, "sidecars");
|
|
165
|
+
}
|
|
166
|
+
function repoRootOf(absFile) {
|
|
167
|
+
let dir = dirname(absFile);
|
|
168
|
+
for (; ; ) {
|
|
169
|
+
if (existsSync(join(dir, ".git"))) return dir;
|
|
170
|
+
const parent = dirname(dir);
|
|
171
|
+
if (parent === dir) return null;
|
|
172
|
+
dir = parent;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
function docPaths(file) {
|
|
176
|
+
const abs = resolve(file);
|
|
177
|
+
const root = repoRootOf(abs);
|
|
178
|
+
const raw = root ? `${basename(root)}/${relative(root, abs)}` : `${basename(dirname(abs))}/${basename(abs)}`;
|
|
179
|
+
const label = raw.replace(/[/\\]+/g, "-").replace(/[^A-Za-z0-9._-]/g, "_");
|
|
180
|
+
const key = `${label}-${createHash("sha1").update(abs).digest("hex").slice(0, 12)}`;
|
|
181
|
+
const controlDir = join(sidecarRoot(), key);
|
|
182
|
+
return {
|
|
183
|
+
file: abs,
|
|
184
|
+
controlDir,
|
|
185
|
+
logPath: join(controlDir, "log.jsonl"),
|
|
186
|
+
canonicalPath: join(controlDir, "canonical.md"),
|
|
187
|
+
backupsDir: join(controlDir, "backups"),
|
|
188
|
+
proposedPath: join(controlDir, "proposed.md"),
|
|
189
|
+
cursorPath: join(controlDir, "cursor"),
|
|
190
|
+
waitLockPath: join(controlDir, "waitlock"),
|
|
191
|
+
waitDebugPath: join(controlDir, "wait-debug.log"),
|
|
192
|
+
statusPath: join(controlDir, "status.json")
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
var DEFAULT_SETTINGS = { autoResolve: true };
|
|
196
|
+
function globalSettingsPath() {
|
|
197
|
+
const base = process.env.INPLAN_HOME || join(homedir(), ".inplan");
|
|
198
|
+
return join(base, "settings.json");
|
|
199
|
+
}
|
|
200
|
+
function readGlobalSettings() {
|
|
201
|
+
const path = globalSettingsPath();
|
|
202
|
+
if (!existsSync(path)) return { ...DEFAULT_SETTINGS };
|
|
203
|
+
try {
|
|
204
|
+
const raw = JSON.parse(readFileSync(path, "utf8"));
|
|
205
|
+
return { ...DEFAULT_SETTINGS, ...raw };
|
|
206
|
+
} catch {
|
|
207
|
+
return { ...DEFAULT_SETTINGS };
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
function writeGlobalSettings(settings) {
|
|
211
|
+
const path = globalSettingsPath();
|
|
212
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
213
|
+
writeFileSync(path, `${JSON.stringify(settings, null, 2)}
|
|
214
|
+
`);
|
|
215
|
+
}
|
|
216
|
+
const MAX_BACKUPS = 25;
|
|
217
|
+
class Session {
|
|
218
|
+
paths;
|
|
219
|
+
backupSeq = 0;
|
|
220
|
+
closed = false;
|
|
221
|
+
/** Latest unsaved state reported by the renderer, for the close prompt. */
|
|
222
|
+
pendingDirty = false;
|
|
223
|
+
pendingContent = "";
|
|
224
|
+
setPending(dirty, content) {
|
|
225
|
+
this.pendingDirty = dirty;
|
|
226
|
+
this.pendingContent = content;
|
|
227
|
+
}
|
|
228
|
+
get hasUnsaved() {
|
|
229
|
+
return this.pendingDirty;
|
|
230
|
+
}
|
|
231
|
+
get pending() {
|
|
232
|
+
return this.pendingContent;
|
|
233
|
+
}
|
|
234
|
+
constructor(file) {
|
|
235
|
+
this.paths = docPaths(file);
|
|
236
|
+
mkdirSync$1(this.paths.controlDir, { recursive: true });
|
|
237
|
+
mkdirSync$1(this.paths.backupsDir, { recursive: true });
|
|
238
|
+
this.backupSeq = this.backupSeqs().at(-1) ?? 0;
|
|
239
|
+
}
|
|
240
|
+
/** Existing `autosave-<n>.md` sequence numbers in the backups dir, ascending. */
|
|
241
|
+
backupSeqs() {
|
|
242
|
+
return readdirSync(this.paths.backupsDir).map((name) => /^autosave-(\d+)\.md$/.exec(name)?.[1]).filter((n) => n != null).map(Number).sort((a, b) => a - b);
|
|
243
|
+
}
|
|
244
|
+
/** Keep only the most recent MAX_BACKUPS autosave files. */
|
|
245
|
+
pruneBackups() {
|
|
246
|
+
const seqs = this.backupSeqs();
|
|
247
|
+
for (const n of seqs.slice(0, Math.max(0, seqs.length - MAX_BACKUPS))) {
|
|
248
|
+
try {
|
|
249
|
+
unlinkSync(join$1(this.paths.backupsDir, `autosave-${n}.md`));
|
|
250
|
+
} catch {
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
load() {
|
|
255
|
+
const content = readFileSync$1(this.paths.file, "utf8");
|
|
256
|
+
if (!existsSync$1(this.paths.canonicalPath)) {
|
|
257
|
+
writeFileSync$1(this.paths.canonicalPath, content);
|
|
258
|
+
}
|
|
259
|
+
return { path: this.paths.file, content };
|
|
260
|
+
}
|
|
261
|
+
save(content, options) {
|
|
262
|
+
if (options.kind === "backup") {
|
|
263
|
+
const path = join$1(this.paths.backupsDir, `autosave-${++this.backupSeq}.md`);
|
|
264
|
+
writeFileSync$1(path, content);
|
|
265
|
+
this.pruneBackups();
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
writeFileSync$1(this.paths.file, content);
|
|
269
|
+
writeFileSync$1(this.paths.canonicalPath, content);
|
|
270
|
+
if (options.kind === "apply") return;
|
|
271
|
+
const type = options.cadence === "turn" ? LogEventType.TurnEnded : LogEventType.DocumentEdited;
|
|
272
|
+
appendLog(this.paths.logPath, { actor: "user", type, payload: { bytes: content.length } });
|
|
273
|
+
}
|
|
274
|
+
/** Record this editor process's own pid (authoritative for liveness checks). */
|
|
275
|
+
logEditorPid(pid) {
|
|
276
|
+
appendLog(this.paths.logPath, { actor: "agent", type: LogEventType.EditorPid, payload: { pid, v: CONTROL_LOG_VERSION } });
|
|
277
|
+
}
|
|
278
|
+
logAction(type, payload) {
|
|
279
|
+
appendLog(this.paths.logPath, { actor: "user", type, ...payload !== void 0 ? { payload } : {} });
|
|
280
|
+
}
|
|
281
|
+
/** Record that the editor followed a link away to `path`, so the agent attached
|
|
282
|
+
* to THIS doc steps down (its `wait` returns `navigated`) and re-attaches there. */
|
|
283
|
+
logNavigatedAway(path) {
|
|
284
|
+
appendLog(this.paths.logPath, { actor: "user", type: LogEventType.NavigatedTo, payload: { path } });
|
|
285
|
+
}
|
|
286
|
+
setMode(cadence, acceptance) {
|
|
287
|
+
appendLog(this.paths.logPath, { actor: "user", type: LogEventType.ModeChanged, payload: { cadence, acceptance } });
|
|
288
|
+
}
|
|
289
|
+
/** Global, cross-session settings (loaded by the renderer on launch). */
|
|
290
|
+
getSettings() {
|
|
291
|
+
return readGlobalSettings();
|
|
292
|
+
}
|
|
293
|
+
/** Persist global settings AND log the change so the agent wakes and the trail records it. */
|
|
294
|
+
setSettings(settings) {
|
|
295
|
+
writeGlobalSettings(settings);
|
|
296
|
+
appendLog(this.paths.logPath, { actor: "user", type: LogEventType.SettingsChanged, payload: settings });
|
|
297
|
+
}
|
|
298
|
+
complete(content) {
|
|
299
|
+
writeFileSync$1(this.paths.file, content);
|
|
300
|
+
writeFileSync$1(this.paths.canonicalPath, content);
|
|
301
|
+
}
|
|
302
|
+
/** The parked Review-mode proposal, if one is pending (the file exists ⇔ undecided). */
|
|
303
|
+
pendingProposal() {
|
|
304
|
+
return existsSync$1(this.paths.proposedPath) ? readFileSync$1(this.paths.proposedPath, "utf8") : null;
|
|
305
|
+
}
|
|
306
|
+
/** Discard the parked proposal once the human has accepted/rejected it. */
|
|
307
|
+
clearProposal() {
|
|
308
|
+
if (existsSync$1(this.paths.proposedPath)) unlinkSync(this.paths.proposedPath);
|
|
309
|
+
}
|
|
310
|
+
/** Record why the session ended (logged at most once) so the agent's `wait` can report it. */
|
|
311
|
+
logClose(reason) {
|
|
312
|
+
if (this.closed) return;
|
|
313
|
+
this.closed = true;
|
|
314
|
+
appendLog(this.paths.logPath, { actor: "user", type: LogEventType.SessionClosed, payload: { reason } });
|
|
315
|
+
}
|
|
316
|
+
/**
|
|
317
|
+
* Drive the editor from the control-log protocol — NOT a raw working-file watch
|
|
318
|
+
* — so the desktop behaves identically to the web/cloud `pump`. The CLI gate is
|
|
319
|
+
* the single source of truth: it appends `document_edited` (agent) only for an
|
|
320
|
+
* accepted edit (the working file then holds it), and `agent_revision_proposed`
|
|
321
|
+
* for a parked Review proposal (with the working file already reverted to
|
|
322
|
+
* canonical). Reacting to those events — instead of watching the file — means we
|
|
323
|
+
* never adopt the agent's body write before the gate decides, which is what used
|
|
324
|
+
* to produce the empty-diff race in Review (the baseline stayed put).
|
|
325
|
+
*/
|
|
326
|
+
watch(handlers) {
|
|
327
|
+
let lastLogSeq = readLog(this.paths.logPath).at(-1)?.seq ?? 0;
|
|
328
|
+
const onLog = () => {
|
|
329
|
+
const entries = readLog(this.paths.logPath).filter((e) => e.seq > lastLogSeq);
|
|
330
|
+
if (entries.length) lastLogSeq = entries.at(-1).seq;
|
|
331
|
+
this.dispatchLog(entries, handlers);
|
|
332
|
+
};
|
|
333
|
+
return new FsControlChannel(this.paths).subscribe(onLog);
|
|
334
|
+
}
|
|
335
|
+
/**
|
|
336
|
+
* Fan a batch of new control-log entries out to the editor callbacks. Pure given
|
|
337
|
+
* the on-disk sidecars (no watchers) — exposed for tests. An accepted agent edit
|
|
338
|
+
* (`document_edited`) loads the working file (it now holds the revision); a parked
|
|
339
|
+
* proposal (`agent_revision_proposed`) loads `proposed.md` for the diff — and is
|
|
340
|
+
* NOT loaded as an external change, so the editor keeps its doc + the canonical
|
|
341
|
+
* baseline, and the diff is never empty.
|
|
342
|
+
*/
|
|
343
|
+
dispatchLog(entries, handlers) {
|
|
344
|
+
if (!entries.length) return;
|
|
345
|
+
if (entries.some((e) => e.type === LogEventType.AgentDoneSuggested)) handlers.onAgentDone();
|
|
346
|
+
if (entries.some((e) => e.type === LogEventType.ReloadSuggested)) handlers.onReload();
|
|
347
|
+
if (entries.some((e) => e.type === LogEventType.AgentRevisionProposed)) {
|
|
348
|
+
const proposed = this.pendingProposal();
|
|
349
|
+
if (proposed != null) handlers.onProposal(proposed);
|
|
350
|
+
}
|
|
351
|
+
if (entries.some((e) => e.actor === "agent" && e.type === LogEventType.DocumentEdited) && existsSync$1(this.paths.file)) {
|
|
352
|
+
try {
|
|
353
|
+
handlers.onExternalChange(readFileSync$1(this.paths.file, "utf8"));
|
|
354
|
+
} catch {
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
if (entries.some((e) => e.actor === "agent" && (e.type === LogEventType.AgentRevised || e.type === LogEventType.DocumentEdited))) {
|
|
358
|
+
handlers.onAgentActive();
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
const EN = { code: "en", label: "English" };
|
|
363
|
+
const ENGLISH_ONLY = { locale: "en", catalogs: {}, available: [EN] };
|
|
364
|
+
function createI18nController(deps) {
|
|
365
|
+
const base = deps.home || process.env.INPLAN_HOME || join$1(homedir$1(), ".inplan");
|
|
366
|
+
const cachePath = join$1(base, "i18n-cache.json");
|
|
367
|
+
let snap = { ...ENGLISH_ONLY };
|
|
368
|
+
let gen = 0;
|
|
369
|
+
function persist() {
|
|
370
|
+
try {
|
|
371
|
+
mkdirSync$1(dirname$1(cachePath), { recursive: true });
|
|
372
|
+
writeFileSync$1(cachePath, JSON.stringify(snap));
|
|
373
|
+
} catch {
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
function loadCache() {
|
|
377
|
+
try {
|
|
378
|
+
const raw = JSON.parse(readFileSync$1(cachePath, "utf8"));
|
|
379
|
+
if (raw && typeof raw.locale === "string" && raw.catalogs && Array.isArray(raw.available)) {
|
|
380
|
+
snap = { locale: raw.locale, catalogs: raw.catalogs, available: raw.available };
|
|
381
|
+
}
|
|
382
|
+
} catch {
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
async function token() {
|
|
386
|
+
try {
|
|
387
|
+
const { stdout } = await deps.runCli(["token"]);
|
|
388
|
+
return JSON.parse(stdout.trim() || "{}").token ?? null;
|
|
389
|
+
} catch {
|
|
390
|
+
return null;
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
async function getJson(path, tok) {
|
|
394
|
+
try {
|
|
395
|
+
const res = await fetch(`${deps.cloudBase}${path}`, { headers: { authorization: `Bearer ${tok}` } });
|
|
396
|
+
return res.ok ? await res.json() : null;
|
|
397
|
+
} catch {
|
|
398
|
+
return null;
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
async function fetchCatalog(code, tok) {
|
|
402
|
+
const j = await getJson(`/api/v1/i18n/catalog?locale=${encodeURIComponent(code)}`, tok);
|
|
403
|
+
return j?.catalog ?? null;
|
|
404
|
+
}
|
|
405
|
+
function presentEnglish() {
|
|
406
|
+
snap = { ...ENGLISH_ONLY };
|
|
407
|
+
deps.onChange();
|
|
408
|
+
}
|
|
409
|
+
function toEnglish() {
|
|
410
|
+
snap = { ...ENGLISH_ONLY };
|
|
411
|
+
persist();
|
|
412
|
+
deps.onChange();
|
|
413
|
+
}
|
|
414
|
+
async function bootstrap() {
|
|
415
|
+
const myGen = ++gen;
|
|
416
|
+
loadCache();
|
|
417
|
+
const cachedSnap = snap;
|
|
418
|
+
snap = { ...ENGLISH_ONLY };
|
|
419
|
+
const tok = await token();
|
|
420
|
+
if (myGen !== gen) return;
|
|
421
|
+
if (!tok) return presentEnglish();
|
|
422
|
+
snap = cachedSnap;
|
|
423
|
+
const manifest = await getJson("/api/v1/i18n", tok);
|
|
424
|
+
if (myGen !== gen) return;
|
|
425
|
+
if (!manifest) return deps.onChange();
|
|
426
|
+
if (!manifest.entitled) return toEnglish();
|
|
427
|
+
snap.available = [EN, ...manifest.locales ?? []];
|
|
428
|
+
if (snap.locale !== "en" && !snap.available.some((l) => l.code === snap.locale)) snap.locale = "en";
|
|
429
|
+
if (snap.locale !== "en" && !snap.catalogs[snap.locale]) {
|
|
430
|
+
const cat = await fetchCatalog(snap.locale, tok);
|
|
431
|
+
if (myGen !== gen) return;
|
|
432
|
+
if (cat) snap.catalogs = { ...snap.catalogs, [snap.locale]: cat };
|
|
433
|
+
else snap.locale = "en";
|
|
434
|
+
}
|
|
435
|
+
persist();
|
|
436
|
+
deps.onChange();
|
|
437
|
+
}
|
|
438
|
+
async function setLocale(code) {
|
|
439
|
+
const myGen = ++gen;
|
|
440
|
+
if (code === snap.locale) return;
|
|
441
|
+
if (code !== "en" && !snap.available.some((l) => l.code === code)) return;
|
|
442
|
+
if (code !== "en" && !snap.catalogs[code]) {
|
|
443
|
+
const tok = await token();
|
|
444
|
+
if (myGen !== gen || !tok) return;
|
|
445
|
+
const cat = await fetchCatalog(code, tok);
|
|
446
|
+
if (myGen !== gen || !cat) return;
|
|
447
|
+
snap.catalogs = { ...snap.catalogs, [code]: cat };
|
|
448
|
+
}
|
|
449
|
+
snap.locale = code;
|
|
450
|
+
persist();
|
|
451
|
+
deps.onChange();
|
|
452
|
+
}
|
|
453
|
+
return { getSnapshot: () => snap, bootstrap, setLocale };
|
|
454
|
+
}
|
|
455
|
+
const __dirname$1 = dirname$1(fileURLToPath(import.meta.url));
|
|
456
|
+
process.on("uncaughtException", (err) => {
|
|
457
|
+
process.stderr.write(`[inplan] uncaught exception: ${err instanceof Error ? err.stack ?? err.message : String(err)}
|
|
458
|
+
`);
|
|
459
|
+
});
|
|
460
|
+
process.on("unhandledRejection", (reason) => {
|
|
461
|
+
process.stderr.write(`[inplan] unhandled rejection: ${String(reason)}
|
|
462
|
+
`);
|
|
463
|
+
});
|
|
464
|
+
function resolveTargetFile() {
|
|
465
|
+
const args = process.argv.slice(app.isPackaged ? 1 : 2);
|
|
466
|
+
const candidate = args.find((a) => !a.startsWith("-") && a.endsWith(".md")) ?? args.find((a) => !a.startsWith("-"));
|
|
467
|
+
if (!candidate) return null;
|
|
468
|
+
const abs = resolve$1(candidate);
|
|
469
|
+
return existsSync$1(abs) ? abs : null;
|
|
470
|
+
}
|
|
471
|
+
let session = null;
|
|
472
|
+
let win = null;
|
|
473
|
+
let stopWatching = null;
|
|
474
|
+
const navHistory = [];
|
|
475
|
+
let navIdx = -1;
|
|
476
|
+
function runCli(args) {
|
|
477
|
+
return new Promise((res) => {
|
|
478
|
+
const cli = process.env.INPLAN_CLI;
|
|
479
|
+
if (!cli) {
|
|
480
|
+
res({ code: -1, stdout: "", stderr: "INPLAN_CLI not set" });
|
|
481
|
+
return;
|
|
482
|
+
}
|
|
483
|
+
execFile(
|
|
484
|
+
process.execPath,
|
|
485
|
+
[cli, ...args],
|
|
486
|
+
{ env: { ...process.env, ELECTRON_RUN_AS_NODE: "1" } },
|
|
487
|
+
(err, stdout, stderr) => {
|
|
488
|
+
const code = err && typeof err.code === "number" ? Number(err.code) : err ? 1 : 0;
|
|
489
|
+
res({ code, stdout, stderr });
|
|
490
|
+
}
|
|
491
|
+
);
|
|
492
|
+
});
|
|
493
|
+
}
|
|
494
|
+
const CLOUD_BASE = (process.env.INPLAN_WEB_URL || "https://inplan.ai").replace(/\/$/, "");
|
|
495
|
+
const i18n = createI18nController({
|
|
496
|
+
runCli: (args) => runCli(args).then((r) => ({ stdout: r.stdout })),
|
|
497
|
+
cloudBase: CLOUD_BASE,
|
|
498
|
+
onChange: () => win?.webContents.send("i18n:changed")
|
|
499
|
+
});
|
|
500
|
+
let cloudReachable = false;
|
|
501
|
+
let lastCloudProbe = 0;
|
|
502
|
+
const CLOUD_PROBE_TTL_MS = 6e4;
|
|
503
|
+
async function probeCloud() {
|
|
504
|
+
lastCloudProbe = Date.now();
|
|
505
|
+
const ctrl = new AbortController();
|
|
506
|
+
const timer = setTimeout(() => ctrl.abort(), 2500);
|
|
507
|
+
let ok = false;
|
|
508
|
+
try {
|
|
509
|
+
const res = await fetch(`${CLOUD_BASE}/api/v1/healthz`, { signal: ctrl.signal });
|
|
510
|
+
ok = res.ok;
|
|
511
|
+
} catch {
|
|
512
|
+
ok = false;
|
|
513
|
+
} finally {
|
|
514
|
+
clearTimeout(timer);
|
|
515
|
+
}
|
|
516
|
+
if (ok !== cloudReachable) {
|
|
517
|
+
cloudReachable = ok;
|
|
518
|
+
win?.webContents.send("profile:changed");
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
function ensureCloudProbe() {
|
|
522
|
+
if (Date.now() - lastCloudProbe > CLOUD_PROBE_TTL_MS) void probeCloud();
|
|
523
|
+
}
|
|
524
|
+
async function readProfile() {
|
|
525
|
+
ensureCloudProbe();
|
|
526
|
+
const r = await runCli(["whoami"]);
|
|
527
|
+
let who = {};
|
|
528
|
+
try {
|
|
529
|
+
who = JSON.parse(r.stdout.trim() || "{}");
|
|
530
|
+
} catch {
|
|
531
|
+
}
|
|
532
|
+
if (who.signedIn) {
|
|
533
|
+
return {
|
|
534
|
+
user: { name: who.email ?? "Signed in", ...who.email ? { email: who.email } : {} },
|
|
535
|
+
agentLocation: null,
|
|
536
|
+
// desktop has no live presence room; the web derives it from awareness
|
|
537
|
+
actions: [
|
|
538
|
+
{ id: "collaborate", label: "Collaborate on Cloud", primary: true },
|
|
539
|
+
{ id: "signout", label: "Sign out", danger: true }
|
|
540
|
+
]
|
|
541
|
+
};
|
|
542
|
+
}
|
|
543
|
+
return { user: null, agentLocation: null, actions: cloudReachable ? [{ id: "signin", label: "Sign in…" }] : [] };
|
|
544
|
+
}
|
|
545
|
+
async function collaborateOnCloud() {
|
|
546
|
+
if (!session) return;
|
|
547
|
+
if (session.hasUnsaved) session.complete(session.pending);
|
|
548
|
+
const r = await runCli(["upload", session.paths.file]);
|
|
549
|
+
let out = {};
|
|
550
|
+
try {
|
|
551
|
+
out = JSON.parse(r.stdout.trim() || "{}");
|
|
552
|
+
} catch {
|
|
553
|
+
}
|
|
554
|
+
if (out.status !== "uploaded" || !out.cloudDocId) {
|
|
555
|
+
dialog.showMessageBoxSync(win, { type: "error", message: "Couldn't move this plan to the cloud.", detail: r.stderr.trim() || "Are you signed in? Run `inplan login`." });
|
|
556
|
+
return;
|
|
557
|
+
}
|
|
558
|
+
const url = out.locator ? `${CLOUD_BASE}/docs/${out.locator.org}/${out.locator.repo}/${out.locator.path}` : `${CLOUD_BASE}/?doc=${out.cloudDocId}`;
|
|
559
|
+
await shell.openExternal(url);
|
|
560
|
+
session.logClose("window_closed");
|
|
561
|
+
app.quit();
|
|
562
|
+
}
|
|
563
|
+
async function checkForUpdate() {
|
|
564
|
+
const r = await runCli(["update", "--check"]);
|
|
565
|
+
try {
|
|
566
|
+
const out = JSON.parse(r.stdout.trim() || "{}");
|
|
567
|
+
if (out.updateAvailable && out.latest) {
|
|
568
|
+
win?.webContents.send("app:update-available", { current: out.current ?? "?", latest: out.latest });
|
|
569
|
+
}
|
|
570
|
+
} catch {
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
function watchSession() {
|
|
574
|
+
if (!session) return null;
|
|
575
|
+
const s = session;
|
|
576
|
+
return s.watch({
|
|
577
|
+
onExternalChange: (content) => win?.webContents.send("doc:external-change", { path: s.paths.file, content }),
|
|
578
|
+
onAgentDone: () => win?.webContents.send("agent:done"),
|
|
579
|
+
onAgentActive: () => win?.webContents.send("agent:active"),
|
|
580
|
+
onProposal: (content) => win?.webContents.send("doc:proposal", { content }),
|
|
581
|
+
onReload: () => win?.webContents.send("agent:reload")
|
|
582
|
+
});
|
|
583
|
+
}
|
|
584
|
+
function sendNavState() {
|
|
585
|
+
win?.webContents.send("nav:state", { canBack: navIdx > 0, canForward: navIdx < navHistory.length - 1 });
|
|
586
|
+
}
|
|
587
|
+
function navigateTo(file) {
|
|
588
|
+
if (!session || !win) return false;
|
|
589
|
+
if (resolve$1(file) === resolve$1(session.paths.file)) return false;
|
|
590
|
+
if (session.hasUnsaved) {
|
|
591
|
+
const choice = dialog.showMessageBoxSync(win, {
|
|
592
|
+
type: "question",
|
|
593
|
+
buttons: ["Save", "Don't Save", "Cancel"],
|
|
594
|
+
defaultId: 0,
|
|
595
|
+
cancelId: 2,
|
|
596
|
+
message: "Save changes before leaving this plan?",
|
|
597
|
+
detail: "Your edits this turn aren't saved to the plan yet."
|
|
598
|
+
});
|
|
599
|
+
if (choice === 2) return false;
|
|
600
|
+
if (choice === 0) session.complete(session.pending);
|
|
601
|
+
}
|
|
602
|
+
session.logNavigatedAway(file);
|
|
603
|
+
stopWatching?.();
|
|
604
|
+
session = new Session(file);
|
|
605
|
+
session.logEditorPid(process.pid);
|
|
606
|
+
stopWatching = watchSession();
|
|
607
|
+
win.webContents.send("doc:navigated", session.load());
|
|
608
|
+
return true;
|
|
609
|
+
}
|
|
610
|
+
function createWindow() {
|
|
611
|
+
win = new BrowserWindow({
|
|
612
|
+
width: 1200,
|
|
613
|
+
height: 800,
|
|
614
|
+
title: "inplan",
|
|
615
|
+
webPreferences: {
|
|
616
|
+
preload: join$1(__dirname$1, "../preload/index.mjs"),
|
|
617
|
+
sandbox: false
|
|
618
|
+
}
|
|
619
|
+
});
|
|
620
|
+
win.webContents.setWindowOpenHandler(({ url }) => {
|
|
621
|
+
if (/^https?:/.test(url)) void shell.openExternal(url);
|
|
622
|
+
return { action: "deny" };
|
|
623
|
+
});
|
|
624
|
+
win.webContents.on("will-navigate", (event, url) => {
|
|
625
|
+
if (url !== win?.webContents.getURL()) {
|
|
626
|
+
event.preventDefault();
|
|
627
|
+
if (/^https?:/.test(url)) void shell.openExternal(url);
|
|
628
|
+
}
|
|
629
|
+
});
|
|
630
|
+
if (process.env.ELECTRON_RENDERER_URL) {
|
|
631
|
+
void win.loadURL(process.env.ELECTRON_RENDERER_URL);
|
|
632
|
+
} else {
|
|
633
|
+
void win.loadFile(join$1(__dirname$1, "../renderer/index.html"));
|
|
634
|
+
}
|
|
635
|
+
win.webContents.once("did-finish-load", () => {
|
|
636
|
+
void checkForUpdate();
|
|
637
|
+
void probeCloud();
|
|
638
|
+
void i18n.bootstrap();
|
|
639
|
+
});
|
|
640
|
+
stopWatching = watchSession();
|
|
641
|
+
let forceClose = false;
|
|
642
|
+
win.on("close", (e) => {
|
|
643
|
+
if (forceClose || !session?.hasUnsaved) return;
|
|
644
|
+
e.preventDefault();
|
|
645
|
+
const choice = dialog.showMessageBoxSync(win, {
|
|
646
|
+
type: "question",
|
|
647
|
+
buttons: ["Save", "Don't Save", "Cancel"],
|
|
648
|
+
defaultId: 0,
|
|
649
|
+
cancelId: 2,
|
|
650
|
+
message: "Save changes before closing?",
|
|
651
|
+
detail: "Your edits this turn aren't saved to the plan yet."
|
|
652
|
+
});
|
|
653
|
+
if (choice === 2) return;
|
|
654
|
+
if (choice === 0) {
|
|
655
|
+
session.complete(session.pending);
|
|
656
|
+
session.logClose("completed");
|
|
657
|
+
} else {
|
|
658
|
+
session.logClose("window_closed");
|
|
659
|
+
}
|
|
660
|
+
forceClose = true;
|
|
661
|
+
win.close();
|
|
662
|
+
});
|
|
663
|
+
win.on("closed", () => {
|
|
664
|
+
stopWatching?.();
|
|
665
|
+
win = null;
|
|
666
|
+
});
|
|
667
|
+
}
|
|
668
|
+
function registerIpc() {
|
|
669
|
+
ipcMain.handle("doc:load", () => {
|
|
670
|
+
if (!session) throw new Error("no document open");
|
|
671
|
+
return session.load();
|
|
672
|
+
});
|
|
673
|
+
ipcMain.handle("doc:save", (_e, content, options) => {
|
|
674
|
+
session?.save(content, options);
|
|
675
|
+
});
|
|
676
|
+
ipcMain.handle("doc:log-action", (_e, type, payload) => {
|
|
677
|
+
session?.logAction(type, payload);
|
|
678
|
+
});
|
|
679
|
+
ipcMain.handle("doc:report-state", (_e, dirty, content) => {
|
|
680
|
+
session?.setPending(dirty, content);
|
|
681
|
+
});
|
|
682
|
+
ipcMain.handle("doc:set-mode", (_e, cadence, acceptance) => {
|
|
683
|
+
session?.setMode(cadence, acceptance);
|
|
684
|
+
});
|
|
685
|
+
ipcMain.handle("settings:get", () => session?.getSettings());
|
|
686
|
+
ipcMain.handle("settings:set", (_e, settings) => {
|
|
687
|
+
session?.setSettings(settings);
|
|
688
|
+
});
|
|
689
|
+
ipcMain.handle("window:close", () => win?.close());
|
|
690
|
+
ipcMain.handle("proposal:get", () => session?.pendingProposal() ?? null);
|
|
691
|
+
ipcMain.handle("proposal:clear", () => {
|
|
692
|
+
session?.clearProposal();
|
|
693
|
+
});
|
|
694
|
+
ipcMain.handle("doc:open", (_e, target) => {
|
|
695
|
+
const abs = resolve$1("/", target);
|
|
696
|
+
if (!abs.endsWith(".md") || !existsSync$1(abs)) {
|
|
697
|
+
process.stderr.write(`[inplan] open-doc: no such .md file: ${abs}
|
|
698
|
+
`);
|
|
699
|
+
return;
|
|
700
|
+
}
|
|
701
|
+
if (navigateTo(abs)) {
|
|
702
|
+
navHistory.splice(navIdx + 1);
|
|
703
|
+
navHistory.push(abs);
|
|
704
|
+
navIdx = navHistory.length - 1;
|
|
705
|
+
sendNavState();
|
|
706
|
+
}
|
|
707
|
+
});
|
|
708
|
+
ipcMain.handle("nav:go", (_e, dir) => {
|
|
709
|
+
const target = dir === "back" ? navIdx - 1 : navIdx + 1;
|
|
710
|
+
if (target < 0 || target >= navHistory.length) return;
|
|
711
|
+
if (navigateTo(navHistory[target])) {
|
|
712
|
+
navIdx = target;
|
|
713
|
+
sendNavState();
|
|
714
|
+
}
|
|
715
|
+
});
|
|
716
|
+
ipcMain.handle("doc:complete", (_e, content) => {
|
|
717
|
+
session?.complete(content);
|
|
718
|
+
session?.logClose("completed");
|
|
719
|
+
app.quit();
|
|
720
|
+
});
|
|
721
|
+
ipcMain.handle("app:apply-update", async () => {
|
|
722
|
+
const r = await runCli(["update"]);
|
|
723
|
+
try {
|
|
724
|
+
return { ok: JSON.parse(r.stdout.trim() || "{}").status === "updated" };
|
|
725
|
+
} catch {
|
|
726
|
+
return { ok: false };
|
|
727
|
+
}
|
|
728
|
+
});
|
|
729
|
+
ipcMain.handle("profile:get", () => readProfile());
|
|
730
|
+
ipcMain.handle("profile:action", async (_e, id) => {
|
|
731
|
+
if (id === "collaborate") {
|
|
732
|
+
await collaborateOnCloud();
|
|
733
|
+
} else if (id === "signout") {
|
|
734
|
+
await runCli(["logout"]);
|
|
735
|
+
win?.webContents.send("profile:changed");
|
|
736
|
+
void i18n.bootstrap();
|
|
737
|
+
} else if (id === "signin") {
|
|
738
|
+
dialog.showMessageBoxSync(win, {
|
|
739
|
+
type: "info",
|
|
740
|
+
message: "Sign in to inplan.ai",
|
|
741
|
+
detail: "Run `inplan login` in your terminal to connect this app to your inplan.ai account, then reopen this menu."
|
|
742
|
+
});
|
|
743
|
+
win?.webContents.send("profile:changed");
|
|
744
|
+
void i18n.bootstrap();
|
|
745
|
+
}
|
|
746
|
+
});
|
|
747
|
+
ipcMain.handle("i18n:get", () => i18n.getSnapshot());
|
|
748
|
+
ipcMain.handle("i18n:set-locale", (_e, code) => i18n.setLocale(code));
|
|
749
|
+
}
|
|
750
|
+
void app.whenReady().then(() => {
|
|
751
|
+
const target = resolveTargetFile();
|
|
752
|
+
if (target) {
|
|
753
|
+
session = new Session(target);
|
|
754
|
+
session.logEditorPid(process.pid);
|
|
755
|
+
navHistory.push(target);
|
|
756
|
+
navIdx = 0;
|
|
757
|
+
}
|
|
758
|
+
registerIpc();
|
|
759
|
+
createWindow();
|
|
760
|
+
app.on("activate", () => {
|
|
761
|
+
if (BrowserWindow.getAllWindows().length === 0) createWindow();
|
|
762
|
+
});
|
|
763
|
+
});
|
|
764
|
+
app.on("before-quit", () => {
|
|
765
|
+
session?.logClose("window_closed");
|
|
766
|
+
});
|
|
767
|
+
app.on("window-all-closed", () => {
|
|
768
|
+
app.quit();
|
|
769
|
+
});
|