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
package/bin/cli.js
ADDED
|
@@ -0,0 +1,1409 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/cli.ts
|
|
4
|
+
import { spawn as spawn2 } from "child_process";
|
|
5
|
+
import { createRequire } from "module";
|
|
6
|
+
import { appendFileSync as appendFileSync2, copyFileSync, existsSync as existsSync7, mkdirSync as mkdirSync5, readFileSync as readFileSync6, writeFileSync as writeFileSync5 } from "fs";
|
|
7
|
+
import { homedir as homedir4 } from "os";
|
|
8
|
+
import { basename as basename3, dirname as dirname6, join as join5, resolve as resolve2 } from "path";
|
|
9
|
+
import { fileURLToPath } from "url";
|
|
10
|
+
|
|
11
|
+
// ../core/dist/chunk-7QR2RJUX.js
|
|
12
|
+
function isReply(c) {
|
|
13
|
+
return c.parentId !== void 0;
|
|
14
|
+
}
|
|
15
|
+
function isDocComment(c) {
|
|
16
|
+
return c.anchor === "doc";
|
|
17
|
+
}
|
|
18
|
+
function isSpanComment(c) {
|
|
19
|
+
return !isReply(c) && !isDocComment(c);
|
|
20
|
+
}
|
|
21
|
+
var COMMENT_ID_RE = /^cmt-[0-9a-z]+$/;
|
|
22
|
+
var ANCHOR_LINK_RE = /\]\(#(cmt-[0-9a-z]+)\)/gi;
|
|
23
|
+
function stripCode(body) {
|
|
24
|
+
let inFence = false;
|
|
25
|
+
const out = [];
|
|
26
|
+
for (const line of body.split("\n")) {
|
|
27
|
+
if (/^\s*(```|~~~)/.test(line)) {
|
|
28
|
+
inFence = !inFence;
|
|
29
|
+
out.push("");
|
|
30
|
+
} else if (inFence) {
|
|
31
|
+
out.push("");
|
|
32
|
+
} else {
|
|
33
|
+
out.push(line.replace(/`[^`]*`/g, ""));
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return out.join("\n");
|
|
37
|
+
}
|
|
38
|
+
function extractAnchorIdList(body) {
|
|
39
|
+
const ids = [];
|
|
40
|
+
for (const m of stripCode(body).matchAll(ANCHOR_LINK_RE)) {
|
|
41
|
+
ids.push(m[1].toLowerCase());
|
|
42
|
+
}
|
|
43
|
+
return ids;
|
|
44
|
+
}
|
|
45
|
+
function extractAnchorIds(body) {
|
|
46
|
+
return new Set(extractAnchorIdList(body));
|
|
47
|
+
}
|
|
48
|
+
function anchorLinkCounts(body) {
|
|
49
|
+
const counts = /* @__PURE__ */ new Map();
|
|
50
|
+
for (const id of extractAnchorIdList(body)) {
|
|
51
|
+
counts.set(id, (counts.get(id) ?? 0) + 1);
|
|
52
|
+
}
|
|
53
|
+
return counts;
|
|
54
|
+
}
|
|
55
|
+
var BLOCK_OPEN = "<!--inplan";
|
|
56
|
+
var BLOCK_CLOSE = "-->";
|
|
57
|
+
var DOC_FORMAT_VERSION = 1;
|
|
58
|
+
function findBlockOpen(markdown) {
|
|
59
|
+
let inFence = false;
|
|
60
|
+
let offset = 0;
|
|
61
|
+
for (const line of markdown.split("\n")) {
|
|
62
|
+
if (/^\s*(```|~~~)/.test(line)) {
|
|
63
|
+
inFence = !inFence;
|
|
64
|
+
} else if (!inFence) {
|
|
65
|
+
const trimmed = line.trimStart();
|
|
66
|
+
if (trimmed.startsWith(BLOCK_OPEN)) return offset + (line.length - trimmed.length);
|
|
67
|
+
}
|
|
68
|
+
offset += line.length + 1;
|
|
69
|
+
}
|
|
70
|
+
return -1;
|
|
71
|
+
}
|
|
72
|
+
function parse(markdown) {
|
|
73
|
+
const openIdx = findBlockOpen(markdown);
|
|
74
|
+
if (openIdx === -1) {
|
|
75
|
+
return { body: markdown.replace(/\s+$/, ""), comments: [], version: DOC_FORMAT_VERSION };
|
|
76
|
+
}
|
|
77
|
+
const afterOpen = openIdx + BLOCK_OPEN.length;
|
|
78
|
+
const closeIdx = markdown.indexOf(BLOCK_CLOSE, afterOpen);
|
|
79
|
+
if (closeIdx === -1) {
|
|
80
|
+
throw new ParseError("comment data block is not closed with `-->`");
|
|
81
|
+
}
|
|
82
|
+
let inner = markdown.slice(afterOpen, closeIdx);
|
|
83
|
+
let version = DOC_FORMAT_VERSION;
|
|
84
|
+
const versionMatch = /^[^\S\n]*v(\d+)\b/.exec(inner);
|
|
85
|
+
if (versionMatch) {
|
|
86
|
+
version = Number(versionMatch[1]);
|
|
87
|
+
inner = inner.slice(versionMatch[0].length);
|
|
88
|
+
}
|
|
89
|
+
const jsonText = inner.trim();
|
|
90
|
+
let comments;
|
|
91
|
+
try {
|
|
92
|
+
const parsed = jsonText.length === 0 ? [] : JSON.parse(jsonText);
|
|
93
|
+
if (!Array.isArray(parsed)) {
|
|
94
|
+
throw new ParseError("comment data block must contain a JSON array");
|
|
95
|
+
}
|
|
96
|
+
comments = parsed;
|
|
97
|
+
} catch (err) {
|
|
98
|
+
if (err instanceof ParseError) throw err;
|
|
99
|
+
throw new ParseError(`comment data block is not valid JSON: ${err.message}`);
|
|
100
|
+
}
|
|
101
|
+
const body = markdown.slice(0, openIdx).replace(/\s+$/, "");
|
|
102
|
+
return { body, comments, version };
|
|
103
|
+
}
|
|
104
|
+
function serialize(doc) {
|
|
105
|
+
const body = doc.body.replace(/\s+$/, "");
|
|
106
|
+
const json = JSON.stringify(doc.comments, null, 2);
|
|
107
|
+
const version = doc.version ?? DOC_FORMAT_VERSION;
|
|
108
|
+
const block = `${BLOCK_OPEN} v${version}
|
|
109
|
+
${json}
|
|
110
|
+
${BLOCK_CLOSE}
|
|
111
|
+
`;
|
|
112
|
+
return body.length === 0 ? `
|
|
113
|
+
${block}` : `${body}
|
|
114
|
+
|
|
115
|
+
${block}`;
|
|
116
|
+
}
|
|
117
|
+
var ParseError = class extends Error {
|
|
118
|
+
constructor(message) {
|
|
119
|
+
super(message);
|
|
120
|
+
this.name = "ParseError";
|
|
121
|
+
}
|
|
122
|
+
};
|
|
123
|
+
function checkIntegrity(doc) {
|
|
124
|
+
const errors = [];
|
|
125
|
+
const byId = /* @__PURE__ */ new Map();
|
|
126
|
+
const linkCounts = anchorLinkCounts(doc.body);
|
|
127
|
+
for (const c of doc.comments) {
|
|
128
|
+
if (!COMMENT_ID_RE.test(c.id)) {
|
|
129
|
+
errors.push({ code: "malformed_id", commentId: c.id, message: `malformed comment id: ${JSON.stringify(c.id)}` });
|
|
130
|
+
}
|
|
131
|
+
if (byId.has(c.id)) {
|
|
132
|
+
errors.push({ code: "duplicate_id", commentId: c.id, message: `duplicate comment id: ${c.id}` });
|
|
133
|
+
} else {
|
|
134
|
+
byId.set(c.id, c);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
for (const c of doc.comments) {
|
|
138
|
+
const count = linkCounts.get(c.id) ?? 0;
|
|
139
|
+
if (isSpanComment(c)) {
|
|
140
|
+
if (count === 0) {
|
|
141
|
+
errors.push({ code: "span_missing_link", commentId: c.id, message: `span comment ${c.id} has no in-body anchor link` });
|
|
142
|
+
} else if (count > 1) {
|
|
143
|
+
errors.push({ code: "span_duplicate_link", commentId: c.id, message: `span comment ${c.id} has ${count} anchor links (expected 1)` });
|
|
144
|
+
}
|
|
145
|
+
} else if (count > 0) {
|
|
146
|
+
const kind = isReply(c) ? "reply" : "document-level comment";
|
|
147
|
+
errors.push({ code: "nonspan_has_link", commentId: c.id, message: `${kind} ${c.id} must not have an in-body anchor link` });
|
|
148
|
+
}
|
|
149
|
+
if (isReply(c) && !byId.has(c.parentId)) {
|
|
150
|
+
errors.push({ code: "missing_parent", commentId: c.id, message: `comment ${c.id} references missing parent ${c.parentId}` });
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
for (const [id] of linkCounts) {
|
|
154
|
+
const target = byId.get(id);
|
|
155
|
+
if (!target) {
|
|
156
|
+
errors.push({ code: "dangling_link", commentId: id, message: `anchor link #${id} has no matching comment` });
|
|
157
|
+
} else if (!isSpanComment(target)) {
|
|
158
|
+
errors.push({ code: "link_targets_nonspan", commentId: id, message: `anchor link #${id} targets a non-span comment` });
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
return { ok: errors.length === 0, errors };
|
|
162
|
+
}
|
|
163
|
+
function findOrphans(doc) {
|
|
164
|
+
const links = extractAnchorIds(doc.body);
|
|
165
|
+
return doc.comments.filter((c) => isSpanComment(c) && !links.has(c.id));
|
|
166
|
+
}
|
|
167
|
+
function detectLostComments(prev, next) {
|
|
168
|
+
const previouslyOrphaned = new Set(findOrphans(prev).map((c) => c.id));
|
|
169
|
+
return findOrphans(next).filter((c) => !previouslyOrphaned.has(c.id));
|
|
170
|
+
}
|
|
171
|
+
var CONTROL_LOG_VERSION = 1;
|
|
172
|
+
var LogEventType = {
|
|
173
|
+
EditorPid: "editor_pid",
|
|
174
|
+
ModeChanged: "mode_changed",
|
|
175
|
+
CommentCreated: "comment_created",
|
|
176
|
+
CommentModified: "comment_modified",
|
|
177
|
+
CommentDeleted: "comment_deleted",
|
|
178
|
+
CommentResolved: "comment_resolved",
|
|
179
|
+
CommentAnswered: "comment_answered",
|
|
180
|
+
DocumentEdited: "document_edited",
|
|
181
|
+
TurnEnded: "turn_ended",
|
|
182
|
+
AgentRevised: "agent_revised",
|
|
183
|
+
AgentRevisionProposed: "agent_revision_proposed",
|
|
184
|
+
RevisionHunkAccepted: "revision_hunk_accepted",
|
|
185
|
+
RevisionHunkRejected: "revision_hunk_rejected",
|
|
186
|
+
RevisionAcceptedAll: "revision_accepted_all",
|
|
187
|
+
RevisionRejectedAll: "revision_rejected_all",
|
|
188
|
+
SettingsChanged: "settings_changed",
|
|
189
|
+
AgentDoneSuggested: "agent_done_suggested",
|
|
190
|
+
ReloadSuggested: "reload_suggested",
|
|
191
|
+
/** Cloud→local handoff: a human on the web asked the attached local agent to
|
|
192
|
+
* bring the doc back to disk (the inverse of "Collaborate on Cloud"). */
|
|
193
|
+
SaveLocallyRequested: "save_locally_requested",
|
|
194
|
+
/** Turn-mode escape: the human reclaimed control after the agent failed to hand it back. */
|
|
195
|
+
HumanReclaimed: "human_reclaimed",
|
|
196
|
+
/** In-window navigation: the editor followed a Markdown link to a sibling doc.
|
|
197
|
+
* Payload `{ path }` is the new doc; the attached agent's `wait` steps down and
|
|
198
|
+
* the human's agent re-attaches there (the local analogue of save-locally). */
|
|
199
|
+
NavigatedTo: "navigated_to",
|
|
200
|
+
SessionClosed: "session_closed"
|
|
201
|
+
};
|
|
202
|
+
function serializeLogEntry(entry) {
|
|
203
|
+
return JSON.stringify(entry);
|
|
204
|
+
}
|
|
205
|
+
function parseLogLine(line) {
|
|
206
|
+
return JSON.parse(line);
|
|
207
|
+
}
|
|
208
|
+
function parseLog(text) {
|
|
209
|
+
return text.split("\n").map((l) => l.trim()).filter((l) => l.length > 0).map(parseLogLine);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// ../core/dist/node.js
|
|
213
|
+
import { appendFileSync, closeSync, existsSync, openSync, readFileSync, readSync, statSync } from "fs";
|
|
214
|
+
import {
|
|
215
|
+
existsSync as existsSync2,
|
|
216
|
+
mkdirSync,
|
|
217
|
+
readdirSync,
|
|
218
|
+
readFileSync as readFileSync2,
|
|
219
|
+
unlinkSync,
|
|
220
|
+
unwatchFile,
|
|
221
|
+
watch,
|
|
222
|
+
watchFile,
|
|
223
|
+
writeFileSync
|
|
224
|
+
} from "fs";
|
|
225
|
+
import { join } from "path";
|
|
226
|
+
import { createHash } from "crypto";
|
|
227
|
+
import { existsSync as existsSync3 } from "fs";
|
|
228
|
+
import { homedir } from "os";
|
|
229
|
+
import { basename, dirname, join as join2, relative, resolve } from "path";
|
|
230
|
+
import { existsSync as existsSync4, mkdirSync as mkdirSync2, readFileSync as readFileSync3, writeFileSync as writeFileSync2 } from "fs";
|
|
231
|
+
import { homedir as homedir2 } from "os";
|
|
232
|
+
import { dirname as dirname2, join as join3 } from "path";
|
|
233
|
+
import { createHash as createHash2 } from "crypto";
|
|
234
|
+
import { existsSync as existsSync5, mkdirSync as mkdirSync3, readFileSync as readFileSync4, writeFileSync as writeFileSync3 } from "fs";
|
|
235
|
+
import { dirname as dirname3 } from "path";
|
|
236
|
+
function readLog(path) {
|
|
237
|
+
if (!existsSync(path)) return [];
|
|
238
|
+
return parseLog(readFileSync(path, "utf8"));
|
|
239
|
+
}
|
|
240
|
+
function readLogIncrement(path, byteOffset) {
|
|
241
|
+
if (!existsSync(path)) return { entries: [], offset: 0, reset: byteOffset !== 0 };
|
|
242
|
+
const size = statSync(path).size;
|
|
243
|
+
if (size < byteOffset) return { entries: [], offset: 0, reset: true };
|
|
244
|
+
if (size === byteOffset) return { entries: [], offset: byteOffset, reset: false };
|
|
245
|
+
const len = size - byteOffset;
|
|
246
|
+
const fd = openSync(path, "r");
|
|
247
|
+
try {
|
|
248
|
+
const buf = Buffer.allocUnsafe(len);
|
|
249
|
+
const got = readSync(fd, buf, 0, len, byteOffset);
|
|
250
|
+
const chunk = buf.toString("utf8", 0, got);
|
|
251
|
+
const lastNl = chunk.lastIndexOf("\n");
|
|
252
|
+
if (lastNl === -1) return { entries: [], offset: byteOffset, reset: false };
|
|
253
|
+
const complete = chunk.slice(0, lastNl + 1);
|
|
254
|
+
return { entries: parseLog(complete), offset: byteOffset + Buffer.byteLength(complete, "utf8"), reset: false };
|
|
255
|
+
} finally {
|
|
256
|
+
closeSync(fd);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
function appendLog(path, entry) {
|
|
260
|
+
const existing = readLog(path);
|
|
261
|
+
const seq = existing.length ? existing[existing.length - 1].seq + 1 : 1;
|
|
262
|
+
const full = {
|
|
263
|
+
seq,
|
|
264
|
+
ts: entry.ts ?? (/* @__PURE__ */ new Date()).toISOString(),
|
|
265
|
+
actor: entry.actor,
|
|
266
|
+
type: entry.type,
|
|
267
|
+
...entry.payload !== void 0 ? { payload: entry.payload } : {}
|
|
268
|
+
};
|
|
269
|
+
appendFileSync(path, serializeLogEntry(full) + "\n");
|
|
270
|
+
return full;
|
|
271
|
+
}
|
|
272
|
+
var MAX_BACKUPS = 25;
|
|
273
|
+
function isProcessAlive(pid) {
|
|
274
|
+
try {
|
|
275
|
+
process.kill(pid, 0);
|
|
276
|
+
return true;
|
|
277
|
+
} catch (err) {
|
|
278
|
+
return err.code === "EPERM";
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
var FsControlChannel = class {
|
|
282
|
+
constructor(paths) {
|
|
283
|
+
this.paths = paths;
|
|
284
|
+
}
|
|
285
|
+
paths;
|
|
286
|
+
// Incremental-read state: bytes already consumed + the entries parsed so far.
|
|
287
|
+
// Lets readSince parse only newly-appended bytes (O(new)) rather than re-reading
|
|
288
|
+
// the whole log each poll, while still seeing appends from other processes
|
|
289
|
+
// (the editor) since it re-stats to the current size every call.
|
|
290
|
+
byteOffset = 0;
|
|
291
|
+
parsed = [];
|
|
292
|
+
append(event) {
|
|
293
|
+
return Promise.resolve(appendLog(this.paths.logPath, event));
|
|
294
|
+
}
|
|
295
|
+
readSince(cursor) {
|
|
296
|
+
let inc = readLogIncrement(this.paths.logPath, this.byteOffset);
|
|
297
|
+
if (inc.reset) {
|
|
298
|
+
this.parsed = [];
|
|
299
|
+
this.byteOffset = 0;
|
|
300
|
+
inc = readLogIncrement(this.paths.logPath, 0);
|
|
301
|
+
}
|
|
302
|
+
if (inc.entries.length) this.parsed.push(...inc.entries);
|
|
303
|
+
this.byteOffset = inc.offset;
|
|
304
|
+
const entries = this.parsed.filter((e) => e.seq > cursor);
|
|
305
|
+
const next = this.parsed.length ? this.parsed[this.parsed.length - 1].seq : cursor;
|
|
306
|
+
return Promise.resolve({ entries, cursor: next });
|
|
307
|
+
}
|
|
308
|
+
subscribe(onChange) {
|
|
309
|
+
let timer = null;
|
|
310
|
+
const fire = () => {
|
|
311
|
+
if (timer) clearTimeout(timer);
|
|
312
|
+
timer = setTimeout(onChange, 50);
|
|
313
|
+
};
|
|
314
|
+
try {
|
|
315
|
+
const watcher = watch(this.paths.logPath, fire);
|
|
316
|
+
return () => {
|
|
317
|
+
if (timer) clearTimeout(timer);
|
|
318
|
+
watcher.close();
|
|
319
|
+
};
|
|
320
|
+
} catch {
|
|
321
|
+
const listener = () => fire();
|
|
322
|
+
watchFile(this.paths.logPath, { interval: 200 }, listener);
|
|
323
|
+
return () => {
|
|
324
|
+
if (timer) clearTimeout(timer);
|
|
325
|
+
unwatchFile(this.paths.logPath, listener);
|
|
326
|
+
};
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
getCursor() {
|
|
330
|
+
if (!existsSync2(this.paths.cursorPath)) return Promise.resolve(0);
|
|
331
|
+
const n = Number(readFileSync2(this.paths.cursorPath, "utf8").trim());
|
|
332
|
+
return Promise.resolve(Number.isFinite(n) ? n : 0);
|
|
333
|
+
}
|
|
334
|
+
setCursor(seq) {
|
|
335
|
+
writeFileSync(this.paths.cursorPath, String(seq));
|
|
336
|
+
return Promise.resolve();
|
|
337
|
+
}
|
|
338
|
+
claimLock(token) {
|
|
339
|
+
writeFileSync(this.paths.waitLockPath, token);
|
|
340
|
+
return Promise.resolve();
|
|
341
|
+
}
|
|
342
|
+
isSuperseded(token) {
|
|
343
|
+
if (!existsSync2(this.paths.waitLockPath)) return Promise.resolve(false);
|
|
344
|
+
return Promise.resolve(readFileSync2(this.paths.waitLockPath, "utf8").trim() !== token);
|
|
345
|
+
}
|
|
346
|
+
presence() {
|
|
347
|
+
const log = readLog(this.paths.logPath);
|
|
348
|
+
for (let i = log.length - 1; i >= 0; i--) {
|
|
349
|
+
if (log[i].type === LogEventType.EditorPid) {
|
|
350
|
+
const pid = log[i].payload?.pid;
|
|
351
|
+
return Promise.resolve(typeof pid === "number" && isProcessAlive(pid));
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
return Promise.resolve(false);
|
|
355
|
+
}
|
|
356
|
+
};
|
|
357
|
+
var FsDocumentStore = class {
|
|
358
|
+
constructor(paths) {
|
|
359
|
+
this.paths = paths;
|
|
360
|
+
}
|
|
361
|
+
paths;
|
|
362
|
+
readOrNull(path) {
|
|
363
|
+
return existsSync2(path) ? readFileSync2(path, "utf8") : null;
|
|
364
|
+
}
|
|
365
|
+
loadDoc() {
|
|
366
|
+
return Promise.resolve(readFileSync2(this.paths.file, "utf8"));
|
|
367
|
+
}
|
|
368
|
+
saveDoc(content) {
|
|
369
|
+
writeFileSync(this.paths.file, content);
|
|
370
|
+
return Promise.resolve();
|
|
371
|
+
}
|
|
372
|
+
getCanonical() {
|
|
373
|
+
return Promise.resolve(this.readOrNull(this.paths.canonicalPath));
|
|
374
|
+
}
|
|
375
|
+
setCanonical(content) {
|
|
376
|
+
writeFileSync(this.paths.canonicalPath, content);
|
|
377
|
+
return Promise.resolve();
|
|
378
|
+
}
|
|
379
|
+
getProposed() {
|
|
380
|
+
return Promise.resolve(this.readOrNull(this.paths.proposedPath));
|
|
381
|
+
}
|
|
382
|
+
setProposed(content) {
|
|
383
|
+
writeFileSync(this.paths.proposedPath, content);
|
|
384
|
+
return Promise.resolve();
|
|
385
|
+
}
|
|
386
|
+
clearProposed() {
|
|
387
|
+
if (existsSync2(this.paths.proposedPath)) unlinkSync(this.paths.proposedPath);
|
|
388
|
+
return Promise.resolve();
|
|
389
|
+
}
|
|
390
|
+
backup(content) {
|
|
391
|
+
mkdirSync(this.paths.backupsDir, { recursive: true });
|
|
392
|
+
const seqs = this.backupSeqs();
|
|
393
|
+
const next = (seqs.at(-1) ?? 0) + 1;
|
|
394
|
+
writeFileSync(join(this.paths.backupsDir, `autosave-${next}.md`), content);
|
|
395
|
+
for (const n of seqs.slice(0, Math.max(0, seqs.length + 1 - MAX_BACKUPS))) {
|
|
396
|
+
try {
|
|
397
|
+
unlinkSync(join(this.paths.backupsDir, `autosave-${n}.md`));
|
|
398
|
+
} catch {
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
return Promise.resolve();
|
|
402
|
+
}
|
|
403
|
+
backupSeqs() {
|
|
404
|
+
if (!existsSync2(this.paths.backupsDir)) return [];
|
|
405
|
+
return readdirSync(this.paths.backupsDir).map((name) => /^autosave-(\d+)\.md$/.exec(name)?.[1]).filter((n) => n != null).map(Number).sort((a, b) => a - b);
|
|
406
|
+
}
|
|
407
|
+
};
|
|
408
|
+
function sidecarRoot() {
|
|
409
|
+
if (process.env.INPLAN_SIDECAR_DIR) return process.env.INPLAN_SIDECAR_DIR;
|
|
410
|
+
const home = process.env.INPLAN_HOME || join2(homedir(), ".inplan");
|
|
411
|
+
return join2(home, "sidecars");
|
|
412
|
+
}
|
|
413
|
+
function repoRootOf(absFile) {
|
|
414
|
+
let dir = dirname(absFile);
|
|
415
|
+
for (; ; ) {
|
|
416
|
+
if (existsSync3(join2(dir, ".git"))) return dir;
|
|
417
|
+
const parent = dirname(dir);
|
|
418
|
+
if (parent === dir) return null;
|
|
419
|
+
dir = parent;
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
function docPaths(file) {
|
|
423
|
+
const abs = resolve(file);
|
|
424
|
+
const root = repoRootOf(abs);
|
|
425
|
+
const raw = root ? `${basename(root)}/${relative(root, abs)}` : `${basename(dirname(abs))}/${basename(abs)}`;
|
|
426
|
+
const label = raw.replace(/[/\\]+/g, "-").replace(/[^A-Za-z0-9._-]/g, "_");
|
|
427
|
+
const key = `${label}-${createHash("sha1").update(abs).digest("hex").slice(0, 12)}`;
|
|
428
|
+
const controlDir = join2(sidecarRoot(), key);
|
|
429
|
+
return {
|
|
430
|
+
file: abs,
|
|
431
|
+
controlDir,
|
|
432
|
+
logPath: join2(controlDir, "log.jsonl"),
|
|
433
|
+
canonicalPath: join2(controlDir, "canonical.md"),
|
|
434
|
+
backupsDir: join2(controlDir, "backups"),
|
|
435
|
+
proposedPath: join2(controlDir, "proposed.md"),
|
|
436
|
+
cursorPath: join2(controlDir, "cursor"),
|
|
437
|
+
waitLockPath: join2(controlDir, "waitlock"),
|
|
438
|
+
waitDebugPath: join2(controlDir, "wait-debug.log"),
|
|
439
|
+
statusPath: join2(controlDir, "status.json")
|
|
440
|
+
};
|
|
441
|
+
}
|
|
442
|
+
var DEFAULT_SETTINGS = { autoResolve: true };
|
|
443
|
+
function globalSettingsPath() {
|
|
444
|
+
const base = process.env.INPLAN_HOME || join3(homedir2(), ".inplan");
|
|
445
|
+
return join3(base, "settings.json");
|
|
446
|
+
}
|
|
447
|
+
function readGlobalSettings() {
|
|
448
|
+
const path = globalSettingsPath();
|
|
449
|
+
if (!existsSync4(path)) return { ...DEFAULT_SETTINGS };
|
|
450
|
+
try {
|
|
451
|
+
const raw = JSON.parse(readFileSync3(path, "utf8"));
|
|
452
|
+
return { ...DEFAULT_SETTINGS, ...raw };
|
|
453
|
+
} catch {
|
|
454
|
+
return { ...DEFAULT_SETTINGS };
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
function settingsFromEntries(entries, base = readGlobalSettings()) {
|
|
458
|
+
const settings = { ...base };
|
|
459
|
+
for (const e of entries) {
|
|
460
|
+
if (e.type === LogEventType.SettingsChanged && e.payload && typeof e.payload === "object") {
|
|
461
|
+
Object.assign(settings, e.payload);
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
return settings;
|
|
465
|
+
}
|
|
466
|
+
var DEFAULT_STATUS = { location: "local" };
|
|
467
|
+
function hashBody(text) {
|
|
468
|
+
return createHash2("sha256").update(text, "utf8").digest("hex");
|
|
469
|
+
}
|
|
470
|
+
function readStatus(statusPath) {
|
|
471
|
+
if (!existsSync5(statusPath)) return { ...DEFAULT_STATUS };
|
|
472
|
+
try {
|
|
473
|
+
const raw = JSON.parse(readFileSync4(statusPath, "utf8"));
|
|
474
|
+
if (raw.location !== "local" && raw.location !== "cloud") return { ...DEFAULT_STATUS };
|
|
475
|
+
if (raw.location === "cloud" && typeof raw.cloudDocId !== "string") return { ...DEFAULT_STATUS };
|
|
476
|
+
return { ...DEFAULT_STATUS, ...raw, location: raw.location };
|
|
477
|
+
} catch {
|
|
478
|
+
return { ...DEFAULT_STATUS };
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
function writeStatus(statusPath, status) {
|
|
482
|
+
mkdirSync3(dirname3(statusPath), { recursive: true });
|
|
483
|
+
writeFileSync3(statusPath, `${JSON.stringify(status, null, 2)}
|
|
484
|
+
`);
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
// src/cli.ts
|
|
488
|
+
import { HocuspocusProvider, HocuspocusProviderWebsocket } from "@hocuspocus/provider";
|
|
489
|
+
import * as Y from "yjs";
|
|
490
|
+
import WebSocket2 from "ws";
|
|
491
|
+
|
|
492
|
+
// src/agentAuthor.ts
|
|
493
|
+
function agentAuthorFor(model) {
|
|
494
|
+
const m = model?.trim();
|
|
495
|
+
return m ? `Agent (${m}) <agent@inplan>` : "Agent <agent@inplan>";
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
// src/provenance.ts
|
|
499
|
+
import { execFileSync } from "child_process";
|
|
500
|
+
import { basename as basename2, dirname as dirname4, relative as relative2, sep } from "path";
|
|
501
|
+
var defaultRun = (args, cwd) => {
|
|
502
|
+
try {
|
|
503
|
+
return execFileSync("git", args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
|
|
504
|
+
} catch {
|
|
505
|
+
return null;
|
|
506
|
+
}
|
|
507
|
+
};
|
|
508
|
+
function repoNameFromRemote(url) {
|
|
509
|
+
const seg = url.trim().replace(/\.git$/, "").replace(/\/$/, "").split(/[/:]/).filter(Boolean).pop();
|
|
510
|
+
return seg || null;
|
|
511
|
+
}
|
|
512
|
+
function gitProvenance(file, run = defaultRun) {
|
|
513
|
+
const dir = dirname4(file);
|
|
514
|
+
const root = run(["rev-parse", "--show-toplevel"], dir);
|
|
515
|
+
if (!root) return { repo: "local", path: basename2(file) };
|
|
516
|
+
const remote = run(["remote", "get-url", "origin"], dir);
|
|
517
|
+
const repo = remote && repoNameFromRemote(remote) || basename2(root) || "local";
|
|
518
|
+
const rel = relative2(root, file).split(sep).join("/");
|
|
519
|
+
return { repo, path: rel || basename2(file) };
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
// src/cliAuth.ts
|
|
523
|
+
import { chmodSync, existsSync as existsSync6, mkdirSync as mkdirSync4, readFileSync as readFileSync5, rmSync, writeFileSync as writeFileSync4 } from "fs";
|
|
524
|
+
import { homedir as homedir3 } from "os";
|
|
525
|
+
import { dirname as dirname5, join as join4 } from "path";
|
|
526
|
+
import { createClient as createClient2 } from "@supabase/supabase-js";
|
|
527
|
+
import WebSocket from "ws";
|
|
528
|
+
|
|
529
|
+
// ../backend-supabase/dist/index.js
|
|
530
|
+
import { createClient } from "@supabase/supabase-js";
|
|
531
|
+
var PRESENCE_TTL_MS = 15e3;
|
|
532
|
+
var SupabaseControlChannel = class {
|
|
533
|
+
constructor(db, docId, consumerId = "default") {
|
|
534
|
+
this.db = db;
|
|
535
|
+
this.docId = docId;
|
|
536
|
+
this.consumerId = consumerId;
|
|
537
|
+
}
|
|
538
|
+
db;
|
|
539
|
+
docId;
|
|
540
|
+
consumerId;
|
|
541
|
+
async append(event) {
|
|
542
|
+
const { data, error } = await this.db.from("events").insert({ doc_id: this.docId, actor: event.actor, type: event.type, payload: event.payload ?? null }).select("seq, ts, actor, type, payload").single();
|
|
543
|
+
if (error) throw new Error(`append failed: ${error.message}`);
|
|
544
|
+
return rowToEntry(data);
|
|
545
|
+
}
|
|
546
|
+
async readSince(cursor) {
|
|
547
|
+
const { data, error } = await this.db.from("events").select("seq, ts, actor, type, payload").eq("doc_id", this.docId).gt("seq", cursor).order("seq", { ascending: true });
|
|
548
|
+
if (error) throw new Error(`readSince failed: ${error.message}`);
|
|
549
|
+
const rows = data ?? [];
|
|
550
|
+
const entries = rows.map(rowToEntry);
|
|
551
|
+
const last = entries[entries.length - 1];
|
|
552
|
+
return { entries, cursor: last ? last.seq : cursor };
|
|
553
|
+
}
|
|
554
|
+
subscribe(onChange) {
|
|
555
|
+
const channel = this.db.channel(`events:${this.docId}`).on(
|
|
556
|
+
"postgres_changes",
|
|
557
|
+
{ event: "INSERT", schema: "public", table: "events", filter: `doc_id=eq.${this.docId}` },
|
|
558
|
+
() => onChange()
|
|
559
|
+
).subscribe();
|
|
560
|
+
return () => {
|
|
561
|
+
void this.db.removeChannel(channel);
|
|
562
|
+
};
|
|
563
|
+
}
|
|
564
|
+
async getCursor() {
|
|
565
|
+
const { data, error } = await this.db.from("cursors").select("seq").eq("doc_id", this.docId).eq("consumer_id", this.consumerId).maybeSingle();
|
|
566
|
+
if (error) throw new Error(`getCursor failed: ${error.message}`);
|
|
567
|
+
const seq = data?.seq;
|
|
568
|
+
return typeof seq === "number" ? seq : 0;
|
|
569
|
+
}
|
|
570
|
+
async setCursor(seq) {
|
|
571
|
+
const { error } = await this.db.from("cursors").upsert({ doc_id: this.docId, consumer_id: this.consumerId, seq }, { onConflict: "doc_id,consumer_id" });
|
|
572
|
+
if (error) throw new Error(`setCursor failed: ${error.message}`);
|
|
573
|
+
}
|
|
574
|
+
async claimLock(token) {
|
|
575
|
+
const { error } = await this.db.from("locks").upsert({ doc_id: this.docId, token, claimed_at: (/* @__PURE__ */ new Date()).toISOString() }, { onConflict: "doc_id" });
|
|
576
|
+
if (error) throw new Error(`claimLock failed: ${error.message}`);
|
|
577
|
+
}
|
|
578
|
+
async isSuperseded(token) {
|
|
579
|
+
const { data, error } = await this.db.from("locks").select("token").eq("doc_id", this.docId).maybeSingle();
|
|
580
|
+
if (error) throw new Error(`isSuperseded failed: ${error.message}`);
|
|
581
|
+
const current = data?.token;
|
|
582
|
+
return current != null && current !== token;
|
|
583
|
+
}
|
|
584
|
+
async presence() {
|
|
585
|
+
const { data, error } = await this.db.from("editor_presence").select("last_seen").eq("doc_id", this.docId).maybeSingle();
|
|
586
|
+
if (error) throw new Error(`presence failed: ${error.message}`);
|
|
587
|
+
const lastSeen = data?.last_seen;
|
|
588
|
+
if (!lastSeen) return false;
|
|
589
|
+
return Date.now() - new Date(lastSeen).getTime() < PRESENCE_TTL_MS;
|
|
590
|
+
}
|
|
591
|
+
};
|
|
592
|
+
function rowToEntry(r) {
|
|
593
|
+
return { seq: r.seq, ts: r.ts, actor: r.actor, type: r.type, payload: r.payload ?? void 0 };
|
|
594
|
+
}
|
|
595
|
+
var SupabaseDocumentStore = class {
|
|
596
|
+
constructor(db, docId) {
|
|
597
|
+
this.db = db;
|
|
598
|
+
this.docId = docId;
|
|
599
|
+
}
|
|
600
|
+
db;
|
|
601
|
+
docId;
|
|
602
|
+
async loadDoc() {
|
|
603
|
+
return await this.readColumn("body") ?? "";
|
|
604
|
+
}
|
|
605
|
+
async saveDoc(content) {
|
|
606
|
+
await this.writeColumns({ body: content });
|
|
607
|
+
}
|
|
608
|
+
async getCanonical() {
|
|
609
|
+
return this.readColumn("canonical");
|
|
610
|
+
}
|
|
611
|
+
async setCanonical(content) {
|
|
612
|
+
await this.writeColumns({ canonical: content });
|
|
613
|
+
}
|
|
614
|
+
async getProposed() {
|
|
615
|
+
return this.readColumn("proposed");
|
|
616
|
+
}
|
|
617
|
+
async setProposed(content) {
|
|
618
|
+
await this.writeColumns({ proposed: content });
|
|
619
|
+
}
|
|
620
|
+
async clearProposed() {
|
|
621
|
+
await this.writeColumns({ proposed: null });
|
|
622
|
+
}
|
|
623
|
+
async backup(content) {
|
|
624
|
+
const { error } = await this.db.from("doc_versions").insert({ doc_id: this.docId, body: content });
|
|
625
|
+
if (error) throw new Error(`backup failed: ${error.message}`);
|
|
626
|
+
}
|
|
627
|
+
async readColumn(name) {
|
|
628
|
+
const { data, error } = await this.db.from("documents").select(name).eq("id", this.docId).maybeSingle();
|
|
629
|
+
if (error) throw new Error(`read ${name} failed: ${error.message}`);
|
|
630
|
+
const value = data?.[name];
|
|
631
|
+
return typeof value === "string" ? value : null;
|
|
632
|
+
}
|
|
633
|
+
async writeColumns(patch) {
|
|
634
|
+
const { error } = await this.db.from("documents").update({ ...patch, updated_at: (/* @__PURE__ */ new Date()).toISOString() }).eq("id", this.docId);
|
|
635
|
+
if (error) throw new Error(`update failed: ${error.message}`);
|
|
636
|
+
}
|
|
637
|
+
};
|
|
638
|
+
|
|
639
|
+
// src/cliAuth.ts
|
|
640
|
+
var realtimeTransport = { transport: WebSocket };
|
|
641
|
+
function authPath() {
|
|
642
|
+
const base = process.env.INPLAN_HOME || join4(homedir3(), ".inplan");
|
|
643
|
+
return join4(base, "auth.json");
|
|
644
|
+
}
|
|
645
|
+
function loadAuth() {
|
|
646
|
+
const path = authPath();
|
|
647
|
+
if (!existsSync6(path)) return null;
|
|
648
|
+
try {
|
|
649
|
+
const raw = JSON.parse(readFileSync5(path, "utf8"));
|
|
650
|
+
if (typeof raw.url === "string" && typeof raw.anonKey === "string" && typeof raw.refreshToken === "string") {
|
|
651
|
+
return { url: raw.url, anonKey: raw.anonKey, refreshToken: raw.refreshToken, ...raw.email ? { email: raw.email } : {} };
|
|
652
|
+
}
|
|
653
|
+
return null;
|
|
654
|
+
} catch {
|
|
655
|
+
return null;
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
function saveAuth(auth) {
|
|
659
|
+
const path = authPath();
|
|
660
|
+
mkdirSync4(dirname5(path), { recursive: true });
|
|
661
|
+
writeFileSync4(path, `${JSON.stringify(auth, null, 2)}
|
|
662
|
+
`, { mode: 384 });
|
|
663
|
+
try {
|
|
664
|
+
chmodSync(path, 384);
|
|
665
|
+
} catch {
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
function clearAuth() {
|
|
669
|
+
const path = authPath();
|
|
670
|
+
if (existsSync6(path)) rmSync(path, { force: true });
|
|
671
|
+
}
|
|
672
|
+
async function authedSession() {
|
|
673
|
+
const auth = loadAuth();
|
|
674
|
+
if (!auth) return null;
|
|
675
|
+
const db = createClient2(auth.url, auth.anonKey, {
|
|
676
|
+
auth: { persistSession: false, autoRefreshToken: true, detectSessionInUrl: false },
|
|
677
|
+
realtime: realtimeTransport
|
|
678
|
+
});
|
|
679
|
+
const { data, error } = await db.auth.refreshSession({ refresh_token: auth.refreshToken });
|
|
680
|
+
if (error || !data.session) return null;
|
|
681
|
+
const rotated = data.session.refresh_token || auth.refreshToken;
|
|
682
|
+
const email = data.session.user?.email ?? auth.email;
|
|
683
|
+
if (rotated !== auth.refreshToken || email !== auth.email) {
|
|
684
|
+
saveAuth({ ...auth, refreshToken: rotated, ...email ? { email } : {} });
|
|
685
|
+
}
|
|
686
|
+
return { db, session: data.session };
|
|
687
|
+
}
|
|
688
|
+
async function currentUser() {
|
|
689
|
+
const s = await authedSession();
|
|
690
|
+
if (!s) return null;
|
|
691
|
+
return { id: s.session.user.id, ...s.session.user.email ? { email: s.session.user.email } : {} };
|
|
692
|
+
}
|
|
693
|
+
async function remoteBackend(docId, consumerId = "cli-agent") {
|
|
694
|
+
const s = await authedSession();
|
|
695
|
+
if (!s) return null;
|
|
696
|
+
return {
|
|
697
|
+
db: s.db,
|
|
698
|
+
channel: new SupabaseControlChannel(s.db, docId, consumerId),
|
|
699
|
+
store: new SupabaseDocumentStore(s.db, docId),
|
|
700
|
+
token: s.session.access_token
|
|
701
|
+
};
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
// src/update.ts
|
|
705
|
+
import { spawn } from "child_process";
|
|
706
|
+
var UPDATE_PKG = process.env.INPLAN_PKG || "inplan";
|
|
707
|
+
function compareVersions(a, b) {
|
|
708
|
+
const parts = (v) => v.split("-")[0].split(".").map((n) => Number.parseInt(n, 10) || 0);
|
|
709
|
+
const pa = parts(a);
|
|
710
|
+
const pb = parts(b);
|
|
711
|
+
for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
|
|
712
|
+
const d = (pa[i] ?? 0) - (pb[i] ?? 0);
|
|
713
|
+
if (d !== 0) return d < 0 ? -1 : 1;
|
|
714
|
+
}
|
|
715
|
+
return 0;
|
|
716
|
+
}
|
|
717
|
+
async function latestVersion(pkg) {
|
|
718
|
+
try {
|
|
719
|
+
const res = await fetch(`https://registry.npmjs.org/${pkg.replace("/", "%2F")}/latest`, { headers: { accept: "application/json" } });
|
|
720
|
+
if (!res.ok) return null;
|
|
721
|
+
const data = await res.json();
|
|
722
|
+
return typeof data.version === "string" ? data.version : null;
|
|
723
|
+
} catch {
|
|
724
|
+
return null;
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
async function checkForUpdate(opts) {
|
|
728
|
+
const latest = await (opts.fetchLatest ?? latestVersion)(opts.pkg);
|
|
729
|
+
return { current: opts.current, latest, updateAvailable: latest !== null && compareVersions(opts.current, latest) < 0 };
|
|
730
|
+
}
|
|
731
|
+
function selfUpdate(pkg) {
|
|
732
|
+
return new Promise((resolve3) => {
|
|
733
|
+
const child = spawn("npm", ["install", "-g", `${pkg}@latest`], { stdio: ["ignore", "pipe", "pipe"] });
|
|
734
|
+
let output2 = "";
|
|
735
|
+
child.stdout?.on("data", (d) => output2 += d);
|
|
736
|
+
child.stderr?.on("data", (d) => output2 += d);
|
|
737
|
+
child.on("error", (e) => resolve3({ ok: false, output: e.message }));
|
|
738
|
+
child.on("close", (code) => resolve3({ ok: code === 0, output: output2.trim() }));
|
|
739
|
+
});
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
// src/editorProcess.ts
|
|
743
|
+
function isProcessAlive2(pid) {
|
|
744
|
+
try {
|
|
745
|
+
process.kill(pid, 0);
|
|
746
|
+
return true;
|
|
747
|
+
} catch {
|
|
748
|
+
return false;
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
function latestEditorPid(logPath) {
|
|
752
|
+
const entries = readLog(logPath);
|
|
753
|
+
for (let i = entries.length - 1; i >= 0; i--) {
|
|
754
|
+
if (entries[i].type === LogEventType.EditorPid) {
|
|
755
|
+
const pid = entries[i].payload?.pid;
|
|
756
|
+
return typeof pid === "number" ? pid : null;
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
return null;
|
|
760
|
+
}
|
|
761
|
+
function runningEditorPid(logPath) {
|
|
762
|
+
const pid = latestEditorPid(logPath);
|
|
763
|
+
return pid !== null && isProcessAlive2(pid) ? pid : null;
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
// src/gate.ts
|
|
767
|
+
function evaluateAgentEdit(canonicalText, currentText, confirmed) {
|
|
768
|
+
const current = parse(currentText);
|
|
769
|
+
const canonical = parse(canonicalText);
|
|
770
|
+
const lost = detectLostComments(canonical, current);
|
|
771
|
+
const unconfirmed = lost.filter((c) => !confirmed.has(c.id));
|
|
772
|
+
const removedIds = lost.filter((c) => confirmed.has(c.id)).map((c) => c.id);
|
|
773
|
+
const removedSet = new Set(removedIds);
|
|
774
|
+
const accepted = {
|
|
775
|
+
body: current.body,
|
|
776
|
+
comments: current.comments.filter((c) => !removedSet.has(c.id))
|
|
777
|
+
};
|
|
778
|
+
const integrityErrors = checkIntegrity(accepted).errors.filter((e) => e.code !== "span_missing_link");
|
|
779
|
+
return {
|
|
780
|
+
integrityOk: integrityErrors.length === 0,
|
|
781
|
+
integrityErrors,
|
|
782
|
+
lost,
|
|
783
|
+
unconfirmed,
|
|
784
|
+
removedIds,
|
|
785
|
+
acceptedText: serialize(accepted),
|
|
786
|
+
changed: currentText !== canonicalText
|
|
787
|
+
};
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
// src/wait.ts
|
|
791
|
+
var defaultActionable = (e) => e.actor === "user";
|
|
792
|
+
function wakePredicate(cadence) {
|
|
793
|
+
return cadence === "instant" ? (e) => e.actor === "user" : (e) => e.type === LogEventType.TurnEnded || e.type === LogEventType.SessionClosed || e.type === LogEventType.SaveLocallyRequested || e.type === LogEventType.NavigatedTo;
|
|
794
|
+
}
|
|
795
|
+
function waitForActions(opts) {
|
|
796
|
+
const debounceMs2 = opts.debounceMs ?? 3e3;
|
|
797
|
+
const pollMs2 = opts.pollMs ?? 200;
|
|
798
|
+
const isActionable = opts.isActionable ?? defaultActionable;
|
|
799
|
+
const watchEditor = opts.watchEditor ?? true;
|
|
800
|
+
const ch = opts.channel;
|
|
801
|
+
return new Promise((resolve3, reject) => {
|
|
802
|
+
let deadline = null;
|
|
803
|
+
let lastCount = -1;
|
|
804
|
+
let sawEditorAlive = false;
|
|
805
|
+
let busy = false;
|
|
806
|
+
let done = false;
|
|
807
|
+
const cleanup = () => {
|
|
808
|
+
clearInterval(timer);
|
|
809
|
+
opts.signal?.removeEventListener("abort", onAbort);
|
|
810
|
+
};
|
|
811
|
+
const finish = (r) => {
|
|
812
|
+
if (done) return;
|
|
813
|
+
done = true;
|
|
814
|
+
cleanup();
|
|
815
|
+
resolve3(r);
|
|
816
|
+
};
|
|
817
|
+
const onAbort = () => {
|
|
818
|
+
if (done) return;
|
|
819
|
+
done = true;
|
|
820
|
+
cleanup();
|
|
821
|
+
reject(new Error("wait aborted"));
|
|
822
|
+
};
|
|
823
|
+
const tick = async () => {
|
|
824
|
+
if (busy || done) return;
|
|
825
|
+
busy = true;
|
|
826
|
+
try {
|
|
827
|
+
const { entries, cursor } = await ch.readSince(opts.cursor);
|
|
828
|
+
if (done) return;
|
|
829
|
+
if (opts.token) {
|
|
830
|
+
try {
|
|
831
|
+
if (await ch.isSuperseded(opts.token)) {
|
|
832
|
+
finish({ entries, cursor, superseded: true });
|
|
833
|
+
return;
|
|
834
|
+
}
|
|
835
|
+
} catch {
|
|
836
|
+
}
|
|
837
|
+
}
|
|
838
|
+
if (watchEditor) {
|
|
839
|
+
let alive = false;
|
|
840
|
+
try {
|
|
841
|
+
alive = await ch.presence();
|
|
842
|
+
} catch {
|
|
843
|
+
}
|
|
844
|
+
if (alive) sawEditorAlive = true;
|
|
845
|
+
else if (sawEditorAlive) {
|
|
846
|
+
finish({ entries, cursor, editorGone: true });
|
|
847
|
+
return;
|
|
848
|
+
}
|
|
849
|
+
}
|
|
850
|
+
if (entries.some(isActionable)) {
|
|
851
|
+
if (entries.length !== lastCount) {
|
|
852
|
+
lastCount = entries.length;
|
|
853
|
+
deadline = Date.now() + debounceMs2;
|
|
854
|
+
} else if (deadline !== null && Date.now() >= deadline) {
|
|
855
|
+
finish({ entries, cursor });
|
|
856
|
+
}
|
|
857
|
+
}
|
|
858
|
+
} finally {
|
|
859
|
+
busy = false;
|
|
860
|
+
}
|
|
861
|
+
};
|
|
862
|
+
const timer = setInterval(() => void tick(), pollMs2);
|
|
863
|
+
opts.signal?.addEventListener("abort", onAbort);
|
|
864
|
+
void tick();
|
|
865
|
+
});
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
// src/cli.ts
|
|
869
|
+
var VERSION = "0.1.0";
|
|
870
|
+
function output(obj) {
|
|
871
|
+
process.stdout.write(JSON.stringify(obj) + "\n");
|
|
872
|
+
}
|
|
873
|
+
function getFlag(args, name) {
|
|
874
|
+
const withEq = args.find((a) => a.startsWith(`--${name}=`));
|
|
875
|
+
if (withEq) return withEq.slice(name.length + 3);
|
|
876
|
+
const idx = args.indexOf(`--${name}`);
|
|
877
|
+
if (idx !== -1 && idx + 1 < args.length && !args[idx + 1].startsWith("--")) return args[idx + 1];
|
|
878
|
+
return void 0;
|
|
879
|
+
}
|
|
880
|
+
function hasFlag(args, name) {
|
|
881
|
+
return args.includes(`--${name}`);
|
|
882
|
+
}
|
|
883
|
+
var debounceMs = Number(process.env.INPLAN_DEBOUNCE_MS ?? 3e3);
|
|
884
|
+
var pollMs = Number(process.env.INPLAN_POLL_MS ?? 200);
|
|
885
|
+
var COLLAB_URL = process.env.INPLAN_COLLAB_URL || "wss://inplan-collab.fly.dev";
|
|
886
|
+
function announcePresence(docId, token, model) {
|
|
887
|
+
try {
|
|
888
|
+
const ydoc = new Y.Doc();
|
|
889
|
+
const socket = new HocuspocusProviderWebsocket({ url: COLLAB_URL, WebSocketPolyfill: WebSocket2 });
|
|
890
|
+
const provider = new HocuspocusProvider({ websocketProvider: socket, name: docId, document: ydoc, token });
|
|
891
|
+
provider.awareness?.setLocalStateField("inplanPresence", { kind: "agent", agentLocation: "local", ...model ? { model } : {} });
|
|
892
|
+
return {
|
|
893
|
+
destroy: () => {
|
|
894
|
+
try {
|
|
895
|
+
provider.destroy();
|
|
896
|
+
socket.destroy();
|
|
897
|
+
ydoc.destroy();
|
|
898
|
+
} catch {
|
|
899
|
+
}
|
|
900
|
+
}
|
|
901
|
+
};
|
|
902
|
+
} catch {
|
|
903
|
+
return { destroy: () => {
|
|
904
|
+
} };
|
|
905
|
+
}
|
|
906
|
+
}
|
|
907
|
+
function resolveBundledApp() {
|
|
908
|
+
try {
|
|
909
|
+
const here = dirname6(fileURLToPath(import.meta.url));
|
|
910
|
+
const appMain = join5(here, "..", "app", "main", "index.js");
|
|
911
|
+
if (!existsSync7(appMain)) return null;
|
|
912
|
+
const electron = createRequire(import.meta.url)("electron");
|
|
913
|
+
return typeof electron === "string" ? { electron, appMain } : null;
|
|
914
|
+
} catch {
|
|
915
|
+
return null;
|
|
916
|
+
}
|
|
917
|
+
}
|
|
918
|
+
function spawnApp(file) {
|
|
919
|
+
const override = process.env.INPLAN_APP_CMD;
|
|
920
|
+
const bundled = override ? null : resolveBundledApp();
|
|
921
|
+
if (!override && !bundled) {
|
|
922
|
+
process.stderr.write("[inplan] no editor available (set INPLAN_APP_CMD, or install the published `inplan` package); running headless\n");
|
|
923
|
+
return null;
|
|
924
|
+
}
|
|
925
|
+
const env = { ...process.env, INPLAN_CLI: process.argv[1] ?? "" };
|
|
926
|
+
const child = override ? spawn2(override, [file], { detached: true, stdio: "ignore", shell: true, env }) : spawn2(bundled.electron, [bundled.appMain, file], { detached: true, stdio: "ignore", env });
|
|
927
|
+
child.unref();
|
|
928
|
+
return child.pid ?? null;
|
|
929
|
+
}
|
|
930
|
+
function cadenceFrom(entries) {
|
|
931
|
+
for (let i = entries.length - 1; i >= 0; i--) {
|
|
932
|
+
if (entries[i].type === LogEventType.ModeChanged) {
|
|
933
|
+
const c = entries[i].payload?.cadence;
|
|
934
|
+
if (c === "instant" || c === "turn") return c;
|
|
935
|
+
}
|
|
936
|
+
}
|
|
937
|
+
return "turn";
|
|
938
|
+
}
|
|
939
|
+
function acceptanceFrom(entries) {
|
|
940
|
+
for (let i = entries.length - 1; i >= 0; i--) {
|
|
941
|
+
if (entries[i].type === LogEventType.ModeChanged) {
|
|
942
|
+
const a = entries[i].payload?.acceptance;
|
|
943
|
+
if (a === "auto" || a === "review") return a;
|
|
944
|
+
}
|
|
945
|
+
}
|
|
946
|
+
return "auto";
|
|
947
|
+
}
|
|
948
|
+
function maxSeqFrom(entries) {
|
|
949
|
+
return entries.length ? entries[entries.length - 1].seq : 0;
|
|
950
|
+
}
|
|
951
|
+
function fsBackend(file) {
|
|
952
|
+
const p = docPaths(file);
|
|
953
|
+
mkdirSync5(p.controlDir, { recursive: true });
|
|
954
|
+
return {
|
|
955
|
+
channel: new FsControlChannel(p),
|
|
956
|
+
store: new FsDocumentStore(p),
|
|
957
|
+
history: async () => readLog(p.logPath),
|
|
958
|
+
logExit: (reason) => logWaitExit(p, reason)
|
|
959
|
+
};
|
|
960
|
+
}
|
|
961
|
+
function logWaitExit(p, reason) {
|
|
962
|
+
try {
|
|
963
|
+
appendFileSync2(p.waitDebugPath, `${(/* @__PURE__ */ new Date()).toISOString()} pid=${process.pid} ${reason}
|
|
964
|
+
`);
|
|
965
|
+
} catch {
|
|
966
|
+
}
|
|
967
|
+
}
|
|
968
|
+
async function waitCycle(backend, explicitCursor, confirmed, model) {
|
|
969
|
+
const { channel, store } = backend;
|
|
970
|
+
const history = await backend.history();
|
|
971
|
+
const cursor = explicitCursor ?? (await channel.getCursor() || maxSeqFrom(history));
|
|
972
|
+
const current = await store.loadDoc();
|
|
973
|
+
let canonicalText = await store.getCanonical();
|
|
974
|
+
if (canonicalText === null) {
|
|
975
|
+
canonicalText = current;
|
|
976
|
+
await store.setCanonical(current);
|
|
977
|
+
}
|
|
978
|
+
const ev = evaluateAgentEdit(canonicalText, current, confirmed);
|
|
979
|
+
if (ev.unconfirmed.length > 0) {
|
|
980
|
+
output({
|
|
981
|
+
status: "confirm_required",
|
|
982
|
+
message: "Edit removes anchored comment(s). Re-run wait with --confirmed-comment-deletion=<ids> to proceed.",
|
|
983
|
+
lost: ev.unconfirmed.map((c) => ({ id: c.id, text: c.text, author: c.author }))
|
|
984
|
+
});
|
|
985
|
+
process.exit(3);
|
|
986
|
+
}
|
|
987
|
+
if (!ev.integrityOk) {
|
|
988
|
+
output({ status: "integrity_error", errors: ev.integrityErrors });
|
|
989
|
+
process.exit(2);
|
|
990
|
+
}
|
|
991
|
+
const acceptance = acceptanceFrom(history);
|
|
992
|
+
const bodyChanged = parse(canonicalText).body !== parse(current).body;
|
|
993
|
+
if (ev.removedIds.length > 0) {
|
|
994
|
+
await store.saveDoc(ev.acceptedText);
|
|
995
|
+
await store.setCanonical(ev.acceptedText);
|
|
996
|
+
await store.clearProposed();
|
|
997
|
+
await channel.append({ actor: "agent", type: LogEventType.DocumentEdited, payload: { removed: ev.removedIds } });
|
|
998
|
+
} else if (ev.changed && acceptance === "review" && bodyChanged) {
|
|
999
|
+
await store.setProposed(current);
|
|
1000
|
+
await store.saveDoc(canonicalText);
|
|
1001
|
+
await channel.append({ actor: "agent", type: LogEventType.AgentRevisionProposed, payload: { bytes: current.length } });
|
|
1002
|
+
} else if (ev.changed) {
|
|
1003
|
+
await store.setCanonical(current);
|
|
1004
|
+
await store.clearProposed();
|
|
1005
|
+
await channel.append({ actor: "agent", type: LogEventType.DocumentEdited, payload: { bytes: current.length } });
|
|
1006
|
+
}
|
|
1007
|
+
await channel.append({ actor: "agent", type: LogEventType.AgentRevised });
|
|
1008
|
+
const lockToken = `${process.pid}-${Date.now()}`;
|
|
1009
|
+
await channel.claimLock(lockToken);
|
|
1010
|
+
for (const sig of ["SIGTERM", "SIGHUP", "SIGINT"]) {
|
|
1011
|
+
process.on(sig, () => {
|
|
1012
|
+
backend.logExit(`signal:${sig}`);
|
|
1013
|
+
process.exit(0);
|
|
1014
|
+
});
|
|
1015
|
+
}
|
|
1016
|
+
const cadence = cadenceFrom(history);
|
|
1017
|
+
const isActionable = wakePredicate(cadence);
|
|
1018
|
+
const result = await waitForActions({ channel, cursor, debounceMs, pollMs, isActionable, token: lockToken });
|
|
1019
|
+
if (result.superseded) {
|
|
1020
|
+
backend.logExit("superseded");
|
|
1021
|
+
output({ status: "superseded" });
|
|
1022
|
+
return;
|
|
1023
|
+
}
|
|
1024
|
+
await channel.setCursor(result.cursor);
|
|
1025
|
+
if (backend.onSaveLocally && result.entries.some((e) => e.type === LogEventType.SaveLocallyRequested)) {
|
|
1026
|
+
backend.logExit("save_locally");
|
|
1027
|
+
await backend.onSaveLocally();
|
|
1028
|
+
return;
|
|
1029
|
+
}
|
|
1030
|
+
const navEntry = result.entries.find((e) => e.type === LogEventType.NavigatedTo);
|
|
1031
|
+
if (navEntry) {
|
|
1032
|
+
const path = navEntry.payload?.path;
|
|
1033
|
+
backend.logExit("navigated");
|
|
1034
|
+
output({ status: "navigated", ...path ? { path } : {}, cursor: result.cursor, closed: false });
|
|
1035
|
+
return;
|
|
1036
|
+
}
|
|
1037
|
+
const closeEntry = result.entries.find((e) => e.type === LogEventType.SessionClosed);
|
|
1038
|
+
let status;
|
|
1039
|
+
let reason;
|
|
1040
|
+
if (closeEntry) {
|
|
1041
|
+
status = "closed";
|
|
1042
|
+
reason = closeEntry.payload?.reason ?? "completed";
|
|
1043
|
+
} else if (result.editorGone) {
|
|
1044
|
+
status = "closed";
|
|
1045
|
+
reason = "crashed_or_killed";
|
|
1046
|
+
} else {
|
|
1047
|
+
status = cadence === "turn" ? "your_turn" : "activity";
|
|
1048
|
+
}
|
|
1049
|
+
backend.logExit(`status:${status}${reason ? `/${reason}` : ""}`);
|
|
1050
|
+
output({
|
|
1051
|
+
status,
|
|
1052
|
+
mode: cadence,
|
|
1053
|
+
humanLocked: status === "your_turn",
|
|
1054
|
+
// Materialized current settings (global file + this session's settings_changed),
|
|
1055
|
+
// so the agent always has them without scanning the log history.
|
|
1056
|
+
settings: settingsFromEntries(history),
|
|
1057
|
+
// The canonical name the agent should author comments under (model-qualified
|
|
1058
|
+
// when --model was passed), so presence + authorship stay consistent.
|
|
1059
|
+
agentAuthor: agentAuthorFor(model),
|
|
1060
|
+
...reason ? { reason } : {},
|
|
1061
|
+
cursor: result.cursor,
|
|
1062
|
+
closed: status === "closed",
|
|
1063
|
+
entries: result.entries
|
|
1064
|
+
});
|
|
1065
|
+
}
|
|
1066
|
+
function doLogin(args) {
|
|
1067
|
+
const url = getFlag(args, "url") ?? process.env.INPLAN_SUPABASE_URL;
|
|
1068
|
+
const anonKey = getFlag(args, "anon") ?? process.env.INPLAN_SUPABASE_ANON_KEY;
|
|
1069
|
+
const refreshToken = getFlag(args, "refresh");
|
|
1070
|
+
if (!url || !anonKey || !refreshToken) {
|
|
1071
|
+
process.stderr.write("usage: inplan login --url <url> --anon <anon-key> --refresh <refresh-token> [--email <e>]\n");
|
|
1072
|
+
process.exit(64);
|
|
1073
|
+
}
|
|
1074
|
+
const email = getFlag(args, "email");
|
|
1075
|
+
saveAuth({ url, anonKey, refreshToken, ...email ? { email } : {} });
|
|
1076
|
+
output({ status: "logged_in", url, ...email ? { email } : {} });
|
|
1077
|
+
}
|
|
1078
|
+
async function runRemote(cmd, docId, explicitCursor, confirmed, rest, localFile, model) {
|
|
1079
|
+
const backend = await remoteBackend(docId, "cli-agent");
|
|
1080
|
+
if (!backend) {
|
|
1081
|
+
process.stderr.write("inplan: not logged in (or session expired) \u2014 run `inplan login`\n");
|
|
1082
|
+
process.exit(1);
|
|
1083
|
+
}
|
|
1084
|
+
if (cmd === "signal") {
|
|
1085
|
+
if (hasFlag(rest, "done")) {
|
|
1086
|
+
await backend.channel.append({ actor: "agent", type: LogEventType.AgentDoneSuggested });
|
|
1087
|
+
}
|
|
1088
|
+
if (hasFlag(rest, "reload")) {
|
|
1089
|
+
await backend.channel.append({ actor: "agent", type: LogEventType.ReloadSuggested });
|
|
1090
|
+
}
|
|
1091
|
+
output({ status: "signaled" });
|
|
1092
|
+
return;
|
|
1093
|
+
}
|
|
1094
|
+
const onSaveLocally = localFile ? async () => {
|
|
1095
|
+
const body = await backend.store.loadDoc();
|
|
1096
|
+
writeFileSync5(localFile, body);
|
|
1097
|
+
writeStatus(docPaths(localFile).statusPath, { location: "local", originalPath: localFile, lastSyncedHash: hashBody(body) });
|
|
1098
|
+
const pid = spawnApp(localFile);
|
|
1099
|
+
output({ status: "moved_local", path: localFile, reopened: pid !== null });
|
|
1100
|
+
} : void 0;
|
|
1101
|
+
const presence = announcePresence(docId, backend.token, model);
|
|
1102
|
+
try {
|
|
1103
|
+
await waitCycle(
|
|
1104
|
+
{
|
|
1105
|
+
channel: backend.channel,
|
|
1106
|
+
store: backend.store,
|
|
1107
|
+
history: async () => (await backend.channel.readSince(0)).entries,
|
|
1108
|
+
logExit: () => {
|
|
1109
|
+
},
|
|
1110
|
+
// no local sidecar for a cloud doc
|
|
1111
|
+
...onSaveLocally ? { onSaveLocally } : {}
|
|
1112
|
+
},
|
|
1113
|
+
explicitCursor,
|
|
1114
|
+
confirmed,
|
|
1115
|
+
model
|
|
1116
|
+
);
|
|
1117
|
+
} finally {
|
|
1118
|
+
presence.destroy();
|
|
1119
|
+
}
|
|
1120
|
+
}
|
|
1121
|
+
function doStatus(file) {
|
|
1122
|
+
output(readStatus(docPaths(file).statusPath));
|
|
1123
|
+
}
|
|
1124
|
+
function doPromote(file, args) {
|
|
1125
|
+
const cloudDocId = getFlag(args, "cloud-doc");
|
|
1126
|
+
if (!cloudDocId) {
|
|
1127
|
+
process.stderr.write("usage: inplan promote <file> --cloud-doc <docId> [--locator org/repo/path]\n");
|
|
1128
|
+
process.exit(64);
|
|
1129
|
+
}
|
|
1130
|
+
const body = existsSync7(file) ? readFileSync6(file, "utf8") : "";
|
|
1131
|
+
const status = { location: "cloud", cloudDocId, originalPath: file, lastSyncedHash: hashBody(body) };
|
|
1132
|
+
const locator = getFlag(args, "locator");
|
|
1133
|
+
if (locator) {
|
|
1134
|
+
const [org, repo, ...rest] = locator.split("/");
|
|
1135
|
+
if (org && repo && rest.length) status.cloudLocator = { org, repo, path: rest.join("/") };
|
|
1136
|
+
}
|
|
1137
|
+
writeStatus(docPaths(file).statusPath, status);
|
|
1138
|
+
output({ status: "promoted", location: "cloud", cloudDocId });
|
|
1139
|
+
}
|
|
1140
|
+
async function doDemote(file, args) {
|
|
1141
|
+
const p = docPaths(file);
|
|
1142
|
+
const st = readStatus(p.statusPath);
|
|
1143
|
+
if (st.location !== "cloud" || !st.cloudDocId) {
|
|
1144
|
+
process.stderr.write("inplan demote: document is not in the cloud\n");
|
|
1145
|
+
process.exit(1);
|
|
1146
|
+
}
|
|
1147
|
+
const backend = await remoteBackend(st.cloudDocId, "cli-agent");
|
|
1148
|
+
if (!backend) {
|
|
1149
|
+
process.stderr.write("inplan: not logged in (or session expired) \u2014 run `inplan login`\n");
|
|
1150
|
+
process.exit(1);
|
|
1151
|
+
}
|
|
1152
|
+
const body = await backend.store.loadDoc();
|
|
1153
|
+
const dest = st.originalPath ?? file;
|
|
1154
|
+
writeFileSync5(dest, body);
|
|
1155
|
+
writeStatus(p.statusPath, { location: "local", originalPath: dest, lastSyncedHash: hashBody(body) });
|
|
1156
|
+
output({ status: "demoted", location: "local", path: dest });
|
|
1157
|
+
}
|
|
1158
|
+
function firstHeading(body) {
|
|
1159
|
+
return body.match(/^#\s+(.+?)\s*$/m)?.[1]?.trim() || null;
|
|
1160
|
+
}
|
|
1161
|
+
async function doWhoami() {
|
|
1162
|
+
const user = await currentUser();
|
|
1163
|
+
if (!user) {
|
|
1164
|
+
output({ signedIn: false });
|
|
1165
|
+
return;
|
|
1166
|
+
}
|
|
1167
|
+
output({ signedIn: true, id: user.id, ...user.email ? { email: user.email } : {} });
|
|
1168
|
+
}
|
|
1169
|
+
async function doToken() {
|
|
1170
|
+
const s = await authedSession();
|
|
1171
|
+
output(s ? { token: s.session.access_token } : {});
|
|
1172
|
+
}
|
|
1173
|
+
function bundledSkillPath() {
|
|
1174
|
+
try {
|
|
1175
|
+
const p = join5(dirname6(fileURLToPath(import.meta.url)), "..", "skill", "SKILL.md");
|
|
1176
|
+
return existsSync7(p) ? p : null;
|
|
1177
|
+
} catch {
|
|
1178
|
+
return null;
|
|
1179
|
+
}
|
|
1180
|
+
}
|
|
1181
|
+
function skillTargets() {
|
|
1182
|
+
const home = homedir4();
|
|
1183
|
+
return [
|
|
1184
|
+
{ name: "Claude Code", root: join5(home, ".claude"), target: join5(home, ".claude", "skills", "inplan", "SKILL.md") },
|
|
1185
|
+
{ name: "Pi", root: join5(home, ".pi", "agent"), target: join5(home, ".pi", "agent", "skills", "inplan", "SKILL.md") },
|
|
1186
|
+
{ name: "Codex", root: join5(home, ".codex"), target: join5(home, ".codex", "skills", "inplan", "SKILL.md") }
|
|
1187
|
+
];
|
|
1188
|
+
}
|
|
1189
|
+
function doInstallSkill(args) {
|
|
1190
|
+
const quiet = hasFlag(args, "quiet");
|
|
1191
|
+
if (process.env.INPLAN_NO_SKILL_INSTALL) {
|
|
1192
|
+
if (!quiet) output({ status: "skipped", reason: "INPLAN_NO_SKILL_INSTALL" });
|
|
1193
|
+
return;
|
|
1194
|
+
}
|
|
1195
|
+
const src = bundledSkillPath();
|
|
1196
|
+
if (!src) {
|
|
1197
|
+
if (!quiet) output({ status: "unavailable" });
|
|
1198
|
+
return;
|
|
1199
|
+
}
|
|
1200
|
+
const installed = [];
|
|
1201
|
+
for (const a of skillTargets()) {
|
|
1202
|
+
try {
|
|
1203
|
+
if (!existsSync7(a.root)) continue;
|
|
1204
|
+
if (existsSync7(a.target)) continue;
|
|
1205
|
+
mkdirSync5(dirname6(a.target), { recursive: true });
|
|
1206
|
+
copyFileSync(src, a.target);
|
|
1207
|
+
installed.push(a.name);
|
|
1208
|
+
process.stderr.write(`inplan: installed the inplan skill into ${a.name} (${a.target}). Set INPLAN_NO_SKILL_INSTALL=1 to skip.
|
|
1209
|
+
`);
|
|
1210
|
+
} catch {
|
|
1211
|
+
}
|
|
1212
|
+
}
|
|
1213
|
+
if (!quiet) output({ status: "ok", installed });
|
|
1214
|
+
}
|
|
1215
|
+
function doLogout() {
|
|
1216
|
+
clearAuth();
|
|
1217
|
+
output({ status: "logged_out" });
|
|
1218
|
+
}
|
|
1219
|
+
async function doUpload(file, args) {
|
|
1220
|
+
const s = await authedSession();
|
|
1221
|
+
if (!s) {
|
|
1222
|
+
process.stderr.write("inplan: not logged in (or session expired) \u2014 run `inplan login`\n");
|
|
1223
|
+
process.exit(1);
|
|
1224
|
+
}
|
|
1225
|
+
const orgSlug = getFlag(args, "org");
|
|
1226
|
+
const { data: mems, error } = await s.db.from("memberships").select("org_id, role, orgs(slug, name)").in("role", ["owner", "editor"]);
|
|
1227
|
+
if (error) {
|
|
1228
|
+
process.stderr.write(`inplan upload: ${error.message}
|
|
1229
|
+
`);
|
|
1230
|
+
process.exit(1);
|
|
1231
|
+
}
|
|
1232
|
+
const rows = mems ?? [];
|
|
1233
|
+
const orgOf = (r) => (Array.isArray(r.orgs) ? r.orgs[0] : r.orgs) ?? null;
|
|
1234
|
+
const pick = rows.find((r) => orgSlug ? orgOf(r)?.slug === orgSlug : true);
|
|
1235
|
+
if (!pick) {
|
|
1236
|
+
process.stderr.write(`inplan upload: no organization you can write to${orgSlug ? ` matching "${orgSlug}"` : ""}
|
|
1237
|
+
`);
|
|
1238
|
+
process.exit(1);
|
|
1239
|
+
}
|
|
1240
|
+
const org = orgOf(pick);
|
|
1241
|
+
const body = existsSync7(file) ? readFileSync6(file, "utf8") : "";
|
|
1242
|
+
const prov = gitProvenance(file);
|
|
1243
|
+
const repo = getFlag(args, "repo") ?? prov.repo;
|
|
1244
|
+
const path = getFlag(args, "path") ?? prov.path;
|
|
1245
|
+
const title = firstHeading(body) ?? basename3(path);
|
|
1246
|
+
const { data: doc, error: de } = await s.db.from("documents").insert({ org_id: pick.org_id, owner_id: s.session.user.id, title, repo, path, body }).select("id").single();
|
|
1247
|
+
if (de || !doc) {
|
|
1248
|
+
process.stderr.write(`inplan upload: ${de?.message ?? "could not create the cloud document"}
|
|
1249
|
+
`);
|
|
1250
|
+
process.exit(1);
|
|
1251
|
+
}
|
|
1252
|
+
const cloudDocId = doc.id;
|
|
1253
|
+
const status = {
|
|
1254
|
+
location: "cloud",
|
|
1255
|
+
cloudDocId,
|
|
1256
|
+
originalPath: file,
|
|
1257
|
+
lastSyncedHash: hashBody(body),
|
|
1258
|
+
...org?.slug ? { cloudLocator: { org: org.slug, repo, path } } : {}
|
|
1259
|
+
};
|
|
1260
|
+
writeStatus(docPaths(file).statusPath, status);
|
|
1261
|
+
output({ status: "uploaded", cloudDocId, ...org?.slug ? { locator: { org: org.slug, repo, path } } : {} });
|
|
1262
|
+
}
|
|
1263
|
+
function routeFor(file, cmd, args) {
|
|
1264
|
+
const p = docPaths(file);
|
|
1265
|
+
const status = readStatus(p.statusPath);
|
|
1266
|
+
if (status.location !== "cloud" || !status.cloudDocId) return { kind: "local" };
|
|
1267
|
+
const docId = status.cloudDocId;
|
|
1268
|
+
if (cmd === "signal" || !existsSync7(file)) return { kind: "cloud", docId };
|
|
1269
|
+
const local = readFileSync6(file, "utf8");
|
|
1270
|
+
const diverged = status.lastSyncedHash !== void 0 && hashBody(local) !== status.lastSyncedHash;
|
|
1271
|
+
if (!diverged || hasFlag(args, "use-cloud")) return { kind: "cloud", docId };
|
|
1272
|
+
if (hasFlag(args, "continue-locally")) {
|
|
1273
|
+
writeStatus(p.statusPath, { location: "local", originalPath: file, lastSyncedHash: hashBody(local) });
|
|
1274
|
+
return { kind: "local" };
|
|
1275
|
+
}
|
|
1276
|
+
return { kind: "reconcile", docId };
|
|
1277
|
+
}
|
|
1278
|
+
async function main() {
|
|
1279
|
+
const argv = process.argv.slice(2);
|
|
1280
|
+
const cmd = argv[0];
|
|
1281
|
+
if (cmd === "--version" || cmd === "-v") {
|
|
1282
|
+
process.stdout.write(`${VERSION}
|
|
1283
|
+
`);
|
|
1284
|
+
return;
|
|
1285
|
+
}
|
|
1286
|
+
if (cmd === "login") {
|
|
1287
|
+
doLogin(argv.slice(1));
|
|
1288
|
+
return;
|
|
1289
|
+
}
|
|
1290
|
+
if (cmd === "whoami") {
|
|
1291
|
+
await doWhoami();
|
|
1292
|
+
return;
|
|
1293
|
+
}
|
|
1294
|
+
if (cmd === "token") {
|
|
1295
|
+
await doToken();
|
|
1296
|
+
return;
|
|
1297
|
+
}
|
|
1298
|
+
if (cmd === "install-skill") {
|
|
1299
|
+
doInstallSkill(argv.slice(1));
|
|
1300
|
+
return;
|
|
1301
|
+
}
|
|
1302
|
+
if (cmd === "logout") {
|
|
1303
|
+
doLogout();
|
|
1304
|
+
return;
|
|
1305
|
+
}
|
|
1306
|
+
if (cmd === "update") {
|
|
1307
|
+
const updArgs = argv.slice(1);
|
|
1308
|
+
const pkg = getFlag(updArgs, "pkg") ?? UPDATE_PKG;
|
|
1309
|
+
if (hasFlag(updArgs, "check")) {
|
|
1310
|
+
output({ status: "update_check", pkg, ...await checkForUpdate({ pkg, current: VERSION }) });
|
|
1311
|
+
} else {
|
|
1312
|
+
const r = await selfUpdate(pkg);
|
|
1313
|
+
output({ status: r.ok ? "updated" : "update_failed", pkg, output: r.output });
|
|
1314
|
+
}
|
|
1315
|
+
return;
|
|
1316
|
+
}
|
|
1317
|
+
const args = argv.slice(1);
|
|
1318
|
+
const cursorFlag = getFlag(args, "cursor");
|
|
1319
|
+
const explicitCursor = cursorFlag !== void 0 ? Number(cursorFlag) : null;
|
|
1320
|
+
const model = getFlag(args, "model");
|
|
1321
|
+
const confirmed = new Set(
|
|
1322
|
+
(getFlag(args, "confirmed-comment-deletion") ?? "").split(",").map((s) => s.trim()).filter(Boolean)
|
|
1323
|
+
);
|
|
1324
|
+
if (!cmd || !["open", "wait", "signal", "status", "promote", "demote", "upload"].includes(cmd)) {
|
|
1325
|
+
process.stderr.write(
|
|
1326
|
+
"usage: inplan <open|wait|signal> <file|--remote DOC_ID> [--model NAME] [--cursor N] [--confirmed-comment-deletion=a,b] [--done] [--reload]\n inplan status <file>\n inplan upload <file> [--org <slug>] [--repo <name>] [--path <p>] (Collaborate on Cloud)\n inplan promote <file> --cloud-doc <docId> [--locator org/repo/path]\n inplan demote <file>\n inplan login --url <url> --anon <anon-key> --refresh <refresh-token> [--email <e>]\n inplan whoami | logout\n"
|
|
1327
|
+
);
|
|
1328
|
+
process.exit(64);
|
|
1329
|
+
}
|
|
1330
|
+
const remoteDocId = getFlag(args, "remote");
|
|
1331
|
+
if (remoteDocId) {
|
|
1332
|
+
await runRemote(cmd, remoteDocId, explicitCursor, confirmed, args, void 0, model);
|
|
1333
|
+
return;
|
|
1334
|
+
}
|
|
1335
|
+
const file = argv[1] ? resolve2(argv[1]) : argv[1];
|
|
1336
|
+
if (!file) {
|
|
1337
|
+
process.stderr.write(`inplan ${cmd}: missing <file>
|
|
1338
|
+
`);
|
|
1339
|
+
process.exit(64);
|
|
1340
|
+
}
|
|
1341
|
+
if (cmd === "status") {
|
|
1342
|
+
doStatus(file);
|
|
1343
|
+
return;
|
|
1344
|
+
}
|
|
1345
|
+
if (cmd === "upload") {
|
|
1346
|
+
await doUpload(file, args);
|
|
1347
|
+
return;
|
|
1348
|
+
}
|
|
1349
|
+
if (cmd === "promote") {
|
|
1350
|
+
doPromote(file, args);
|
|
1351
|
+
return;
|
|
1352
|
+
}
|
|
1353
|
+
if (cmd === "demote") {
|
|
1354
|
+
await doDemote(file, args);
|
|
1355
|
+
return;
|
|
1356
|
+
}
|
|
1357
|
+
const route = routeFor(file, cmd, args);
|
|
1358
|
+
if (route.kind === "reconcile") {
|
|
1359
|
+
output({
|
|
1360
|
+
status: "reconcile_required",
|
|
1361
|
+
message: "Local file differs from the last cloud sync. Re-run with --continue-locally to switch this doc back to local, or --use-cloud to keep collaborating in the cloud.",
|
|
1362
|
+
path: file,
|
|
1363
|
+
cloudDocId: route.docId
|
|
1364
|
+
});
|
|
1365
|
+
return;
|
|
1366
|
+
}
|
|
1367
|
+
if (route.kind === "cloud") {
|
|
1368
|
+
await runRemote(cmd, route.docId, explicitCursor, confirmed, args, file, model);
|
|
1369
|
+
return;
|
|
1370
|
+
}
|
|
1371
|
+
if (cmd === "signal") {
|
|
1372
|
+
const p = docPaths(file);
|
|
1373
|
+
mkdirSync5(p.controlDir, { recursive: true });
|
|
1374
|
+
const channel = new FsControlChannel(p);
|
|
1375
|
+
if (hasFlag(args, "done")) {
|
|
1376
|
+
await channel.append({ actor: "agent", type: LogEventType.AgentDoneSuggested });
|
|
1377
|
+
}
|
|
1378
|
+
if (hasFlag(args, "reload")) {
|
|
1379
|
+
await channel.append({ actor: "agent", type: LogEventType.ReloadSuggested });
|
|
1380
|
+
}
|
|
1381
|
+
output({ status: "signaled" });
|
|
1382
|
+
return;
|
|
1383
|
+
}
|
|
1384
|
+
if (!existsSync7(file)) {
|
|
1385
|
+
process.stderr.write(`inplan ${cmd}: file not found: ${file}
|
|
1386
|
+
`);
|
|
1387
|
+
process.exit(1);
|
|
1388
|
+
}
|
|
1389
|
+
if (cmd === "open") {
|
|
1390
|
+
const p = docPaths(file);
|
|
1391
|
+
mkdirSync5(p.controlDir, { recursive: true });
|
|
1392
|
+
const existing = runningEditorPid(p.logPath);
|
|
1393
|
+
if (existing !== null) {
|
|
1394
|
+
process.stderr.write(`[inplan] an editor is already open for this document (pid ${existing}); attaching without launching another window
|
|
1395
|
+
`);
|
|
1396
|
+
} else {
|
|
1397
|
+
const pid = spawnApp(file);
|
|
1398
|
+
if (pid !== null) {
|
|
1399
|
+
await new FsControlChannel(p).append({ actor: "agent", type: LogEventType.EditorPid, payload: { pid, v: CONTROL_LOG_VERSION } });
|
|
1400
|
+
}
|
|
1401
|
+
}
|
|
1402
|
+
}
|
|
1403
|
+
await waitCycle(fsBackend(file), explicitCursor, confirmed, model);
|
|
1404
|
+
}
|
|
1405
|
+
main().catch((err) => {
|
|
1406
|
+
process.stderr.write(`inplan: ${err.message}
|
|
1407
|
+
`);
|
|
1408
|
+
process.exit(1);
|
|
1409
|
+
});
|