@persistmemory/cli 0.5.0 → 0.7.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/dist/bin.js CHANGED
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- // node_modules/@persistmemory/sdk/dist/index.js
3
+ // ../sdk-js/dist/index.js
4
4
  function encodeQuery(params) {
5
5
  if (!params) return "";
6
6
  const search = new URLSearchParams();
@@ -234,6 +234,27 @@ var HttpClient = class {
234
234
  ...options ? { options } : {}
235
235
  });
236
236
  }
237
+ /** POST with a file as the body. The type describes the bytes, not JSON. */
238
+ async postBytes(path, bytes, contentType, query, options) {
239
+ return this.#request({
240
+ method: "POST",
241
+ path,
242
+ rawBody: bytes,
243
+ contentType,
244
+ ...query ? { query } : {},
245
+ ...options ? { options } : {}
246
+ });
247
+ }
248
+ /** GET that returns bytes rather than JSON, for downloading a file. */
249
+ async getBytes(path, query, options) {
250
+ return this.#request({
251
+ method: "GET",
252
+ path,
253
+ rawResponse: true,
254
+ ...query ? { query } : {},
255
+ ...options ? { options } : {}
256
+ });
257
+ }
237
258
  async patch(path, body, options) {
238
259
  return this.#request({
239
260
  method: "PATCH",
@@ -286,11 +307,20 @@ var HttpClient = class {
286
307
  this.#fetch(url, {
287
308
  method: request.method,
288
309
  headers: this.#headers(request),
289
- ...request.body !== void 0 ? { body: JSON.stringify(request.body) } : {},
310
+ ...request.rawBody !== void 0 ? { body: request.rawBody } : request.body !== void 0 ? { body: JSON.stringify(request.body) } : {},
290
311
  signal: deadline.signal
291
312
  }),
292
313
  deadline.signal
293
314
  );
315
+ if (request.rawResponse && response.ok) {
316
+ const disposition = response.headers.get("content-disposition") ?? "";
317
+ const named = /filename="([^"]+)"/.exec(disposition)?.[1];
318
+ return {
319
+ bytes: new Uint8Array(await response.arrayBuffer()),
320
+ contentType: response.headers.get("content-type") ?? "application/octet-stream",
321
+ ...named ? { filename: named } : {}
322
+ };
323
+ }
294
324
  const payload = await readBody(response);
295
325
  if (!response.ok) throw errorFromResponse(response.status, payload, response.headers);
296
326
  return payload;
@@ -311,9 +341,11 @@ var HttpClient = class {
311
341
  return {
312
342
  // The only place the key is ever read.
313
343
  authorization: `Bearer ${this.#apiKey}`,
314
- accept: "application/json",
344
+ // A download route answers with the file's own type, so `*/*` rather
345
+ // than a promise to accept only JSON that the server would have to break.
346
+ accept: request.rawResponse ? "*/*" : "application/json",
315
347
  "user-agent": this.#userAgent,
316
- ...request.body !== void 0 ? { "content-type": "application/json" } : {},
348
+ ...request.rawBody !== void 0 ? { "content-type": request.contentType ?? "application/octet-stream" } : request.body !== void 0 ? { "content-type": "application/json" } : {},
317
349
  ...request.options?.idempotencyKey ? { "idempotency-key": request.options.idempotencyKey } : {}
318
350
  };
319
351
  }
@@ -344,7 +376,7 @@ var SignalFired = class extends Error {
344
376
  };
