primcli 1.67.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 +34 -10
- package/bin/claude-pending-mail.mjs +58 -1
- package/bin/claude-wake.mjs +6 -1
- package/dist/{api-command-Dh4hs7kh.js → api-command-D7DPn2Ro.js} +76 -14
- package/dist/{machine-session-s6LvMvRd.js → machine-session-7bISVcRb.js} +181 -26
- package/dist/{notify-session-CeKAcPIX.js → notify-session-Cp0Kkstb.js} +1 -1
- package/dist/oclif/auto-signal-worker.js +2 -2
- package/dist/oclif/index.js +110 -42
- package/dist/oclif/receiver-heal.js +1 -1
- package/dist/{signal-command-BxafnMiU.js → signal-command-Bi2U2BR_.js} +1 -1
- package/dist/skills/primitive-connect/SKILL.md +6 -0
- package/dist/skills/primitive-connect/package-lock.json +78 -78
- package/dist/skills/primitive-connect/package.json +1 -1
- package/dist/skills/primitive-connect/references/conversations.md +22 -1
- package/dist/skills/primitive-connect.json +6 -6
- package/package.json +1 -1
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
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
example
|
|
1059
|
-
|
|
1060
|
-
SessionEnd for a restart, a switch to
|
|
1061
|
-
hook waits five minutes
|
|
1062
|
-
within that time cancels the end.
|
|
1063
|
-
|
|
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`.
|
|
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 =
|
|
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 });
|
package/bin/claude-wake.mjs
CHANGED
|
@@ -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
|
-
|
|
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) {
|
|
@@ -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
|
-
*
|
|
4933
|
-
*
|
|
4934
|
-
*
|
|
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
|
|
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
|
|
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,
|
|
4947
|
-
* reply the session already has. A
|
|
4948
|
-
*
|
|
4949
|
-
|
|
4950
|
-
|
|
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
|
-
|
|
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-
|
|
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)
|