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