345
377
  function untilAborted(work, signal) {
346
378
  work.catch(() => void 0);
347
- return new Promise((resolve9, reject) => {
379
+ return new Promise((resolve8, reject) => {
348
380
  if (signal.aborted) {
349
381
  reject(new SignalFired());
350
382
  return;
@@ -354,7 +386,7 @@ function untilAborted(work, signal) {
354
386
  work.then(
355
387
  (value) => {
356
388
  signal.removeEventListener("abort", onAbort);
357
- resolve9(value);
389
+ resolve8(value);
358
390
  },
359
391
  (error) => {
360
392
  signal.removeEventListener("abort", onAbort);
@@ -364,14 +396,14 @@ function untilAborted(work, signal) {
364
396
  });
365
397
  }
366
398
  function defaultSleep(ms, signal) {
367
- return new Promise((resolve9, reject) => {
399
+ return new Promise((resolve8, reject) => {
368
400
  if (signal?.aborted) {
369
401
  reject(new AbortError());
370
402
  return;
371
403
  }
372
404
  const timer = setTimeout(() => {
373
405
  signal?.removeEventListener("abort", onAbort);
374
- resolve9();
406
+ resolve8();
375
407
  }, ms);
376
408
  function onAbort() {
377
409
  clearTimeout(timer);
@@ -617,17 +649,22 @@ var Spaces = class {
617
649
  /**
618
650
  * The memories filed in a Space.
619
651
  *
620
- * This endpoint answers `{ data, pagination: { limit } }` with no cursor: it
621
- * returns the first `limit` members and stops. Wrapped in a `Paginated`
622
- * anyway so it reads like every other list, and it simply yields one page -
623
- * a caller who needs more should filter `memories.list` by `spaceIds`, which
624
- * is the endpoint that actually pages.
652
+ * The cursor is PASSED. This fetch used to ignore the paginator's cursor
653
+ * on the stale belief that the endpoint had none the server has minted
654
+ * `pagination.nextCursor` since it started paging, and its own comment
655
+ * says "both SDKs iterate by reading pagination". Ignoring it meant every
656
+ * page request was identical: the loop guard saw a non-advancing fetch and
657
+ * stopped silently, so `all()` returned the first page twice and dropped
658
+ * everything after it — duplicated AND truncated data, with no error.
625
659
  */
626
660
  memories(id, params = {}, options) {
627
661
  return new Paginated(
628
- () => this.#http.get(
662
+ (cursor) => this.#http.get(
629
663
  `/api/v1/spaces/${encodeURIComponent(id)}/memories`,
630
- { ...params.limit !== void 0 ? { limit: params.limit } : {} },
664
+ {
665
+ ...params.limit !== void 0 ? { limit: params.limit } : {},
666
+ ...cursor !== void 0 ? { cursor } : {}
667
+ },
631
668
  options
632
669
  )
633
670
  );
@@ -653,6 +690,94 @@ var Spaces = class {
653
690
  options
654
691
  );
655
692
  }
693
+ /* ----------------------- who else can see it ----------------------- */
694
+ /**
695
+ * Who can see this Space, including invitations nobody has accepted.
696
+ *
697
+ * A DIFFERENT EDGE from `memories()` next door, and the difference is worth
698
+ * holding on to: that one maps a MEMORY to a Space, this one maps a PERSON
699
+ * to a Space. The server keeps them in two tables with two names for exactly
700
+ * that reason.
701
+ *
702
+ * Read `acceptedAt` before you render a row. An invitation grants nothing
703
+ * until it is accepted, so a list that draws invited and accepted people the
704
+ * same way tells its user somebody is reading their memories when nobody is.
705
+ *
706
+ * Paginated like every other list here. A Space has a handful of
707
+ * collaborators rather than thousands, so this will usually be one page -
708
+ * which costs a caller nothing and means the shape does not change if a
709
+ * Space ever has an organisation on it.
710
+ */
711
+ collaborators(id, params = {}, options) {
712
+ return new Paginated(
713
+ (cursor) => this.#http.get(
714
+ `/api/v1/sharing/spaces/${encodeURIComponent(id)}/collaborators`,
715
+ {
716
+ ...params.limit !== void 0 ? { limit: params.limit } : {},
717
+ ...cursor !== void 0 ? { cursor } : {}
718
+ },
719
+ options
720
+ )
721
+ );
722
+ }
723
+ /**
724
+ * Offers somebody sight of a Space. Answers with the invitation.
725
+ *
726
+ * AN OFFER, NOT A GRANT, and the returned `acceptedAt` will be absent to
727
+ * prove it. The recipient has to accept before they can see anything, which
728
+ * is the property that keeps "nothing enters your memory without you" true
729
+ * even when somebody else starts the sharing. Do not tell your user their
730
+ * Space "has been shared" on the strength of a 2xx here.
731
+ *
732
+ * WHAT THEY GET IS THE WHOLE SPACE: every memory already filed in it and
733
+ * every memory that lands in it afterwards. There is no narrower grant, and
734
+ * `role` does not make one - it decides what they may do BESIDES read.
735
+ *
736
+ * Worth an idempotency key when a person is behind it. A double-clicked
737
+ * "share" is two invitations to the same address, and the second one is a
738
+ * second email arriving at somebody who has already been asked.
739
+ */
740
+ async share(id, params, options) {
741
+ return this.#http.post(
742
+ `/api/v1/sharing/spaces/${encodeURIComponent(id)}/collaborators`,
743
+ params,
744
+ options
745
+ );
746
+ }
747
+ /**
748
+ * Ends somebody's access, or withdraws an invitation they never accepted.
749
+ *
750
+ * Nothing was ever copied into their account - a collaborator SEES the
751
+ * owner's memories rather than holding a duplicate - so this is one write
752
+ * and not a cascade, and there is no orphaned copy left behind.
753
+ *
754
+ * A body on a DELETE, matching `removeMemories` above. The alternative is an
755
+ * address in a path segment, where every `.`, `+` and `@` is a chance for a
756
+ * proxy or a router to normalise somebody else's email into the one that
757
+ * gets revoked.
758
+ */
759
+ async unshare(id, email, options) {
760
+ return this.#http.delete(
761
+ `/api/v1/sharing/spaces/${encodeURIComponent(id)}/collaborators`,
762
+ { email },
763
+ options
764
+ );
765
+ }
766
+ /**
767
+ * Changes what an existing collaborator may do. Never invites anybody.
768
+ *
769
+ * The quiet one. Moving somebody from `viewer` to `owner` sends no
770
+ * invitation and needs no acceptance, and afterwards they can share the
771
+ * Space onward and revoke the person who promoted them. Show your user what
772
+ * `owner` means before you send this, not after.
773
+ */
774
+ async setRole(id, params, options) {
775
+ return this.#http.patch(
776
+ `/api/v1/sharing/spaces/${encodeURIComponent(id)}/collaborators`,
777
+ params,
778
+ options
779
+ );
780
+ }
656
781
  };
657
782
  var Sources = class {
658
783
  #http;
@@ -890,6 +1015,119 @@ var Conversations = class {
890
1015
  );
891
1016
  }
892
1017
  };
1018
+ var Google = class {
1019
+ #http;
1020
+ constructor(http) {
1021
+ this.#http = http;
1022
+ }
1023
+ /** Files by name, newest first. Omit the query for recently changed ones. */
1024
+ async searchDrive(params = {}, options) {
1025
+ return this.#http.get(
1026
+ "/api/v1/google/drive/files",
1027
+ {
1028
+ ...params.query !== void 0 ? { query: params.query } : {},
1029
+ ...params.limit !== void 0 ? { limit: params.limit } : {}
1030
+ },
1031
+ options
1032
+ );
1033
+ }
1034
+ async getDriveFile(fileId, options) {
1035
+ return this.#http.get(
1036
+ `/api/v1/google/drive/files/${encodeURIComponent(fileId)}`,
1037
+ void 0,
1038
+ options
1039
+ );
1040
+ }
1041
+ /**
1042
+ * The bytes of a Drive file.
1043
+ *
1044
+ * A Google Doc, Sheet or Slide holds no bytes of its own and is exported on
1045
+ * the way - a document as PDF, a spreadsheet as CSV - so `filename` comes
1046
+ * back describing what it BECAME. Writing it under the id instead produces a
1047
+ * file nothing will open.
1048
+ */
1049
+ async downloadDriveFile(fileId, options) {
1050
+ return this.#http.getBytes(
1051
+ `/api/v1/google/drive/files/${encodeURIComponent(fileId)}/content`,
1052
+ void 0,
1053
+ options
1054
+ );
1055
+ }
1056
+ /**
1057
+ * Writes a file into the user's Drive.
1058
+ *
1059
+ * Needs one of the Drive write permissions on their connection. A read-only
1060
+ * grant is refused by Google, and the error names the missing permission
1061
+ * rather than reporting a failed upload - one is fixed with a checkbox and
1062
+ * the other sends somebody looking for a bug.
1063
+ */
1064
+ async saveToDrive(params, options) {
1065
+ return this.#http.postBytes(
1066
+ "/api/v1/google/drive/files",
1067
+ params.bytes,
1068
+ params.contentType ?? "application/octet-stream",
1069
+ {
1070
+ name: params.name,
1071
+ ...params.folderId !== void 0 ? { folderId: params.folderId } : {}
1072
+ },
1073
+ options
1074
+ );
1075
+ }
1076
+ /**
1077
+ * Recent messages - senders, subjects and a one-line preview, never bodies.
1078
+ *
1079
+ * `query` is Gmail's own syntax passed through as written: `from:priya`,
1080
+ * `has:attachment`, `newer_than:7d`. It selects within the connected mailbox
1081
+ * and cannot reach another one.
1082
+ */
1083
+ async searchMail(params = {}, options) {
1084
+ return this.#http.get(
1085
+ "/api/v1/google/mail",
1086
+ {
1087
+ ...params.query !== void 0 ? { query: params.query } : {},
1088
+ ...params.limit !== void 0 ? { limit: params.limit } : {}
1089
+ },
1090
+ options
1091
+ );
1092
+ }
1093
+ /** One message, with its body and the names of what is attached. */
1094
+ async readMail(messageId, options) {
1095
+ return this.#http.get(
1096
+ `/api/v1/google/mail/${encodeURIComponent(messageId)}`,
1097
+ void 0,
1098
+ options
1099
+ );
1100
+ }
1101
+ /**
1102
+ * The bytes of one attachment.
1103
+ *
1104
+ * Separate from `readMail` so listing a mailbox never drags attachments
1105
+ * across the network: a message with a 40 MB deck should not cost 40 MB to
1106
+ * summarise.
1107
+ */
1108
+ async downloadAttachment(messageId, attachmentId, options) {
1109
+ return this.#http.getBytes(
1110
+ `/api/v1/google/mail/${encodeURIComponent(messageId)}/attachments/${encodeURIComponent(attachmentId)}`,
1111
+ void 0,
1112
+ options
1113
+ );
1114
+ }
1115
+ /** Sends as the connected account. Needs the send permission. */
1116
+ async sendMail(params, options) {
1117
+ return this.#http.post("/api/v1/google/mail/send", params, options);
1118
+ }
1119
+ /** People in the user's contacts. Omit the query to list them. */
1120
+ async contacts(params = {}, options) {
1121
+ return this.#http.get(
1122
+ "/api/v1/google/contacts",
1123
+ {
1124
+ ...params.query !== void 0 ? { query: params.query } : {},
1125
+ ...params.limit !== void 0 ? { limit: params.limit } : {}
1126
+ },
1127
+ options
1128
+ );
1129
+ }
1130
+ };
893
1131
  var Integrations = class {
894
1132
  #http;
895
1133
  constructor(http) {
@@ -1020,6 +1258,8 @@ var PersistMemory = class {
1020
1258
  conflicts;
1021
1259
  conversations;
1022
1260
  integrations;
1261
+ /** Drive, mail and contacts on the user's connected Google account. */
1262
+ google;
1023
1263
  health;
1024
1264
  agent;
1025
1265
  #http;
@@ -1036,6 +1276,7 @@ var PersistMemory = class {
1036
1276
  this.conflicts = new Conflicts(this.#http);
1037
1277
  this.conversations = new Conversations(this.#http);
1038
1278
  this.integrations = new Integrations(this.#http);
1279
+ this.google = new Google(this.#http);
1039
1280
  this.health = new Health(this.#http);
1040
1281
  this.agent = new Agent(this.#http);
1041
1282
  }
@@ -1293,6 +1534,23 @@ function maskToken(token) {
1293
1534
  return `${token.slice(0, 4)}\u2026${token.slice(-4)}`;
1294
1535
  }
1295
1536
 
1537
+ // src/display-safe.ts
1538
+ var NEUTRALISED = /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f-\u009f\u061c\u200b\u200e\u200f\u202a-\u202e\u2060-\u2064\u2066-\u206f\ufeff\ufff9-\ufffb]|[\u{e0000}-\u{e007f}]/gu;
1539
+ var LINE_BREAKS = /\r\n|[\r\u2028\u2029]/g;
1540
+ function displaySafe(text) {
1541
+ return text.replace(LINE_BREAKS, "\n").replace(NEUTRALISED, "");
1542
+ }
1543
+ function jsonSafe(value) {
1544
+ return JSON.stringify(value, void 0, 2).replace(
1545
+ NEUTRALISED,
1546
+ (match) => (
1547
+ // Per UTF-16 unit, so a tags-block codepoint becomes its surrogate pair
1548
+ // rather than one escape JSON cannot represent.
1549
+ match.split("").map((unit) => "\\u" + unit.charCodeAt(0).toString(16).padStart(4, "0")).join("")
1550
+ )
1551
+ );
1552
+ }
1553
+
1296
1554
  // src/output.ts
1297
1555
  var OUTPUT_FORMATS = ["table", "json", "yaml", "csv", "tsv"];
1298
1556
  function isOutputFormat(value) {
@@ -1301,7 +1559,7 @@ function isOutputFormat(value) {
1301
1559
  function render(rows, columns, options) {
1302
1560
  switch (options.format) {
1303
1561
  case "json":
1304
- return JSON.stringify(rows, null, 2);
1562
+ return jsonSafe(rows);
1305
1563
  case "yaml":
1306
1564
  return toYaml(rows);
1307
1565
  case "csv":
@@ -1313,13 +1571,13 @@ function render(rows, columns, options) {
1313
1571
  }
1314
1572
  }
1315
1573
  function renderOne(row, fields, options) {
1316
- if (options.format === "json") return JSON.stringify(row, null, 2);
1574
+ if (options.format === "json") return jsonSafe(row);
1317
1575
  if (options.format === "yaml") return toYaml(row);
1318
1576
  if (options.format === "csv" || options.format === "tsv") {
1319
1577
  return delimited([row], fields, options.format === "csv" ? "," : " ");
1320
1578
  }
1321
1579
  const width = Math.max(...fields.map((field) => field.header.length));
1322
- return fields.map((field) => `${field.header.padEnd(width)} ${field.value(row)}`).join("\n");
1580
+ return fields.map((field) => `${field.header.padEnd(width)} ${displaySafe(field.value(row))}`).join("\n");
1323
1581
  }
1324
1582
  function table(rows, columns, width = process.stdout.columns || 120) {
1325
1583
  if (rows.length === 0) return "";
@@ -1379,7 +1637,8 @@ function isBlock(value) {
1379
1637
  if (Array.isArray(value)) return value.length > 0;
1380
1638
  return typeof value === "object" && value !== null && Object.keys(value).length > 0;
1381
1639
  }
1382
- function yamlString(value) {
1640
+ function yamlString(text) {
1641
+ const value = displaySafe(text);
1383
1642
  if (value === "") return '""';
1384
1643
  if (value.includes("\n")) {
1385
1644
  return `|-
@@ -1391,7 +1650,7 @@ ${value.split("\n").map((line) => ` ${line}`).join("\n")}`;
1391
1650
  return ambiguous ? `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"` : value;
1392
1651
  }
1393
1652
  function oneLine(value) {
1394
- return value.replace(/\s*\n\s*/g, " ").trim();
1653
+ return displaySafe(value).replace(/\s*\n\s*/g, " ").trim();
1395
1654
  }
1396
1655
  function clip(value, width) {
1397
1656
  if (value.length <= width) return value;
@@ -1404,7 +1663,7 @@ function shortDate(iso) {
1404
1663
  }
1405
1664
 
1406
1665
  // src/help.ts
1407
- var VERSION = true ? "0.5.0" : versionFromManifest();
1666
+ var VERSION = true ? "0.7.0" : versionFromManifest();
1408
1667
  var PACKAGE = "@persistmemory/cli";
1409
1668
  var HELP = `
1410
1669
  pm \u2014 PersistMemory from your terminal
@@ -1442,28 +1701,31 @@ var HELP = `
1442
1701
  to what is in it
1443
1702
  spaces merge "A" "B" --name "C" a new Space holding both, originals kept
1444
1703
 
1704
+ spaces sharing "Acme" who can see it, and who has accepted
1705
+ spaces share "Acme" <email> --role viewer|editor
1706
+ offer somebody sight of EVERYTHING in it,
1707
+ now and later. You must say the role.
1708
+ They see nothing until they accept.
1709
+ spaces unshare "Acme" <email> end their access
1710
+ spaces role "Acme" <email> editor
1711
+ change what an existing collaborator may do
1712
+
1445
1713
  remember <text> capture text
1446
1714
  remember - capture whatever is piped in
1447
1715
  remember --file <path> capture a file's contents
1448
1716
 
1449
1717
  agent answer file and command requests from this
1450
- machine. Reads the folders in your home
1451
- directory \u2014 never anything hidden \u2014 and
1452
- nothing outside them. It says which ones
1453
- when it starts. Stays in the foreground;
1454
- background it and stop it later with:
1718
+ machine. Reads anywhere on it \u2014 you
1719
+ approve every request first, and see the
1720
+ exact path or command before you do.
1721
+ Stays in the foreground; background it
1722
+ and stop it later with:
1455
1723
  nohup pm agent > ~/agent.log 2>&1 &
1456
1724
  pkill -f "pm agent"
1457
- agent --root <dir> [--root ...] choose the folders for this run
1725
+ agent --root <dir> [--root ...] narrow it to these folders, for this run
1458
1726
 
1459
- Settings live in ~/.persistmemory/agent.json, and apply every time:
1460
-
1461
- {
1462
- "roots": ["~/code", "~/Documents"],
1463
- "mode": "ask", ask | auto-read | plan
1464
- "allow": ["swift"], programs this machine treats as reads
1465
- "deny": ["rm"] programs it refuses, whatever is approved
1466
- }
1727
+ A command that reaches the network, or that runs a language, is refused by
1728
+ this machine whatever anybody approves.
1467
1729
 
1468
1730
  search <query> search your memory
1469
1731
  list memories the most recent memories
@@ -1622,8 +1884,8 @@ async function startLoopback(options = {}) {
1622
1884
  const timeoutMs = options.timeoutMs ?? 5 * 60 * 1e3;
1623
1885
  let resolveCallback;
1624
1886
  let rejectCallback;
1625
- const received = new Promise((resolve9, reject) => {
1626
- resolveCallback = resolve9;
1887
+ const received = new Promise((resolve8, reject) => {
1888
+ resolveCallback = resolve8;
1627
1889
  rejectCallback = reject;
1628
1890
  });
1629
1891
  const server = createServer((request, response) => {
@@ -1659,9 +1921,9 @@ async function startLoopback(options = {}) {
1659
1921
  response.end(donePage(callback));
1660
1922
  resolveCallback?.(callback);
1661
1923
  });
1662
- await new Promise((resolve9, reject) => {
1924
+ await new Promise((resolve8, reject) => {
1663
1925
  server.once("error", reject);
1664
- server.listen(0, "127.0.0.1", resolve9);
1926
+ server.listen(0, "127.0.0.1", resolve8);
1665
1927
  });
1666
1928
  const address = server.address();
1667
1929
  if (address === null || typeof address === "string") {
@@ -1925,7 +2187,7 @@ function safeEqual(a, b) {
1925
2187
  }
1926
2188
  async function openBrowser(url) {
1927
2189
  const [command, args] = process.platform === "darwin" ? ["open", [url]] : process.platform === "win32" ? ["cmd", ["/c", "start", "", url]] : ["xdg-open", [url]];
1928
- await new Promise((resolve9, reject) => {
2190
+ await new Promise((resolve8, reject) => {
1929
2191
  const child = spawn(command, args, {
1930
2192
  stdio: "ignore",
1931
2193
  // Detached so closing the terminal does not close the browser, and so
@@ -1934,7 +2196,7 @@ async function openBrowser(url) {
1934
2196
  });
1935
2197
  child.once("error", reject);
1936
2198
  child.unref();
1937
- resolve9();
2199
+ resolve8();
1938
2200
  });
1939
2201
  }
1940
2202
  async function describe(response) {
@@ -1996,16 +2258,16 @@ async function currentCredential(resolved, deps) {
1996
2258
  // src/context.ts
1997
2259
  import { createInterface } from "node:readline";
1998
2260
  async function askOnTty(prompt, input = process.stdin, output = process.stdout) {
1999
- return new Promise((resolve9) => {
2261
+ return new Promise((resolve8) => {
2000
2262
  const readline = createInterface({ input, output });
2001
2263
  let answered = false;
2002
2264
  readline.question(prompt, (answer3) => {
2003
2265
  answered = true;
2004
- resolve9(answer3.trim());
2266
+ resolve8(answer3.trim());
2005
2267
  readline.close();
2006
2268
  });
2007
2269
  readline.once("close", () => {
2008
- if (!answered) resolve9("");
2270
+ if (!answered) resolve8("");
2009
2271
  });
2010
2272
  });
2011
2273
  }
@@ -2015,16 +2277,16 @@ var BACKSPACE = "\b";
2015
2277
  async function readSecretFromTty(prompt) {
2016
2278
  const input = process.stdin;
2017
2279
  if (!input.isTTY) {
2018
- return new Promise((resolve9) => {
2280
+ return new Promise((resolve8) => {
2019
2281
  const readline = createInterface({ input });
2020
2282
  let answered = false;
2021
2283
  readline.once("line", (line) => {
2022
2284
  answered = true;
2023
- resolve9(line.trim());
2285
+ resolve8(line.trim());
2024
2286
  readline.close();
2025
2287
  });
2026
2288
  readline.once("close", () => {
2027
- if (!answered) resolve9("");
2289
+ if (!answered) resolve8("");
2028
2290
  });
2029
2291
  });
2030
2292
  }
@@ -2033,14 +2295,14 @@ async function readSecretFromTty(prompt) {
2033
2295
  input.setRawMode?.(true);
2034
2296
  input.resume();
2035
2297
  input.setEncoding("utf8");
2036
- return new Promise((resolve9) => {
2298
+ return new Promise((resolve8) => {
2037
2299
  let value = "";
2038
2300
  const finish2 = () => {
2039
2301
  input.removeListener("data", onData);
2040
2302
  input.setRawMode?.(previouslyRaw);
2041
2303
  input.pause();
2042
2304
  process.stdout.write("\n");
2043
- resolve9(value.trim());
2305
+ resolve8(value.trim());
2044
2306
  };
2045
2307
  const onData = (chunk) => {
2046
2308
  for (const character of chunk) {
@@ -2076,9 +2338,9 @@ async function readStdin() {
2076
2338
 
2077
2339
  // src/commands/agent.ts
2078
2340
  import { hostname } from "node:os";
2079
- import { homedir as homedir3 } from "node:os";
2080
- import { basename, join as join6, resolve as resolve4 } from "node:path";
2081
- import { existsSync as existsSync6, readFileSync as readFileSync6, readdirSync as readdirSync2, statSync as statSync4, writeFileSync as writeFileSync4 } from "node:fs";
2341
+ import { homedir as homedir2 } from "node:os";
2342
+ import { basename, join as join5, resolve as resolve3 } from "node:path";
2343
+ import { existsSync as existsSync5, readFileSync as readFileSync5, readdirSync, statSync as statSync3, writeFileSync as writeFileSync4 } from "node:fs";
2082
2344
 
2083
2345
  // src/files.ts
2084
2346
  import { existsSync as existsSync3, readFileSync as readFileSync3, realpathSync, statSync, writeFileSync as writeFileSync3 } from "node:fs";
@@ -2189,9 +2451,26 @@ ${head.join("\n")}`;
2189
2451
  // src/commands/run-command.ts
2190
2452
  import { spawn as spawn2 } from "node:child_process";
2191
2453
  import { existsSync as existsSync4, readFileSync as readFileSync4, statSync as statSync2 } from "node:fs";
2192
- import { isAbsolute as isAbsolute2, join as join4, resolve as resolvePath } from "node:path";
2454
+ import { isAbsolute as isAbsolute2, join as join4, relative as relative2, resolve as resolvePath } from "node:path";
2193
2455
  var CLASSES = new Map([
2194
- // Reads. Report on the filesystem and change nothing.
2456
+ /*
2457
+ Reads. Report on the machine and change nothing.
2458
+
2459
+ Several of these are reads only until a particular flag is passed —
2460
+ `find -exec` runs any program, `sort -o` writes a file, `dmesg -C` empties
2461
+ the kernel buffer — and the name alone cannot see that. `FLAG_CHANGES_CLASS`
2462
+ below is where those come back out again; a name in this list is the
2463
+ starting point, not the verdict.
2464
+
2465
+ `journalctl`, `dmesg` and `zcat` are here because of what people actually
2466
+ ask for. "Check the logs on my server" is `journalctl -n 200 -u nginx`,
2467
+ `dmesg -T`, or `zcat` on a rotated `.gz`, and leaving all three in
2468
+ `unknown` bought nothing: every command already waits for a person, so the
2469
+ only thing an honest `read` label changes is that it stops being a lie.
2470
+ `zcat` earns it by only ever writing to stdout — `gunzip` and `gzip -d`
2471
+ delete their input, which is why neither is here — and the decompression
2472
+ bomb it can be handed is bounded by the output cap rather than by trust.
2473
+ */
2195
2474
  ...[
2196
2475
  "ls",
2197
2476
  "cat",
@@ -2219,7 +2498,10 @@ var CLASSES = new Map([
2219
2498
  "dirname",
2220
2499
  "realpath",
2221
2500
  "ps",
2222
- "env"
2501
+ "env",
2502
+ "journalctl",
2503
+ "dmesg",
2504
+ "zcat"
2223
2505
  ].map((name) => [name, "read"]),
2224
2506
  // Writes. Recoverable or not, they change the machine.
2225
2507
  ...[
@@ -2266,16 +2548,159 @@ var SUBCOMMAND_READS = /* @__PURE__ */ new Map([
2266
2548
  "blame",
2267
2549
  "shortlog",
2268
2550
  "ls-files",
2269
- "rev-parse",
2270
- "config"
2551
+ "rev-parse"
2271
2552
  ])
2272
2553
  ],
2273
- ["npm", /* @__PURE__ */ new Set(["ls", "list", "view", "outdated", "why", "config"])],
2274
- ["yarn", /* @__PURE__ */ new Set(["list", "why", "info", "config"])],
2275
- ["docker", /* @__PURE__ */ new Set(["ps", "images", "logs", "inspect"])]
2554
+ ["npm", /* @__PURE__ */ new Set(["ls", "list", "view", "outdated", "why"])],
2555
+ ["yarn", /* @__PURE__ */ new Set(["list", "why", "info"])],
2556
+ /*
2557
+ `docker logs` STAYS a read, having been looked at again.
2558
+
2559
+ The case against it is real: every docker subcommand is a request to a
2560
+ daemon running as root, so "it only reads" is a statement about the
2561
+ subcommand and not about the socket it is sent down. The case for keeping
2562
+ it is that `logs` cannot start, stop or change anything — it prints what a
2563
+ container already wrote to its own stdout — and it is one of the three
2564
+ things anybody means by "check the logs on my server".
2565
+
2566
+ What actually needed fixing was not the label but the flags in front of
2567
+ it: see `REMOTE_FLAGS`. The residue this leaves is `docker --config=<dir>
2568
+ logs x`, which could load a CLI plugin from a directory the argv chose;
2569
+ `logs` is built in rather than a plugin, and a plugin has to be planted on
2570
+ the disk first, so it is left standing rather than papered over here.
2571
+ */
2572
+ ["docker", /* @__PURE__ */ new Set(["ps", "images", "logs", "inspect"])],
2573
+ /*
2574
+ `systemctl`, which was in no table at all and so was `unknown` whole.
2575
+
2576
+ It is the same shape as `git` and the reason this map exists: `systemctl
2577
+ status nginx` asks the manager a question over its bus, and `systemctl
2578
+ stop nginx` takes somebody's website down. One name, two powers, and the
2579
+ first non-flag word is the whole difference. Everything not listed —
2580
+ start, stop, restart, enable, mask, daemon-reload — lands in `write`,
2581
+ which is where it belongs and where it already effectively was.
2582
+
2583
+ `-H user@host` is not here because it is not a subcommand: it makes
2584
+ systemctl talk to ANOTHER machine over ssh, which is `REMOTE_FLAGS` below
2585
+ and a refusal, not a class.
2586
+ */
2587
+ [
2588
+ "systemctl",
2589
+ /* @__PURE__ */ new Set([
2590
+ "status",
2591
+ "show",
2592
+ "cat",
2593
+ "list-units",
2594
+ "list-unit-files",
2595
+ "list-timers",
2596
+ "list-sockets",
2597
+ "list-dependencies",
2598
+ "list-jobs",
2599
+ "list-machines",
2600
+ "is-active",
2601
+ "is-enabled",
2602
+ "is-failed",
2603
+ "is-system-running",
2604
+ "get-default",
2605
+ "show-environment"
2606
+ ])
2607
+ ]
2608
+ ]);
2609
+ var FLAG_CHANGES_CLASS = /* @__PURE__ */ new Map([
2610
+ [
2611
+ "find",
2612
+ new Map([
2613
+ // Runs anything, once per file found. A model that cannot get `bash`
2614
+ // past this file can get `find . -name x -exec bash {} ;` past it.
2615
+ ...["-exec", "-execdir", "-ok", "-okdir"].map(
2616
+ (flag) => [flag, "interpreter"]
2617
+ ),
2618
+ ...["-delete", "-fprint", "-fprint0", "-fprintf", "-fls"].map(
2619
+ (flag) => [flag, "write"]
2620
+ )
2621
+ ])
2622
+ ],
2623
+ ["sort", /* @__PURE__ */ new Map([["-o", "write"], ["--output", "write"]])],
2624
+ /*
2625
+ `fd`, `rg` and `tree` were left in the read table with no entry here, and
2626
+ the first two run arbitrary programs exactly the way `find -exec` does.
2627
+
2628
+ `fd -x` IS `find -exec` under a newer name, and `rg --pre` runs a program
2629
+ per file to decode it. Both were classified `read` and therefore ran
2630
+ automatically: `fd --exec curl http://…` reached the network, which is the
2631
+ one refusal nothing is supposed to override. They were refused only when
2632
+ the helper was spelled with a slash — `pathOutsideRoots` catching
2633
+ `/bin/sh` — so naming it `sh`, or putting it inside the roots, evaporated
2634
+ the refusal. That is not a boundary, it is a coincidence about spelling.
2635
+
2636
+ `tree -o` writes a file, the same shape as `sort -o`.
2637
+ */
2638
+ [
2639
+ "fd",
2640
+ new Map(
2641
+ ["-x", "--exec", "-X", "--exec-batch"].map(
2642
+ (flag) => [flag, "interpreter"]
2643
+ )
2644
+ )
2645
+ ],
2646
+ [
2647
+ "rg",
2648
+ new Map(
2649
+ ["--pre", "--hostname-bin"].map((flag) => [flag, "interpreter"])
2650
+ )
2651
+ ],
2652
+ ["tree", /* @__PURE__ */ new Map([["-o", "write"]])],
2653
+ [
2654
+ "journalctl",
2655
+ new Map(
2656
+ [
2657
+ "--vacuum-size",
2658
+ "--vacuum-time",
2659
+ "--vacuum-files",
2660
+ "--rotate",
2661
+ "--flush",
2662
+ "--sync",
2663
+ "--relinquish-var",
2664
+ "--smart-relinquish-var",
2665
+ "--setup-keys",
2666
+ "--update-catalog"
2667
+ ].map((flag) => [flag, "write"])
2668
+ )
2669
+ ],
2670
+ [
2671
+ "dmesg",
2672
+ new Map(
2673
+ // `-c` reads AND clears, which is the one that costs somebody the
2674
+ // evidence they were reading the log to find.
2675
+ [
2676
+ "-C",
2677
+ "--clear",
2678
+ "-c",
2679
+ "--read-clear",
2680
+ "-D",
2681
+ "--console-off",
2682
+ "-E",
2683
+ "--console-on",
2684
+ "-n",
2685
+ "--console-level"
2686
+ ].map((flag) => [flag, "write"])
2687
+ )
2688
+ ]
2689
+ ]);
2690
+ var REMOTE_FLAGS = /* @__PURE__ */ new Map([
2691
+ ["docker", /* @__PURE__ */ new Set(["-H", "--host", "--context"])],
2692
+ ["systemctl", /* @__PURE__ */ new Set(["-H", "--host"])]
2693
+ ]);
2694
+ var ENDLESS_FLAGS = /* @__PURE__ */ new Map([
2695
+ ["tail", /* @__PURE__ */ new Set(["-f", "-F", "--follow"])],
2696
+ ["journalctl", /* @__PURE__ */ new Set(["-f", "--follow"])],
2697
+ ["dmesg", /* @__PURE__ */ new Set(["-w", "--follow", "-W", "--follow-new"])],
2698
+ ["docker logs", /* @__PURE__ */ new Set(["-f", "--follow"])],
2699
+ ["kubectl logs", /* @__PURE__ */ new Set(["-f", "--follow"])]
2276
2700
  ]);
2277
2701
  var MAX_OUTPUT_BYTES = 256 * 1024;
2278
2702
  var TIMEOUT_MS = 2e4;
2703
+ var AFTER_KILL_MS = 1e3;
2279
2704
  function describe2(argv) {
2280
2705
  return argv.join(" ");
2281
2706
  }
@@ -2283,15 +2708,73 @@ function programOf(argv) {
2283
2708
  const first = argv[0] ?? "";
2284
2709
  return first.split("/").pop() ?? first;
2285
2710
  }
2711
+ function subcommandOf(argv) {
2712
+ return argv.slice(1).find((one) => !one.startsWith("-"));
2713
+ }
2714
+ function flagsIn(argv) {
2715
+ const found = /* @__PURE__ */ new Set();
2716
+ for (const argument of argv.slice(1)) {
2717
+ if (argument === "--") break;
2718
+ if (argument === "-" || !argument.startsWith("-")) continue;
2719
+ const name = argument.split("=")[0] ?? argument;
2720
+ found.add(name);
2721
+ if (!name.startsWith("--")) {
2722
+ for (const letter of name.slice(1)) found.add(`-${letter}`);
2723
+ }
2724
+ }
2725
+ return found;
2726
+ }
2727
+ function anyFlag(argv, flags) {
2728
+ if (!flags) return void 0;
2729
+ for (const flag of flagsIn(argv)) {
2730
+ if (flags.has(flag)) return flag;
2731
+ }
2732
+ return void 0;
2733
+ }
2734
+ function keysFor(argv) {
2735
+ const program = programOf(argv);
2736
+ const sub = subcommandOf(argv);
2737
+ return sub ? [program, `${program} ${sub}`] : [program];
2738
+ }
2739
+ function remoteFlag(argv) {
2740
+ return anyFlag(argv, REMOTE_FLAGS.get(programOf(argv)));
2741
+ }
2742
+ function endlessFlag(argv) {
2743
+ for (const key of keysFor(argv)) {
2744
+ const found = anyFlag(argv, ENDLESS_FLAGS.get(key));
2745
+ if (found) return found;
2746
+ }
2747
+ return void 0;
2748
+ }
2749
+ function flagClass(argv) {
2750
+ for (const key of keysFor(argv)) {
2751
+ const table2 = FLAG_CHANGES_CLASS.get(key);
2752
+ if (!table2) continue;
2753
+ for (const flag of flagsIn(argv)) {
2754
+ const found = table2.get(flag);
2755
+ if (found) return found;
2756
+ }
2757
+ }
2758
+ return void 0;
2759
+ }
2760
+ function runsAnotherProgram(argv) {
2761
+ if (programOf(argv) !== "env") return false;
2762
+ return argv.slice(1).some((one) => !one.startsWith("-") && !one.includes("="));
2763
+ }
2286
2764
  function classify(argv, policy) {
2287
2765
  const program = programOf(argv);
2766
+ if (remoteFlag(argv)) return "network";
2767
+ const imposed = flagClass(argv);
2768
+ if (imposed === "interpreter" || runsAnotherProgram(argv)) return "interpreter";
2769
+ const known = CLASSES.get(program);
2770
+ if (known === "network" || known === "interpreter") return known;
2288
2771
  if (policy.allow.includes(program)) return "read";
2289
2772
  const reads = SUBCOMMAND_READS.get(program);
2290
2773
  if (reads) {
2291
- const sub = argv.slice(1).find((one) => !one.startsWith("-"));
2774
+ const sub = subcommandOf(argv);
2292
2775
  return sub && reads.has(sub) ? "read" : "write";
2293
2776
  }
2294
- return CLASSES.get(program) ?? "unknown";
2777
+ return imposed ?? known ?? "unknown";
2295
2778
  }
2296
2779
  function judge(argv, policy) {
2297
2780
  const program = programOf(argv);
@@ -2308,21 +2791,32 @@ function judge(argv, policy) {
2308
2791
  }
2309
2792
  const commandClass = classify(argv, policy);
2310
2793
  if (commandClass === "network") {
2794
+ const remote = remoteFlag(argv);
2311
2795
  return {
2312
2796
  commandClass,
2313
2797
  automatic: false,
2314
- refusal: `"${program}" can reach the network. This machine will not run it, and no approval enables it: a command that reads private files and can also send them is the one combination nobody can review by looking at it.`,
2798
+ refusal: (remote ? `"${program} ${remote}" points at another machine, so it can reach the network. ` : `"${program}" can reach the network. `) + "This machine will not run it, and no approval enables it: a command that reads private files and can also send them is the one combination nobody can review by looking at it.",
2315
2799
  reason: "reads private data and has a way out"
2316
2800
  };
2317
2801
  }
2318
2802
  if (commandClass === "interpreter") {
2803
+ const language = CLASSES.get(program) === "interpreter";
2319
2804
  return {
2320
2805
  commandClass,
2321
2806
  automatic: false,
2322
- refusal: `"${program}" runs a language, which is every command at once. Ask for the specific command instead.`,
2807
+ refusal: language ? `"${program}" runs a language, which is every command at once. Ask for the specific command instead.` : `"${describe2(argv)}" runs a program of its own, which is every command at once. Ask for the specific command instead.`,
2323
2808
  reason: "an interpreter is not one command"
2324
2809
  };
2325
2810
  }
2811
+ const endless = endlessFlag(argv);
2812
+ if (endless) {
2813
+ return {
2814
+ commandClass,
2815
+ automatic: false,
2816
+ refusal: `"${program} ${endless}" follows the log and never finishes, and nothing here streams \u2014 the reply is sent when the command exits. Ask for the end of the log instead: \`tail -n 500 <file>\`, \`journalctl -n 500 -u <unit>\`, \`docker logs --tail 500 <container>\`.`,
2817
+ reason: "a follow never produces an answer"
2818
+ };
2819
+ }
2326
2820
  const outside = pathOutsideRoots(argv, policy.roots);
2327
2821
  if (outside) {
2328
2822
  return {
@@ -2346,42 +2840,81 @@ function judge(argv, policy) {
2346
2840
  };
2347
2841
  }
2348
2842
  function pathOutsideRoots(argv, roots) {
2843
+ const realRoots = roots.map((root) => realLocation(resolvePath(root)));
2349
2844
  for (const argument of argv.slice(1)) {
2350
- if (argument.startsWith("-")) continue;
2351
- if (!argument.startsWith("/") && !argument.startsWith("~") && !argument.startsWith(".") && !argument.includes("/")) {
2845
+ const attached = /^-[A-Za-z]([/~.].*)$/.exec(argument)?.[1];
2846
+ const value = argument.startsWith("-") ? argument.includes("=") ? argument.slice(argument.indexOf("=") + 1) : attached ?? "" : argument;
2847
+ if (argument.startsWith("-") && !argument.includes("=") && attached === void 0) continue;
2848
+ if (!value.startsWith("/") && !value.startsWith("~") && !value.startsWith(".") && !value.includes("/")) {
2352
2849
  continue;
2353
2850
  }
2354
- const resolved = expand(argument, roots[0] ?? process.cwd());
2355
- if (!roots.some((root) => resolved === root || resolved.startsWith(`${root}/`))) {
2851
+ const real = realLocation(expand(value, roots[0] ?? process.cwd()));
2852
+ if (!realRoots.some((root) => contains(root, real))) {
2356
2853
  return argument;
2357
2854
  }
2358
2855
  }
2359
2856
  return void 0;
2360
2857
  }
2858
+ function contains(root, path) {
2859
+ const rel = relative2(root, path);
2860
+ return rel === "" || !rel.startsWith("..") && !isAbsolute2(rel);
2861
+ }
2361
2862
  function expand(argument, base) {
2362
2863
  const home = process.env["HOME"] ?? "";
2363
2864
  const withHome = argument.startsWith("~") ? join4(home, argument.slice(1)) : argument;
2364
2865
  return isAbsolute2(withHome) ? resolvePath(withHome) : resolvePath(base, withHome);
2365
2866
  }
2366
- async function runCommand(argv, policy) {
2867
+ function refusal(text) {
2868
+ return { ok: false, text, bytes: 0 };
2869
+ }
2870
+ async function runCommand(argv, policy, limits = {}) {
2871
+ const maxOutputBytes = limits.maxOutputBytes ?? MAX_OUTPUT_BYTES;
2872
+ const timeoutMs = limits.timeoutMs ?? TIMEOUT_MS;
2367
2873
  const verdict = judge(argv, policy);
2368
- if (verdict.refusal) return { ok: false, text: verdict.refusal };
2874
+ if (verdict.refusal) return refusal(verdict.refusal);
2369
2875
  if (policy.mode === "plan") {
2370
- return { ok: false, text: `Plan mode: this machine did not run \`${describe2(argv)}\`.` };
2876
+ return refusal(`Plan mode: this machine did not run \`${describe2(argv)}\`.`);
2371
2877
  }
2372
2878
  const cwd = policy.roots[0];
2373
- if (!cwd) return { ok: false, text: "This machine has no folders it may read." };
2879
+ if (!cwd) return refusal("This machine has no folders it may read.");
2374
2880
  try {
2375
- if (!statSync2(cwd).isDirectory()) return { ok: false, text: `${cwd} is not a folder.` };
2881
+ if (!statSync2(cwd).isDirectory()) return refusal(`${cwd} is not a folder.`);
2376
2882
  } catch {
2377
- return { ok: false, text: `${cwd} does not exist.` };
2883
+ return refusal(`${cwd} does not exist.`);
2378
2884
  }
2379
- return new Promise((resolve9) => {
2885
+ return new Promise((resolve8) => {
2380
2886
  const child = spawn2(argv[0], argv.slice(1), {
2381
2887
  cwd,
2382
2888
  // NO shell. With one, every character a model can produce is a character
2383
2889
  // the shell can act on, and the argument list stops meaning anything.
2384
2890
  shell: false,
2891
+ /*
2892
+ ITS OWN PROCESS GROUP, so the timeout can kill everything it started.
2893
+
2894
+ A signal to one pid stops one process. `zcat` is a shell wrapper around
2895
+ `gzip` on most systems, a script spawns what it likes, and killing the
2896
+ parent leaves the child running and holding the stdout pipe — which is
2897
+ what `close` waits for, so the promise below waited too, and the loop
2898
+ with it. A negative pid signals the group.
2899
+
2900
+ The cost is that this child no longer sees the Ctrl-C that stops the
2901
+ agent, which is the right way round: the loop finishes the request it
2902
+ is holding, and a half-killed command is not a better answer.
2903
+
2904
+ Not on Windows, which has no process groups to signal; there the
2905
+ backstop below is the whole guarantee.
2906
+ */
2907
+ detached: process.platform !== "win32",
2908
+ /*
2909
+ NO STDIN, which is a bound as much as the timeout is.
2910
+
2911
+ The default is a pipe nobody ever writes to, so anything that reads
2912
+ standard input — `cat` with no file, `grep` with a pattern and no path,
2913
+ a program that stops to ask something — waited for the full twenty
2914
+ seconds and came back empty, indistinguishable from a hang. There is
2915
+ nobody at a keyboard here. Closed, so those read EOF and exit at once.
2916
+ */
2917
+ stdio: ["ignore", "pipe", "pipe"],
2385
2918
  /*
2386
2919
  A bare environment.
2387
2920
 
@@ -2395,126 +2928,87 @@ async function runCommand(argv, policy) {
2395
2928
  LANG: process.env["LANG"] ?? "C"
2396
2929
  }
2397
2930
  });
2398
- let output = "";
2399
- let truncated = false;
2931
+ const started = Date.now();
2932
+ const chunks = [];
2933
+ let bytes = 0;
2934
+ let lastOutput;
2935
+ let stopped;
2936
+ let settled = false;
2937
+ let backstop;
2938
+ const kill = () => {
2939
+ try {
2940
+ if (child.pid !== void 0 && process.platform !== "win32") {
2941
+ process.kill(-child.pid, "SIGKILL");
2942
+ } else {
2943
+ child.kill("SIGKILL");
2944
+ }
2945
+ } catch {
2946
+ }
2947
+ };
2948
+ const finished = (code) => {
2949
+ const elapsed = (Date.now() - started) / 1e3;
2950
+ const notes = [];
2951
+ if (stopped === "output") {
2952
+ notes.push(
2953
+ `[CUT OFF. This is the first ${bytes} bytes and this machine stopped the command there \u2014 there was more, and it is not below. For a log, ask for the end of it instead: \`tail -n 500 <file>\`, \`journalctl -n 500 -u <unit>\`.]`
2954
+ );
2955
+ }
2956
+ if (stopped === "time") {
2957
+ notes.push(
2958
+ `[STOPPED after ${elapsed.toFixed(1)}s. ` + (lastOutput === void 0 ? "It had produced nothing at all in that time" : `It had produced ${bytes} bytes, the last of them ${((lastOutput - started) / 1e3).toFixed(1)}s in`) + ", so this is a fragment of the answer rather than the answer.]"
2959
+ );
2960
+ }
2961
+ if (code !== null && code !== 0) notes.push(`[exit code ${code}]`);
2962
+ const head = notes.length > 0 ? `${notes.join("\n")}
2963
+
2964
+ ` : "";
2965
+ const body = bytes === 0 ? "(no output)\n" : Buffer.concat(chunks).toString("utf8");
2966
+ return {
2967
+ // See `ok` on CommandOutcome: a non-zero exit with something to say is
2968
+ // an answer, and a silent failure is a sentence.
2969
+ ok: bytes > 0 || code === 0,
2970
+ text: `$ ${describe2(argv)}
2971
+
2972
+ ${head}${body}`,
2973
+ bytes,
2974
+ ...code !== null ? { exitCode: code } : {},
2975
+ ...stopped ? { stopped } : {}
2976
+ };
2977
+ };
2978
+ const settle = (outcome) => {
2979
+ if (settled) return;
2980
+ settled = true;
2981
+ clearTimeout(timer);
2982
+ if (backstop) clearTimeout(backstop);
2983
+ resolve8(outcome);
2984
+ };
2985
+ const stop = (why) => {
2986
+ if (stopped) return;
2987
+ stopped = why;
2988
+ kill();
2989
+ backstop = setTimeout(() => settle(finished(null)), AFTER_KILL_MS);
2990
+ };
2400
2991
  const collect = (chunk) => {
2401
- if (truncated) return;
2402
- output += chunk.toString("utf8");
2403
- if (output.length > MAX_OUTPUT_BYTES) {
2404
- output = output.slice(0, MAX_OUTPUT_BYTES);
2405
- truncated = true;
2406
- child.kill("SIGKILL");
2992
+ if (stopped) return;
2993
+ lastOutput = Date.now();
2994
+ const room = maxOutputBytes - bytes;
2995
+ if (chunk.length > room) {
2996
+ chunks.push(chunk.subarray(0, room));
2997
+ bytes += room;
2998
+ stop("output");
2999
+ return;
2407
3000
  }
3001
+ chunks.push(chunk);
3002
+ bytes += chunk.length;
2408
3003
  };
2409
3004
  child.stdout.on("data", collect);
2410
3005
  child.stderr.on("data", collect);
2411
- const timer = setTimeout(() => child.kill("SIGKILL"), TIMEOUT_MS);
2412
- child.on("error", (error) => {
2413
- clearTimeout(timer);
2414
- resolve9({ ok: false, text: `Could not run it: ${error.message}` });
2415
- });
2416
- child.on("close", (code) => {
2417
- clearTimeout(timer);
2418
- const notes = [
2419
- truncated ? `
2420
-
2421
- [output cut off at ${MAX_OUTPUT_BYTES} bytes]` : "",
2422
- code !== 0 && code !== null ? `
2423
-
2424
- [exit code ${code}]` : ""
2425
- ].join("");
2426
- resolve9({ ok: code === 0, text: `$ ${describe2(argv)}
2427
-
2428
- ${output}${notes}` });
2429
- });
3006
+ const timer = setTimeout(() => stop("time"), timeoutMs);
3007
+ child.on("error", (error) => settle(refusal(`Could not run it: ${error.message}`)));
3008
+ child.on("close", (code) => settle(finished(code)));
2430
3009
  });
2431
3010
  }
2432
3011
 
2433
- // src/commands/agent-config.ts
2434
- import { existsSync as existsSync5, readFileSync as readFileSync5, readdirSync, statSync as statSync3 } from "node:fs";
2435
- import { homedir as homedir2 } from "node:os";
2436
- import { join as join5, resolve as resolve3 } from "node:path";
2437
- var NOT_MATERIAL = /* @__PURE__ */ new Set([
2438
- "Library",
2439
- "Applications",
2440
- "System",
2441
- "Public",
2442
- "node_modules",
2443
- "go",
2444
- "Parallels",
2445
- "VirtualBox VMs"
2446
- ]);
2447
- function discoverRoots(home = homedir2(), list = (path) => {
2448
- try {
2449
- return readdirSync(path);
2450
- } catch {
2451
- return [];
2452
- }
2453
- }, isDirectory = (path) => {
2454
- try {
2455
- return statSync3(path).isDirectory();
2456
- } catch {
2457
- return false;
2458
- }
2459
- }) {
2460
- return list(home).filter((name) => !name.startsWith(".")).filter((name) => !NOT_MATERIAL.has(name)).map((name) => join5(home, name)).filter(isDirectory).sort();
2461
- }
2462
- function expandHome(path, home = homedir2()) {
2463
- return path.startsWith("~") ? resolve3(home, path.slice(1).replace(/^[/\\]/, "")) : resolve3(path);
2464
- }
2465
- var MODES = /* @__PURE__ */ new Set(["ask", "auto-read", "plan"]);
2466
- function readAgentConfig(path, read2 = (at) => readFileSync5(at, "utf8"), exists = (at) => existsSync5(at), home = homedir2()) {
2467
- if (!exists(path)) return {};
2468
- let parsed;
2469
- try {
2470
- parsed = JSON.parse(read2(path));
2471
- } catch (error) {
2472
- return { error: `${path} is not valid JSON: ${error instanceof Error ? error.message : ""}` };
2473
- }
2474
- if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
2475
- return { error: `${path} should hold an object, like {"roots": ["~/code"]}.` };
2476
- }
2477
- const held = parsed;
2478
- const config = {};
2479
- if (held["roots"] !== void 0) {
2480
- if (!Array.isArray(held["roots"]) || held["roots"].some((one) => typeof one !== "string")) {
2481
- return { error: `${path}: "roots" should be a list of folders.` };
2482
- }
2483
- config.roots = held["roots"].map((one) => expandHome(one, home));
2484
- }
2485
- if (held["mode"] !== void 0) {
2486
- if (typeof held["mode"] !== "string" || !MODES.has(held["mode"])) {
2487
- return { error: `${path}: "mode" should be "ask", "auto-read" or "plan".` };
2488
- }
2489
- config.mode = held["mode"];
2490
- }
2491
- for (const key of ["allow", "deny"]) {
2492
- if (held[key] === void 0) continue;
2493
- if (!Array.isArray(held[key]) || held[key].some((one) => typeof one !== "string")) {
2494
- return { error: `${path}: "${key}" should be a list of program names.` };
2495
- }
2496
- config[key] = held[key];
2497
- }
2498
- return { config };
2499
- }
2500
- function resolveAgentConfig(args) {
2501
- const roots = args.flagRoots && args.flagRoots.length > 0 ? args.flagRoots : args.stored?.roots && args.stored.roots.length > 0 ? args.stored.roots : args.discovered ?? [];
2502
- return {
2503
- roots,
2504
- /*
2505
- `ask` unless the owner said otherwise.
2506
-
2507
- The mode decides how much happens with nobody watching, so the default
2508
- is the one that does least. `auto-read` is a reasonable thing to choose
2509
- and an unreasonable thing to be given.
2510
- */
2511
- mode: args.stored?.mode ?? "ask",
2512
- allow: args.stored?.allow ?? [],
2513
- deny: args.stored?.deny ?? [],
2514
- source: args.flagRoots && args.flagRoots.length > 0 ? "flags" : args.stored?.roots && args.stored.roots.length > 0 ? "config" : "discovered"
2515
- };
2516
- }
2517
-
2518
3012
  // src/commands/agent.ts
2519
3013
  async function said(response, fallback) {
2520
3014
  const body = await response.json().catch(() => void 0);
@@ -2522,7 +3016,7 @@ async function said(response, fallback) {
2522
3016
  return message2 ?? `${fallback} (${response.status})`;
2523
3017
  }
2524
3018
  function locate(roots, requested) {
2525
- const expanded = requested.startsWith("~") ? resolve4(homedir3(), requested.slice(1).replace(/^[/\\]/, "")) : requested;
3019
+ const expanded = requested.startsWith("~") ? resolve3(homedir2(), requested.slice(1).replace(/^[/\\]/, "")) : requested;
2526
3020
  if (!expanded.includes("/") && !expanded.includes("\\")) {
2527
3021
  const wanted2 = expanded.trim().toLowerCase();
2528
3022
  const named = roots.find((root) => basename(root).toLowerCase() === wanted2);
@@ -2538,29 +3032,23 @@ function locate(roots, requested) {
2538
3032
  throw new Error(`${requested} is not inside any allowed folder (${roots.join(", ")})`);
2539
3033
  }
2540
3034
  function uncontested(target) {
2541
- if (!existsSync6(target)) return target;
3035
+ if (!existsSync5(target)) return target;
2542
3036
  const dot = target.lastIndexOf(".");
2543
3037
  const stem = dot > target.lastIndexOf("/") && dot !== -1 ? target.slice(0, dot) : target;
2544
3038
  const extension = stem === target ? "" : target.slice(dot);
2545
3039
  for (let n = 1; n < 1e3; n += 1) {
2546
3040
  const candidate = `${stem} (${n})${extension}`;
2547
- if (!existsSync6(candidate)) return candidate;
3041
+ if (!existsSync5(candidate)) return candidate;
2548
3042
  }
2549
3043
  throw new Error(`${target} and a thousand names beside it are taken.`);
2550
3044
  }
2551
- async function answer(context, apiUrl, token, settings, request) {
2552
- const roots = settings.roots;
3045
+ async function answer(context, apiUrl, token, roots, request) {
2553
3046
  let located;
2554
3047
  if (request.kind === "run_command") {
2555
3048
  if (!request.argv || request.argv.length === 0) {
2556
3049
  return { ok: false, error: "No command was given." };
2557
3050
  }
2558
- const policy = {
2559
- mode: settings.mode,
2560
- allow: settings.allow,
2561
- deny: settings.deny,
2562
- roots
2563
- };
3051
+ const policy = { mode: "ask", allow: [], deny: [], roots };
2564
3052
  const outcome = await runCommand(request.argv, policy);
2565
3053
  if (!outcome.ok) return { ok: false, error: outcome.text };
2566
3054
  return upload(
@@ -2590,10 +3078,10 @@ async function answer(context, apiUrl, token, settings, request) {
2590
3078
  }
2591
3079
  try {
2592
3080
  let target = located;
2593
- if (existsSync6(located) && statSync4(located).isDirectory()) {
3081
+ if (existsSync5(located) && statSync3(located).isDirectory()) {
2594
3082
  const disposition = fetched.headers.get("content-disposition") ?? "";
2595
3083
  const named = /filename="([^"]+)"/.exec(disposition)?.[1];
2596
- target = join6(located, basename(named ?? "file"));
3084
+ target = join5(located, basename(named ?? "file"));
2597
3085
  }
2598
3086
  target = uncontested(target);
2599
3087
  writeFileSync4(target, downloaded, { flag: "wx" });
@@ -2616,21 +3104,9 @@ ${downloaded.length} bytes
2616
3104
  let filename = request.path.split("/").pop() ?? "file";
2617
3105
  if (request.kind === "list_dir") {
2618
3106
  try {
2619
- const stats = statSync4(located);
3107
+ const stats = statSync3(located);
2620
3108
  if (!stats.isDirectory()) return { ok: false, error: `${request.path} is not a folder.` };
2621
- const entries = readdirSync2(located, { withFileTypes: true }).filter((entry) => !entry.name.startsWith(".")).slice(0, MAX_LISTED).map((entry) => {
2622
- if (entry.isDirectory()) return `${entry.name}/`;
2623
- try {
2624
- return `${entry.name} ${sizeOf(join6(located, entry.name))}`;
2625
- } catch {
2626
- return entry.name;
2627
- }
2628
- }).sort();
2629
- const listing = entries.length > 0 ? entries.join("\n") : "(empty)";
2630
- bytes = Buffer.from(`${request.path}
2631
-
2632
- ${listing}
2633
- `, "utf8");
3109
+ bytes = Buffer.from(folderListing(request.path, located), "utf8");
2634
3110
  filename = `${request.path.split("/").filter(Boolean).pop() ?? "listing"}.txt`;
2635
3111
  const grant = await upload(apiUrl, token, filename, bytes);
2636
3112
  return grant;
@@ -2639,9 +3115,9 @@ ${listing}
2639
3115
  }
2640
3116
  }
2641
3117
  try {
2642
- const stats = statSync4(located);
3118
+ const stats = statSync3(located);
2643
3119
  if (!stats.isFile()) return { ok: false, error: `${request.path} is not a file.` };
2644
- bytes = readFileSync6(located);
3120
+ bytes = readFileSync5(located);
2645
3121
  } catch (error) {
2646
3122
  return { ok: false, error: error instanceof Error ? error.message : "could not read it" };
2647
3123
  }
@@ -2682,8 +3158,27 @@ async function upload(apiUrl, token, filename, bytes) {
2682
3158
  return { ok: true, attachToken: stored.attachToken, bytes: bytes.length };
2683
3159
  }
2684
3160
  var MAX_LISTED = 200;
3161
+ function folderListing(requested, located) {
3162
+ const all = readdirSync(located, { withFileTypes: true }).filter((entry) => !entry.name.startsWith(".")).sort((a, b) => a.name.localeCompare(b.name));
3163
+ const shown = all.slice(0, MAX_LISTED).map((entry) => {
3164
+ if (entry.isDirectory()) return `${entry.name}/`;
3165
+ try {
3166
+ return `${entry.name} ${sizeOf(join5(located, entry.name))}`;
3167
+ } catch {
3168
+ return entry.name;
3169
+ }
3170
+ });
3171
+ const listing = shown.length > 0 ? shown.join("\n") : "(empty)";
3172
+ const rest = all.length > shown.length ? `
3173
+
3174
+ [\u2026${all.length - shown.length} more entries not listed: this is the first ${MAX_LISTED} by name, not the whole folder]` : "";
3175
+ return `${requested}
3176
+
3177
+ ${listing}${rest}
3178
+ `;
3179
+ }
2685
3180
  function sizeOf(path) {
2686
- const size = statSync4(path).size;
3181
+ const size = statSync3(path).size;
2687
3182
  if (size < 1024) return `${size} B`;
2688
3183
  if (size < 1024 * 1024) return `${Math.round(size / 1024)} KB`;
2689
3184
  return `${(size / (1024 * 1024)).toFixed(1)} MB`;
@@ -2695,41 +3190,16 @@ async function agentCommand(context) {
2695
3190
  return 1;
2696
3191
  }
2697
3192
  const roots = (listFlag(context.args, "root") ?? []).map(
2698
- (one) => resolve4(one.startsWith("~") ? resolve4(homedir3(), one.slice(1).replace(/^[/\\]/, "")) : one)
3193
+ (one) => resolve3(one.startsWith("~") ? resolve3(homedir2(), one.slice(1).replace(/^[/\\]/, "")) : one)
2699
3194
  );
2700
- const configPath = join6(context.paths.dir, "agent.json");
2701
- const stored = readAgentConfig(configPath);
2702
- if (stored.error) {
2703
- context.error(stored.error);
2704
- return 1;
2705
- }
2706
- const settings = resolveAgentConfig({
2707
- flagRoots: roots,
2708
- ...stored.config ? { stored: stored.config } : {},
2709
- discovered: discoverRoots()
2710
- });
2711
- if (settings.roots.length === 0) {
2712
- context.error(
2713
- "There is nothing in your home directory this machine would offer, so say which folders it may read from:"
2714
- );
2715
- context.error("");
2716
- context.error(" pm agent --root ~/projects");
2717
- context.error("");
2718
- context.error(`Or write them once in ${configPath}:`);
2719
- context.error("");
2720
- context.error(' { "roots": ["~/projects"], "mode": "ask" }');
2721
- return 1;
2722
- }
2723
- roots.length = 0;
2724
- roots.push(...settings.roots);
2725
- const shown = settings.roots.map((path) => path.replace(homedir3(), "~"));
2726
- const where = settings.source === "flags" ? "" : settings.source === "config" ? ` (from ${configPath})` : " (found in your home directory)";
3195
+ const everywhere = roots.length === 0;
3196
+ if (everywhere) roots.push("/");
2727
3197
  context.print(
2728
- `Reading ${shown.join(", ")} \u2014 nothing outside them${where}. Mode: ${settings.mode}.`
3198
+ everywhere ? "Reading anywhere on this machine. You approve every request first, and see the exact path or command before you do. Narrow it with --root if you want to." : `Reading ${roots.map((path) => path.replace(homedir2(), "~")).join(", ")} \u2014 nothing outside them.`
2729
3199
  );
2730
3200
  for (const root of roots) {
2731
3201
  try {
2732
- if (!statSync4(root).isDirectory()) {
3202
+ if (!statSync3(root).isDirectory()) {
2733
3203
  context.error(`${root} is not a folder.`);
2734
3204
  return 1;
2735
3205
  }
@@ -2802,8 +3272,10 @@ async function agentCommand(context) {
2802
3272
  } else {
2803
3273
  const { items } = await claimed.json();
2804
3274
  for (const request of items) {
2805
- context.print(`Reading ${request.path}`);
2806
- const outcome = await answer(context, apiUrl, await authorization(), settings, request);
3275
+ context.print(
3276
+ request.kind === "run_command" ? `Running ${request.path}` : `Reading ${request.path}`
3277
+ );
3278
+ const outcome = await answer(context, apiUrl, await authorization(), roots, request);
2807
3279
  const done = await call(
2808
3280
  `complete/${encodeURIComponent(request.id)}`,
2809
3281
  outcome.ok ? { result: { attachToken: outcome.attachToken } } : { error: outcome.error }
@@ -2825,8 +3297,8 @@ async function agentCommand(context) {
2825
3297
 
2826
3298
  // src/commands/google.ts
2827
3299
  import { writeFileSync as writeFileSync5 } from "node:fs";
2828
- import { basename as basename2, resolve as resolve5 } from "node:path";
2829
- import { readFileSync as readFileSync7 } from "node:fs";
3300
+ import { basename as basename2, resolve as resolve4 } from "node:path";
3301
+ import { readFileSync as readFileSync6 } from "node:fs";
2830
3302
  async function callApi(context, path, init = {}) {
2831
3303
  const credential = context.resolved.credential;
2832
3304
  if (!credential) {
@@ -2891,7 +3363,7 @@ async function driveGet(context, fileId) {
2891
3363
  const disposition = response.headers.get("content-disposition") ?? "";
2892
3364
  const named = /filename="([^"]+)"/.exec(disposition)?.[1];
2893
3365
  const out = stringFlag(context.args, "out");
2894
- const target = resolve5(out ?? basename2(named ?? fileId));
3366
+ const target = resolve4(out ?? basename2(named ?? fileId));
2895
3367
  writeFileSync5(target, Buffer.from(await response.arrayBuffer()));
2896
3368
  context.print(target);
2897
3369
  return 0;
@@ -2903,7 +3375,7 @@ async function drivePut(context, path) {
2903
3375
  }
2904
3376
  let bytes;
2905
3377
  try {
2906
- bytes = readFileSync7(resolve5(path));
3378
+ bytes = readFileSync6(resolve4(path));
2907
3379
  } catch {
2908
3380
  context.error(`Cannot read ${path}.`);
2909
3381
  return 1;
@@ -2987,8 +3459,8 @@ async function mailCommand(context) {
2987
3459
  }
2988
3460
 
2989
3461
  // src/commands/requests.ts
2990
- import { existsSync as existsSync7, writeFileSync as writeFileSync6 } from "node:fs";
2991
- import { resolve as resolve6 } from "node:path";
3462
+ import { existsSync as existsSync6, writeFileSync as writeFileSync6 } from "node:fs";
3463
+ import { basename as basename3, resolve as resolve5 } from "node:path";
2992
3464
  async function requestsCommand(context) {
2993
3465
  if (context.args.words[1] === "get") return collectCommand(context);
2994
3466
  const credential = context.resolved.credential;
@@ -3055,9 +3527,8 @@ async function collectCommand(context) {
3055
3527
  context.error(`The download refused it (${file.status}). Links expire in minutes \u2014 try again.`);
3056
3528
  return 1;
3057
3529
  }
3058
- const name = stringFlag(context.args, "output", "o") ?? filename;
3059
- const target = resolve6(name);
3060
- if (existsSync7(target)) {
3530
+ const target = downloadTarget(filename, stringFlag(context.args, "output", "o"));
3531
+ if (existsSync6(target)) {
3061
3532
  context.error(`${target} already exists. Pass --output to write somewhere else.`);
3062
3533
  return 1;
3063
3534
  }
@@ -3065,16 +3536,19 @@ async function collectCommand(context) {
3065
3536
  context.print(`Wrote ${target}`);
3066
3537
  return 0;
3067
3538
  }
3539
+ function downloadTarget(filename, output) {
3540
+ return resolve5(output ?? (basename3(filename) || "file"));
3541
+ }
3068
3542
 
3069
3543
  // src/workspace.ts
3070
- import { existsSync as existsSync8, readFileSync as readFileSync8, writeFileSync as writeFileSync7 } from "node:fs";
3071
- import { dirname as dirname3, join as join7, resolve as resolvePath2 } from "node:path";
3544
+ import { existsSync as existsSync7, readFileSync as readFileSync7, writeFileSync as writeFileSync7 } from "node:fs";
3545
+ import { dirname as dirname3, join as join6, resolve as resolvePath2 } from "node:path";
3072
3546
  var WORKSPACE_FILE = ".persistmemory.json";
3073
3547
  function findWorkspace(from = process.cwd()) {
3074
3548
  let dir = resolvePath2(from);
3075
3549
  for (; ; ) {
3076
- const file = join7(dir, WORKSPACE_FILE);
3077
- if (existsSync8(file)) {
3550
+ const file = join6(dir, WORKSPACE_FILE);
3551
+ if (existsSync7(file)) {
3078
3552
  const config = readWorkspace(file);
3079
3553
  if (config) return { file, dir, config };
3080
3554
  }
@@ -3085,7 +3559,7 @@ function findWorkspace(from = process.cwd()) {
3085
3559
  }
3086
3560
  function readWorkspace(file) {
3087
3561
  try {
3088
- const parsed = JSON.parse(readFileSync8(file, "utf8"));
3562
+ const parsed = JSON.parse(readFileSync7(file, "utf8"));
3089
3563
  const space = parsed.space;
3090
3564
  if (space && typeof space === "object" && typeof space.id === "string" && space.id !== "" && typeof space.name === "string") {
3091
3565
  return { space: { id: space.id, name: space.name } };
@@ -3096,7 +3570,7 @@ function readWorkspace(file) {
3096
3570
  }
3097
3571
  }
3098
3572
  function writeWorkspace(dir, config) {
3099
- const file = join7(dir, WORKSPACE_FILE);
3573
+ const file = join6(dir, WORKSPACE_FILE);
3100
3574
  writeFileSync7(file, `${JSON.stringify(config, null, 2)}
3101
3575
  `, "utf8");
3102
3576
  return file;
@@ -3366,9 +3840,9 @@ function message(error) {
3366
3840
  }
3367
3841
 
3368
3842
  // src/commands/maintain.ts
3369
- import { existsSync as existsSync9, rmSync } from "node:fs";
3843
+ import { existsSync as existsSync8, rmSync } from "node:fs";
3370
3844
  import { spawnSync } from "node:child_process";
3371
- import { dirname as dirname4, resolve as resolve8 } from "node:path";
3845
+ import { dirname as dirname4, resolve as resolve7 } from "node:path";
3372
3846
  import { fileURLToPath } from "node:url";
3373
3847
  function installArgs(pkg = PACKAGE) {
3374
3848
  return ["install", "-g", "--prefer-online", `${pkg}@latest`];
@@ -3432,7 +3906,7 @@ Delete the file it runs from: ${processPath()}`
3432
3906
  return 0;
3433
3907
  }
3434
3908
  async function deleteCommand(context) {
3435
- if (!existsSync9(context.paths.dir)) {
3909
+ if (!existsSync8(context.paths.dir)) {
3436
3910
  context.print(`Nothing to delete: ${context.paths.dir} does not exist.`);
3437
3911
  return 0;
3438
3912
  }
@@ -3461,7 +3935,7 @@ async function deleteCommand(context) {
3461
3935
  return 0;
3462
3936
  }
3463
3937
  function removeEverything(context) {
3464
- const dir = resolve8(context.paths.dir);
3938
+ const dir = resolve7(context.paths.dir);
3465
3939
  if (dir === "/" || dir.split("/").filter(Boolean).length < 2) {
3466
3940
  context.error(`Refusing to delete ${dir}: that does not look like a data directory.`);
3467
3941
  return;
@@ -3473,7 +3947,7 @@ function installer() {
3473
3947
  }
3474
3948
  function processPath() {
3475
3949
  try {
3476
- return resolve8(dirname4(fileURLToPath(import.meta.url)));
3950
+ return resolve7(dirname4(fileURLToPath(import.meta.url)));
3477
3951
  } catch {
3478
3952
  return process.argv[1] ?? "";
3479
3953
  }
@@ -3482,15 +3956,15 @@ function processPath() {
3482
3956
  // src/commands/session.ts
3483
3957
  import { createInterface as createInterface2 } from "node:readline";
3484
3958
  import { randomUUID } from "node:crypto";
3485
- import { relative as relative2 } from "node:path";
3959
+ import { relative as relative3 } from "node:path";
3486
3960
 
3487
3961
  // src/events.ts
3488
- import { appendFileSync, existsSync as existsSync10, mkdirSync as mkdirSync3, readFileSync as readFileSync9 } from "node:fs";
3489
- import { join as join8 } from "node:path";
3962
+ import { appendFileSync, existsSync as existsSync9, mkdirSync as mkdirSync3, readFileSync as readFileSync8 } from "node:fs";
3963
+ import { join as join7 } from "node:path";
3490
3964
  function openSessionLog(paths, id) {
3491
- const directory = join8(paths.dir, "sessions");
3965
+ const directory = join7(paths.dir, "sessions");
3492
3966
  mkdirSync3(directory, { recursive: true, mode: 448 });
3493
- const path = join8(directory, `${id}.jsonl`);
3967
+ const path = join7(directory, `${id}.jsonl`);
3494
3968
  return {
3495
3969
  id,
3496
3970
  path,
@@ -3502,8 +3976,8 @@ function openSessionLog(paths, id) {
3502
3976
  }
3503
3977
  },
3504
3978
  read() {
3505
- if (!existsSync10(path)) return [];
3506
- return readFileSync9(path, "utf8").split("\n").filter((line) => line.trim() !== "").flatMap((line) => {
3979
+ if (!existsSync9(path)) return [];
3980
+ return readFileSync8(path, "utf8").split("\n").filter((line) => line.trim() !== "").flatMap((line) => {
3507
3981
  try {
3508
3982
  return [JSON.parse(line)];
3509
3983
  } catch {
@@ -3580,9 +4054,9 @@ async function sessionCommand(context) {
3580
4054
  context.print(` Ask anything. /help for commands, /exit to leave.
3581
4055
  `);
3582
4056
  const readline = createInterface2({ input: process.stdin, output: process.stdout });
3583
- const ask = (prompt) => new Promise((resolve9) => {
3584
- readline.question(prompt, resolve9);
3585
- readline.once("close", () => resolve9(void 0));
4057
+ const ask = (prompt) => new Promise((resolve8) => {
4058
+ readline.question(prompt, resolve8);
4059
+ readline.once("close", () => resolve8(void 0));
3586
4060
  });
3587
4061
  const root = process.cwd();
3588
4062
  let running = true;
@@ -3654,11 +4128,11 @@ async function handleInput(args) {
3654
4128
  path: file.path,
3655
4129
  bytes: file.bytes
3656
4130
  });
3657
- context.print(` read ${relative2(root, file.path)} (${Math.round(file.bytes / 1024)} KB)`);
4131
+ context.print(` read ${relative3(root, file.path)} (${Math.round(file.bytes / 1024)} KB)`);
3658
4132
  if (command === "capture") {
3659
4133
  const client = await context.client();
3660
4134
  const result = await client.memories.remember(
3661
- { text: file.text, title: relative2(root, file.path) },
4135
+ { text: file.text, title: relative3(root, file.path) },
3662
4136
  { idempotencyKey: `cli:capture:${file.path}:${file.bytes}` }
3663
4137
  );
3664
4138
  log.append({
@@ -3762,11 +4236,189 @@ async function write2(args) {
3762
4236
  return;
3763
4237
  }
3764
4238
  commitWrite(proposed, true);
3765
- context.print(` Wrote ${relative2(root, proposed.path)}.`);
4239
+ context.print(` Wrote ${relative3(root, proposed.path)}.`);
4240
+ }
4241
+
4242
+ // src/commands/sharing.ts
4243
+ var collaboratorColumns = [
4244
+ { header: "email", value: (one) => one.email },
4245
+ { header: "name", value: (one) => one.name ?? "" },
4246
+ { header: "role", value: (one) => one.role },
4247
+ // The column that matters. See `standing`.
4248
+ { header: "access", value: (one) => standing(one) },
4249
+ { header: "invited by", value: (one) => one.invitedBy ?? "" }
4250
+ ];
4251
+ function standing(one) {
4252
+ return one.acceptedAt ? `yes, since ${one.acceptedAt.slice(0, 10)}` : "no \u2014 invitation not accepted";
4253
+ }
4254
+ function sharingArgs(context) {
4255
+ const words = context.args.words.slice(2);
4256
+ const at = words.findIndex((one) => one.includes("@"));
4257
+ const email = at >= 0 ? words[at] ?? "" : "";
4258
+ const named = at > 0 ? words.slice(0, at).join(" ") : "";
4259
+ const trailing = at >= 0 ? words[at + 1] : void 0;
4260
+ return { named, email, role: stringFlag(context.args, "role") ?? trailing };
4261
+ }
4262
+ var ROLES = ["viewer", "editor"];
4263
+ function isRole(value) {
4264
+ return value !== void 0 && ROLES.includes(value);
4265
+ }
4266
+ async function resolveSpace(context, named) {
4267
+ const client = await context.client();
4268
+ const { data } = await client.spaces.list({ limit: 200 }).first();
4269
+ if (named.startsWith("space_")) {
4270
+ const byId = data.find((one) => one.id === named);
4271
+ if (byId) return byId;
4272
+ context.error(`No Space with id ${named}. Run \`pm spaces list\` to see yours.`);
4273
+ return 1;
4274
+ }
4275
+ const wanted2 = named.trim().toLowerCase();
4276
+ const matches = data.filter((one) => one.name.trim().toLowerCase() === wanted2);
4277
+ const only = matches[0];
4278
+ if (matches.length === 1 && only) return only;
4279
+ if (matches.length === 0) {
4280
+ context.error(`No Space called "${named}". Run \`pm spaces list\` to see yours.`);
4281
+ return 1;
4282
+ }
4283
+ context.error(
4284
+ `More than one Space is called "${named}": ${matches.map((one) => one.id).join(", ")}.`
4285
+ );
4286
+ context.error("Name it by id \u2014 sharing the wrong one gives somebody the wrong memories.");
4287
+ return 1;
4288
+ }
4289
+ async function shareSpaceCommand(context) {
4290
+ const { named, email, role } = sharingArgs(context);
4291
+ if (named === "" || email === "") {
4292
+ context.error("Which Space, and who?");
4293
+ context.error("");
4294
+ context.error(' pm spaces share "Work" priya@example.com --role viewer');
4295
+ return 2;
4296
+ }
4297
+ if (!isRole(role)) {
4298
+ context.error("Say what they may do \u2014 there is no default:");
4299
+ context.error("");
4300
+ context.error(` pm spaces share "${named}" ${email} --role viewer read everything in it`);
4301
+ context.error(` pm spaces share "${named}" ${email} --role editor read it, and add to it`);
4302
+ if (role !== void 0) {
4303
+ context.error("");
4304
+ context.error(
4305
+ role.toLowerCase() === "owner" ? "A Space cannot be handed over. Its owner is whoever created it." : `Roles are viewer and editor. "${role}" is neither.`
4306
+ );
4307
+ }
4308
+ return 2;
4309
+ }
4310
+ const space = await resolveSpace(context, named);
4311
+ if (typeof space === "number") return space;
4312
+ const client = await context.client();
4313
+ const invitation = await client.spaces.share(
4314
+ space.id,
4315
+ { email, role },
4316
+ {
4317
+ /*
4318
+ Derived from what is being shared, not random.
4319
+
4320
+ The SDK will not retry a POST without a key, and a random one per
4321
+ attempt would defeat the point: a share that timed out after the
4322
+ server recorded it would send a second invitation to a real person's
4323
+ inbox. Same Space, same address, one invitation.
4324
+ */
4325
+ idempotencyKey: `cli:share:${space.id}:${email}`
4326
+ }
4327
+ );
4328
+ if (context.flags.output !== "table") {
4329
+ context.print(renderOne(invitation, collaboratorColumns, { format: context.flags.output }));
4330
+ return 0;
4331
+ }
4332
+ context.print(`Invited ${invitation.email} to "${space.name}" as ${invitation.role}.`);
4333
+ context.print("");
4334
+ context.print(
4335
+ `They will see everything filed in "${space.name}" \u2014 including memories added later.`
4336
+ );
4337
+ context.print("Nothing is shared yet: they have to accept the invitation first.");
4338
+ if (invitation.role === "owner") {
4339
+ context.print("");
4340
+ context.print("As an owner they can share it onward and revoke anybody, including you.");
4341
+ }
4342
+ context.print("");
4343
+ context.print(`Undo it with: pm spaces unshare "${space.name}" ${invitation.email}`);
4344
+ return 0;
4345
+ }
4346
+ async function unshareSpaceCommand(context) {
4347
+ const { named, email } = sharingArgs(context);
4348
+ if (named === "" || email === "") {
4349
+ context.error("Which Space, and who?");
4350
+ context.error("");
4351
+ context.error(' pm spaces unshare "Work" priya@example.com');
4352
+ return 2;
4353
+ }
4354
+ const space = await resolveSpace(context, named);
4355
+ if (typeof space === "number") return space;
4356
+ const client = await context.client();
4357
+ const { email: ended } = await client.spaces.unshare(space.id, email);
4358
+ if (context.flags.output !== "table") {
4359
+ context.print(JSON.stringify({ spaceId: space.id, email: ended }, void 0, 2));
4360
+ return 0;
4361
+ }
4362
+ context.print(`${ended} can no longer see "${space.name}".`);
4363
+ context.print("Nothing of it was ever copied into their account, so nothing of it remains.");
4364
+ return 0;
4365
+ }
4366
+ async function spaceRoleCommand(context) {
4367
+ const { named, email, role } = sharingArgs(context);
4368
+ if (named === "" || email === "" || !isRole(role)) {
4369
+ context.error("Which Space, who, and what to:");
4370
+ context.error("");
4371
+ context.error(' pm spaces role "Work" priya@example.com editor');
4372
+ context.error("");
4373
+ context.error(`Roles: ${ROLES.join(", ")}. This changes somebody who already has access \u2014`);
4374
+ context.error("use `pm spaces share` to invite a new person.");
4375
+ return 2;
4376
+ }
4377
+ const space = await resolveSpace(context, named);
4378
+ if (typeof space === "number") return space;
4379
+ const client = await context.client();
4380
+ const changed = await client.spaces.setRole(space.id, { email, role });
4381
+ if (context.flags.output !== "table") {
4382
+ context.print(renderOne(changed, collaboratorColumns, { format: context.flags.output }));
4383
+ return 0;
4384
+ }
4385
+ context.print(`${changed.email} is now ${changed.role} on "${space.name}".`);
4386
+ if (changed.role === "owner") {
4387
+ context.print("As an owner they can share it onward and revoke anybody, including you.");
4388
+ }
4389
+ return 0;
4390
+ }
4391
+ async function spaceSharingCommand(context) {
4392
+ const named = context.args.words.slice(2).join(" ").trim();
4393
+ if (named === "") {
4394
+ context.error('Which Space? Try `pm spaces sharing "Work"`.');
4395
+ return 2;
4396
+ }
4397
+ const space = await resolveSpace(context, named);
4398
+ if (typeof space === "number") return space;
4399
+ const client = await context.client();
4400
+ const { data } = await client.spaces.collaborators(space.id, { limit: 200 }).first();
4401
+ if (context.flags.output !== "table") {
4402
+ context.print(render(data, collaboratorColumns, { format: context.flags.output }));
4403
+ return 0;
4404
+ }
4405
+ if (data.length === 0) {
4406
+ context.print(`Nobody else can see "${space.name}". It has never been shared.`);
4407
+ return 0;
4408
+ }
4409
+ context.print(render(data, collaboratorColumns, { format: context.flags.output }));
4410
+ const waiting = data.filter((one) => one.acceptedAt === void 0).length;
4411
+ if (waiting > 0) {
4412
+ context.print("");
4413
+ context.print(
4414
+ `${waiting} ${waiting === 1 ? "invitation has" : "invitations have"} not been accepted. ${waiting === 1 ? "That person can" : "Those people can"} see nothing yet.`
4415
+ );
4416
+ }
4417
+ return 0;
3766
4418
  }
3767
4419
 
3768
4420
  // src/commands/memory.ts
3769
- import { readFileSync as readFileSync10 } from "node:fs";
4421
+ import { readFileSync as readFileSync9 } from "node:fs";
3770
4422
 
3771
4423
  // src/spaces.ts
3772
4424
  async function spacesFor(context, client, env = process.env) {
@@ -3830,7 +4482,7 @@ async function rememberCommand(context) {
3830
4482
  let text;
3831
4483
  if (file) {
3832
4484
  try {
3833
- text = readFileSync10(file, "utf8");
4485
+ text = readFileSync9(file, "utf8");
3834
4486
  } catch {
3835
4487
  context.error(`Could not read ${file}.`);
3836
4488
  return 1;
@@ -4234,8 +4886,14 @@ async function dispatch(context) {
4234
4886
  if (noun === "create" || noun === "new") return createSpaceCommand(context);
4235
4887
  if (noun === "delete" || noun === "remove") return deleteSpaceCommand(context);
4236
4888
  if (noun === "merge") return mergeSpacesCommand(context);
4889
+ if (noun === "share") return shareSpaceCommand(context);
4890
+ if (noun === "unshare") return unshareSpaceCommand(context);
4891
+ if (noun === "role") return spaceRoleCommand(context);
4892
+ if (noun === "sharing") return spaceSharingCommand(context);
4237
4893
  if (noun === void 0 || noun === "list") return listSpacesCommand(context);
4238
- context.error(`Cannot "pm spaces ${noun}". Try list, create, delete or merge.`);
4894
+ context.error(
4895
+ `Cannot "pm spaces ${noun}". Try list, create, delete, merge, share, unshare, role or sharing.`
4896
+ );
4239
4897
  return 2;
4240
4898
  case "list":
4241
4899
  if (noun === "memories" || noun === "memory") return listMemoriesCommand(context);