primcli 1.66.0 → 1.68.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/README.md CHANGED
@@ -1052,17 +1052,30 @@ The SessionStart hook runs `primitive agent session-register --runtime claude --
1052
1052
  It gives the session an address once, named `<runtime>-<repository>`, using the
1053
1053
  saved member login, and only re-verifies on resume; a session whose agent was
1054
1054
  disconnected or removed never gets a second address. The SessionEnd hook runs
1055
- `primitive agent session-end`, which disconnects only agents that
1056
- `session-register` created, and only while the session's profile still holds
1057
- that exact credential: an agent later connected into the same profile (for
1058
- example with `primitive agent connect`) is left connected, and only the
1059
- registered agent's own receive hooks are removed. Claude reports the same
1060
- SessionEnd for a restart, a switch to another session and a real exit, so the
1061
- hook waits five minutes before disconnecting, and a start of the same session
1062
- within that time cancels the end. The end is recorded first, so a registration still
1063
- running disconnects the agent it creates; when the local credential is gone it
1055
+ `primitive agent session-end`, which marks the session ended and removes its
1056
+ exact-session receive hooks, but keeps the agent's credential and profile, so
1057
+ closing a terminal does not cost the session its agent: resuming the session
1058
+ (for example `claude --resume <session>`) re-verifies the agent and reinstalls
1059
+ the hooks at the same address. Presence lapses on its own once the session
1060
+ stops running. Claude reports the same SessionEnd for a restart, a switch to
1061
+ another session and a real exit, so the hook waits five minutes first, and a
1062
+ start of the same session within that time cancels the end.
1063
+
1064
+ An agent that `session-register` created is disconnected only when its session
1065
+ has not started again for 30 days, by `machine doctor --fix` or the next
1066
+ `session-register` on the machine, and only while the session's profile still
1067
+ holds that exact credential: an agent later connected into the same profile
1068
+ (for example with `primitive agent connect`) is never disconnected, and neither
1069
+ is one connected any other way. `primitive agent session-end --revoke`
1070
+ disconnects the agent at once instead; when its local credential is gone it
1064
1071
  revokes by address with the member login, and an unconfirmed disconnect is
1065
- reported as `disconnect_pending` and retried by `machine doctor --fix`. Both always exit 0 and finish slow work in the
1072
+ reported as `disconnect_pending` and retried by `machine doctor --fix`. If a
1073
+ session's agent was already disconnected in Primitive, its next start reports
1074
+ it (status `revoked` or `ended`) instead of creating a new address; the owner
1075
+ can use Reconnect on the agent in the Primitive app, which invites the same
1076
+ address again, and paste that instruction into the session.
1077
+
1078
+ Both commands always exit 0 and finish slow work in the
1066
1079
  background. Non-interactive Claude runs (`claude -p` and SDK hosts, identified
1067
1080
  by `CLAUDE_CODE_ENTRYPOINT`) are skipped with status `skipped_headless`. Codex
1068
1081
  sessions register from their SessionStart hook, with the instructions block as a
@@ -1220,6 +1233,17 @@ hooks print the same line as the live wake, including the load-the-skill line
1220
1233
  for verified mail (notices recorded by older CLI versions keep a shorter form
1221
1234
  with the same `to=` field and profile-selecting read command).
1222
1235
 
1236
+ Mail in one thread is announced once, by its newest message. A message whose
1237
+ thread already has newer mail (`newer=` above zero) does not wake the session
1238
+ on its own: the listener moves on to the newer one, and wakes for the held
1239
+ message only if nothing newer arrives within a few seconds. The hooks replay
1240
+ only the newest pending notice of each thread, followed by a line saying how
1241
+ many older pending messages in the thread it covers, and an older tick of a
1242
+ repeating message is dropped once a newer message in its thread is recorded.
1243
+ Reading a message, or replying to it from the session, also clears the pending
1244
+ notices for older messages in its thread, so a backlog left by a long gap never
1245
+ replays one message per turn.
1246
+
1223
1247
  An exact reply that `primitive chat` or `primitive emails wait` returns inside
1224
1248
  a session is already handled: its pending notice for that session is removed,
1225
1249
  and a listener that receives the reply again (after a restart, or a redelivered
@@ -102,6 +102,7 @@ export function readPendingMail(configDir, profile, sessionId) {
102
102
  threadId: notice.thread_id?.toLowerCase() ?? null,
103
103
  inThread: notice.in_thread,
104
104
  newer: notice.newer,
105
+ receivedAt: Date.parse(notice.received_at),
105
106
  // Server-derived; an unreadable label is dropped, not the notice.
106
107
  interaction:
107
108
  notice.kind !== "status" &&
@@ -125,6 +126,56 @@ export function readPendingMail(configDir, profile, sessionId) {
125
126
  });
126
127
  }
127
128
 
129
+ const REPEAT_TICK = "repeat.tick/1";
130
+
131
+ /**
132
+ * One notice per thread: the newest mail notice in each thread stands for
133
+ * every older one, which it covers (reading or answering it clears them).
134
+ * A repeating message's tick that knows of newer mail in its thread is
135
+ * superseded even when that newer tick has no notice yet, since the newer
136
+ * one will arrive. Status notices and mail outside a thread are never
137
+ * collapsed. A kept notice carries `covered`, how many it stands for.
138
+ * Order is kept, so the oldest remaining notice still comes first.
139
+ */
140
+ export function collapsePendingMail(notices) {
141
+ const newest = new Map();
142
+ notices.forEach((notice, index) => {
143
+ if (notice.kind === "status" || !notice.threadId) return;
144
+ const current = newest.get(notice.threadId);
145
+ if (
146
+ current === undefined ||
147
+ notice.receivedAt > notices[current].receivedAt ||
148
+ (notice.receivedAt === notices[current].receivedAt && index > current)
149
+ )
150
+ newest.set(notice.threadId, index);
151
+ });
152
+ const covered = new Map();
153
+ const kept = notices.filter((notice, index) => {
154
+ if (notice.kind === "status" || !notice.threadId) return true;
155
+ if (newest.get(notice.threadId) !== index) {
156
+ covered.set(notice.threadId, (covered.get(notice.threadId) ?? 0) + 1);
157
+ return false;
158
+ }
159
+ return !(
160
+ notice.interaction === REPEAT_TICK &&
161
+ Number.isSafeInteger(notice.newer) &&
162
+ notice.newer > 0
163
+ );
164
+ });
165
+ return kept.map((notice) =>
166
+ notice.kind !== "status" && notice.threadId && covered.get(notice.threadId)
167
+ ? { ...notice, covered: covered.get(notice.threadId) }
168
+ : notice,
169
+ );
170
+ }
171
+
172
+ /** The line after a notice that stands for older pending mail in its thread. */
173
+ export function coveredLine(notice) {
174
+ const count = notice.covered;
175
+ if (!Number.isSafeInteger(count) || count < 1) return "";
176
+ return `${count === 1 ? "1 older pending message" : `${count} older pending messages`} in thread ${notice.threadId} ${count === 1 ? "is" : "are"} covered by ${notice.emailId}; reading or answering it clears ${count === 1 ? "that one" : "them"} too.\n`;
177
+ }
178
+
128
179
  // Copies of WAKE_SENTENCES and the category mapping in
129
180
  // src/oclif/interaction-actions.ts; a test keeps them equal.
130
181
  export const WAKE_SENTENCES = {
@@ -234,6 +285,10 @@ const wakeSenderPattern = /^[a-z0-9._%+-]{1,64}@[a-z0-9.-]{1,253}$/;
234
285
  * line, including its load-the-skill line (naming `receiver.skillFile`).
235
286
  */
236
287
  export function formatPendingMail(notice, receiver = {}) {
288
+ return `${formatPendingLine(notice, receiver)}${coveredLine(notice)}`;
289
+ }
290
+
291
+ function formatPendingLine(notice, receiver) {
237
292
  if (notice.kind === "status")
238
293
  return `Primitive status arrived: ${notice.emailId}${recipientField(receiver.address)} from=${notice.sender} on_sent=${notice.refSentEmailId}. This is activity on a conversation this session started, not a new task.\n`;
239
294
  if (
@@ -475,7 +530,9 @@ async function main() {
475
530
  },
476
531
  );
477
532
  }
478
- const notices = readPendingMail(configDir, profile, sessionId);
533
+ const notices = collapsePendingMail(
534
+ readPendingMail(configDir, profile, sessionId),
535
+ );
479
536
  if (notices.length) {
480
537
  const due = duePendingMail(configDir, profile, sessionId, notices);
481
538
  context(due, { profile, address, skillFile: claudeSkillFile(), cli });
@@ -2,6 +2,7 @@ import { spawn, spawnSync } from "node:child_process";
2
2
  import {
3
3
  claudeSkillFile,
4
4
  clearDeliveredStatus,
5
+ collapsePendingMail,
5
6
  duePendingMail,
6
7
  formatPendingMail,
7
8
  readPendingMail,
@@ -180,7 +181,11 @@ try {
180
181
  configDir,
181
182
  profileName,
182
183
  session,
183
- readPendingMail(configDir, profileName, session),
184
+ // Superseded notices never block Stop: only the newest notice
185
+ // in each thread is announced, standing for the older ones.
186
+ collapsePendingMail(
187
+ readPendingMail(configDir, profileName, session),
188
+ ),
184
189
  )
185
190
  : [];
186
191
  if (pending.length) {
@@ -1,4 +1,4 @@
1
- import { S as __exportAll, _ as WEBHOOK_EVENT_TYPES, g as SENT_EMAIL_EVENT_TYPES } from "./repeats-BL7ZC-_6-O_YE1_cO.js";
1
+ import { S as __exportAll, _ as WEBHOOK_EVENT_TYPES, g as SENT_EMAIL_EVENT_TYPES } from "./repeats-BL7ZC-_6-BZ-MphTV.js";
2
2
  import { C as resolveCliAuth, F as agentProfilesDirectory, G as createClient, H as requireDefaultLoginProfile, K as createConfig, N as agentProfileDirectory, R as loadConnectedAgentProfile, S as normalizeApiBaseUrl, T as saveSignupCredentials, W as PrimitiveApiClient, _ as deleteCliCredentials, c as resolveConfigEnvironment, d as validateCliHeaderName, f as validateCliHeaderValue, i as loadCliConfig, j as AGENT_PROFILE_ENV, m as cliAccessTokenExpiresAt, p as acquireCliCredentialsLock, w as saveCliCredentials, x as loadCliCredentials, y as detectPrimitiveKeyEnvMisname } from "./cli-config-DMszFbGn.js";
3
3
  import { h as writeMailJson, m as withMailLock, p as syncMailDirectory, u as privateMailPermissions } from "./shared-mail-files-MwlVE90c.js";
4
4
  import { Command, Errors, Flags } from "@oclif/core";
@@ -4875,6 +4875,19 @@ function write(configDir, profileName, session, notices) {
4875
4875
  notices
4876
4876
  });
4877
4877
  }
4878
+ const REPEAT_TICK = "repeat.tick/1";
4879
+ function receivedAt(row) {
4880
+ return Date.parse(row.received_at);
4881
+ }
4882
+ /**
4883
+ * Whether `older` is a mail notice that `newer` covers: a mail notice in the
4884
+ * same thread, received before it. Reading or answering the newest message
4885
+ * in a thread covers every older pending one there; one received at the same
4886
+ * moment is not known to be older, so it stays.
4887
+ */
4888
+ function coveredBy(older, newer) {
4889
+ return older.kind === "mail" && older.thread_id !== null && older.thread_id === newer.thread_id && receivedAt(older) < Date.parse(newer.received_at);
4890
+ }
4878
4891
  /**
4879
4892
  * The notice list is full of unread mail. Nothing is dropped: the caller
4880
4893
  * leaves the event unacknowledged, so the mail stays queued and is
@@ -4899,7 +4912,7 @@ async function recordPendingMail(configDir, profileName, session, value) {
4899
4912
  });
4900
4913
  if (!row) throw new Error("Pending mail notice is invalid.");
4901
4914
  return withMailLock(lockDirectory(configDir, profileName), () => {
4902
- const next = [...readPendingMail(configDir, profileName, session, true).filter((existing) => existing.email_id !== row.email_id), row];
4915
+ const next = [...readPendingMail(configDir, profileName, session, true).filter((existing) => existing.email_id !== row.email_id && !(row.kind === "mail" && existing.interaction === REPEAT_TICK && coveredBy(existing, row))), row];
4903
4916
  if (row.kind !== "status" && next.filter((existing) => existing.kind !== "status").length > 50) throw new PendingMailFullError();
4904
4917
  while (next.length > 60) {
4905
4918
  const status = next.findIndex((existing) => existing !== row && existing.kind === "status");
@@ -4929,28 +4942,77 @@ function pendingMailSessions(configDir, profileName) {
4929
4942
  }
4930
4943
  }
4931
4944
  /**
4932
- * Clear one email after a session read it, from that session's file only.
4933
- * A read with no known runtime session (a terminal or script) clears
4934
- * nothing: it is not evidence that any session has seen the email, and each
4935
- * session keeps its notice until it reads the email itself.
4945
+ * Remove one email's notice and every older mail notice in its thread that
4946
+ * it covers. The thread and time come from the email's own notice, or from
4947
+ * `known` (the email as the API returned it) when it has none.
4936
4948
  */
4937
- async function clearReadPendingMail(configDir, profileName, session, emailId) {
4949
+ async function removeCoveredPendingMail(configDir, profileName, session, emailId, known) {
4950
+ const id = emailId.toLowerCase();
4951
+ return withMailLock(lockDirectory(configDir, profileName), () => {
4952
+ const current = readPendingMail(configDir, profileName, session, true);
4953
+ const own = current.find((row) => row.email_id === id);
4954
+ const anchor = own?.kind === "mail" ? own : known && typeof known.thread_id === "string" && UUID$1.test(known.thread_id.toLowerCase()) && Number.isFinite(Date.parse(known.received_at)) ? {
4955
+ thread_id: known.thread_id.toLowerCase(),
4956
+ received_at: known.received_at
4957
+ } : null;
4958
+ const next = current.filter((row) => row.email_id !== id && !(anchor !== null && coveredBy(row, anchor)));
4959
+ if (next.length !== current.length) write(configDir, profileName, session, next);
4960
+ return next;
4961
+ });
4962
+ }
4963
+ /**
4964
+ * Clear one email after a session read it, from that session's file only,
4965
+ * with every older pending mail in its thread: reading the newest message
4966
+ * covers them. A read with no known runtime session (a terminal or script)
4967
+ * clears nothing: it is not evidence that any session has seen the email,
4968
+ * and each session keeps its notice until it reads the email itself.
4969
+ */
4970
+ async function clearReadPendingMail(configDir, profileName, session, emailId, known) {
4938
4971
  if (!session) return;
4939
4972
  const sessions = pendingMailSessions(configDir, profileName).filter((id) => id === sessionId(session));
4940
- for (const id of sessions) await removePendingMail(configDir, profileName, id, [emailId]);
4973
+ for (const id of sessions) await removeCoveredPendingMail(configDir, profileName, id, emailId, known);
4941
4974
  }
4942
4975
  /**
4943
4976
  * Clear one email's notices for a session after the session consumed it
4944
4977
  * without reading it by id: an exact reply that `primitive chat` or
4945
4978
  * `primitive emails wait` returned. Every profile of the session holding a
4946
- * notice for the email is cleared, so a restarted wake does not announce a
4947
- * reply the session already has. A key with no runtime session clears
4948
- * nothing.
4949
- */
4950
- async function clearConsumedPendingMail(configDir, sessionKey, emailId) {
4979
+ * notice for the email is cleared, with the older mail in its thread, so a
4980
+ * restarted wake does not announce a reply the session already has. A
4981
+ * listener leaves such a reply to the wait without writing a notice, so with
4982
+ * `known` (the reply's thread and time) the older notices in its thread are
4983
+ * also cleared for every profile of the session that receives at
4984
+ * `recipient`. A key with no runtime session clears nothing.
4985
+ */
4986
+ async function clearConsumedPendingMail(configDir, sessionKey, emailId, known) {
4951
4987
  const session = sessionKey?.slice(sessionKey.indexOf(":") + 1).toLowerCase();
4952
4988
  if (!session || !UUID$1.test(session)) return;
4953
- for (const profile of pendingMailProfiles(configDir, session, emailId)) await removePendingMail(configDir, profile, session, [emailId]);
4989
+ const profiles = new Set(pendingMailProfiles(configDir, session, emailId));
4990
+ const anchor = typeof known?.thread_id === "string" && typeof known.received_at === "string" ? {
4991
+ thread_id: known.thread_id,
4992
+ received_at: known.received_at
4993
+ } : null;
4994
+ if (anchor && known?.recipient) for (const profile of receivingProfiles(configDir, session, known.recipient)) profiles.add(profile);
4995
+ for (const profile of [...profiles].sort()) await removeCoveredPendingMail(configDir, profile, session, emailId, anchor);
4996
+ }
4997
+ /**
4998
+ * Profiles with a pending-mail file for the session whose connected address
4999
+ * is `recipient`.
5000
+ */
5001
+ function receivingProfiles(configDir, session, recipient) {
5002
+ let names;
5003
+ try {
5004
+ names = readdirSync(join(agentProfilesDirectory(configDir), "profiles"));
5005
+ } catch {
5006
+ return [];
5007
+ }
5008
+ const address = recipient.trim().toLowerCase();
5009
+ return names.filter((name) => {
5010
+ try {
5011
+ return pendingMailSessions(configDir, name).includes(session) && loadConnectedAgentProfile(configDir, name)?.agent_address.toLowerCase() === address;
5012
+ } catch {
5013
+ return false;
5014
+ }
5015
+ });
4954
5016
  }
4955
5017
  /**
4956
5018
  * Record that this profile read one of its own pending emails and the API
@@ -5299,7 +5361,7 @@ function savedConnectedProfiles(configDir, exclude) {
5299
5361
  */
5300
5362
  async function otherProfiles(configDir, session, current) {
5301
5363
  if (session) try {
5302
- const { connectedProfilesForSession } = await import("./machine-session-CgEAzThT.js").then((n) => n.s);
5364
+ const { connectedProfilesForSession } = await import("./machine-session-7bISVcRb.js").then((n) => n.s);
5303
5365
  return {
5304
5366
  inSession: true,
5305
5367
  profiles: connectedProfilesForSession(configDir, session, current)