agents-can-communicate 0.7.0 → 0.7.1
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/docs/ARCHITECTURE.md +5 -0
- package/docs/CLI.md +13 -2
- package/docs/CONCEPTS.md +7 -0
- package/docs/PROTOCOL.md +31 -0
- package/node_modules/@agents-can-communicate/adapter-antigravity/package.json +1 -1
- package/node_modules/@agents-can-communicate/adapter-antigravity/plugin/plugin.json +1 -1
- package/node_modules/@agents-can-communicate/adapter-antigravity/plugin/skills/acc/SKILL.md +8 -0
- package/node_modules/@agents-can-communicate/adapter-claude-code/package.json +1 -1
- package/node_modules/@agents-can-communicate/adapter-claude-code/plugin/skills/acc/SKILL.md +8 -0
- package/node_modules/@agents-can-communicate/adapter-codex/package.json +1 -1
- package/node_modules/@agents-can-communicate/adapter-codex/plugin/skills/acc/SKILL.md +8 -0
- package/node_modules/@agents-can-communicate/adapter-gemini-cli/extension/gemini-extension.json +1 -1
- package/node_modules/@agents-can-communicate/adapter-gemini-cli/extension/skills/acc/SKILL.md +8 -0
- package/node_modules/@agents-can-communicate/adapter-gemini-cli/package.json +1 -1
- package/node_modules/@agents-can-communicate/adapter-grok/package.json +1 -1
- package/node_modules/@agents-can-communicate/adapter-grok/plugin/skills/acc/SKILL.md +8 -0
- package/node_modules/@agents-can-communicate/adapter-kimi/package.json +1 -1
- package/node_modules/@agents-can-communicate/adapter-kimi/plugin/skills/acc/SKILL.md +8 -0
- package/node_modules/@agents-can-communicate/adapter-sdk/package.json +1 -1
- package/node_modules/@agents-can-communicate/adapter-sdk/src/context-projector.mjs +15 -2
- package/node_modules/@agents-can-communicate/cli/package.json +1 -1
- package/node_modules/@agents-can-communicate/cli/src/main.mjs +15 -8
- package/node_modules/@agents-can-communicate/core/package.json +1 -1
- package/node_modules/@agents-can-communicate/core/src/conversations.mjs +2 -2
- package/node_modules/@agents-can-communicate/core/src/next-turn-delivery.mjs +21 -4
- package/node_modules/@agents-can-communicate/core/src/receipts.mjs +66 -10
- package/node_modules/@agents-can-communicate/core/src/status.mjs +19 -0
- package/node_modules/@agents-can-communicate/core/src/sync.mjs +11 -2
- package/node_modules/@agents-can-communicate/delivery-router/package.json +1 -1
- package/node_modules/@agents-can-communicate/hook-runner/package.json +1 -1
- package/node_modules/@agents-can-communicate/hook-runner/src/runner.mjs +20 -11
- package/node_modules/@agents-can-communicate/installer/package.json +1 -1
- package/node_modules/@agents-can-communicate/mcp-server/package.json +1 -1
- package/node_modules/@agents-can-communicate/mcp-server/src/tools.mjs +5 -3
- package/node_modules/@agents-can-communicate/protocol/package.json +1 -1
- package/node_modules/@agents-can-communicate/storage-filesystem/package.json +1 -1
- package/package.json +1 -1
package/docs/ARCHITECTURE.md
CHANGED
|
@@ -181,6 +181,11 @@ Adapters project peer bodies in an attributed untrusted frame. If a complete bod
|
|
|
181
181
|
fit, the projection keeps the message id and points to `acc inbox --message <id>` instead
|
|
182
182
|
of silently truncating it.
|
|
183
183
|
|
|
184
|
+
On a client that takes next-turn bodies, a live offer that is still `offered` 15 minutes later
|
|
185
|
+
is projected once more before a turn,
|
|
186
|
+
after the new bodies and marked as a repeat, and never by an end-of-turn continuation; see
|
|
187
|
+
[the protocol contract](PROTOCOL.md#one-repeat-of-a-live-offer).
|
|
188
|
+
|
|
184
189
|
For this participant's unresolved messages already `offered` or `retrieved`, the standard
|
|
185
190
|
projector combines reply and acknowledgement reminders into a compact count after new
|
|
186
191
|
message bodies. Queued obligations and claim conflicts remain individual attention items.
|
package/docs/CLI.md
CHANGED
|
@@ -97,7 +97,13 @@ acc work --clear
|
|
|
97
97
|
```
|
|
98
98
|
|
|
99
99
|
`status` returns participants, current intent, claims, protection, attention, and current
|
|
100
|
-
delivery bindings.
|
|
100
|
+
delivery bindings. Each session row carries its participant's `unretrieved: {queued, offered}`,
|
|
101
|
+
the messages addressed to that participant that it has not fetched, split by how far delivery
|
|
102
|
+
got. Two sessions of one participant show the same numbers, so read the workspace total from
|
|
103
|
+
`counts.unretrieved` rather than adding rows, and the text line ends with
|
|
104
|
+
`<n> offered, not retrieved` when that number is above zero. Offered is not read: a
|
|
105
|
+
transport took the bytes, and the model may or may not have looked. Default `sync` reads
|
|
106
|
+
events after a 16-digit event cursor (100 by
|
|
101
107
|
default, up to 500). `--scope history` discovers historical message headers and reads
|
|
102
108
|
selected records; `--scope full` adds an unbounded snapshot for explicit workspace forensics.
|
|
103
109
|
|
|
@@ -258,7 +264,12 @@ summary fields, ordering, page limits, and message-id cursors as inbox. `--type`
|
|
|
258
264
|
any message kind and filters before pagination; keep the same filter between pages.
|
|
259
265
|
This public workspace observation includes records from before your session joined.
|
|
260
266
|
Exact history reads return `view: "message"` and one complete message in `items`,
|
|
261
|
-
without changing any receipt
|
|
267
|
+
without changing any receipt, plus `receipts`: one entry per recipient with `state`,
|
|
268
|
+
`updatedAt` (the last state change) and `offer` - the transport that accepted it, when, and
|
|
269
|
+
`repeatedAt` if the next turn showed it again - or `offer: null` when no offer was recorded
|
|
270
|
+
or an older ACC recorded it. This is how a sender tells a message a peer's client accepted
|
|
271
|
+
(`offered`) from one its participant fetched (`retrieved`) or answered (`acknowledged`).
|
|
272
|
+
Retrieved is not model attention either. Do not combine `--message` with type, cursor, or limit;
|
|
262
273
|
message and type are valid only in history scope. Decision reads also include `decisionStatus`. Use
|
|
263
274
|
`acc sync --scope history --type decision --current --json` for terminal decisions
|
|
264
275
|
and withdrawals; keep these filters when paging. Exact reads cannot use `--current`.
|
package/docs/CONCEPTS.md
CHANGED
|
@@ -121,6 +121,13 @@ Listing inbox headers and observing workspace history do not change receipts.
|
|
|
121
121
|
There is no `seen` receipt. A failed delivery attempt leaves the message queued and
|
|
122
122
|
recoverable rather than creating a terminal failure state.
|
|
123
123
|
|
|
124
|
+
The sender can see where each copy got to. An exact history read of a message lists every
|
|
125
|
+
recipient's receipt with the time of its last change and the transport that offered it, and
|
|
126
|
+
`acc status` counts, per participant, the messages offered and not yet retrieved. A live
|
|
127
|
+
transport accepting a message is the weakest of these facts, so a live offer that nothing
|
|
128
|
+
followed for 15 minutes is shown once more at the recipient's next turn, marked as a repeat,
|
|
129
|
+
on a client that takes next-turn bodies.
|
|
130
|
+
|
|
124
131
|
## Durable first, faster delivery second
|
|
125
132
|
|
|
126
133
|
The durable inbox is universal. An adapter whose captures reach the installed client may
|
package/docs/PROTOCOL.md
CHANGED
|
@@ -199,6 +199,37 @@ peer body into diagnostics.
|
|
|
199
199
|
Receipt `offered` is committed only after the transport accepts bytes. A failed attempt
|
|
200
200
|
leaves the receipt queued.
|
|
201
201
|
|
|
202
|
+
The same transaction records how the receipt was offered, in the receipt's `extensions`:
|
|
203
|
+
|
|
204
|
+
```json
|
|
205
|
+
{ "offer": { "transport": "codex-app-server", "at": "<timestamp>", "repeatedAt": null } }
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
It lives in `extensions` because a new receipt field would fail validation in an older ACC
|
|
209
|
+
that reads the same store. A receipt offered by an older ACC has no `offer` entry; readers
|
|
210
|
+
report `offer: null` and never guess a transport. An exact history read returns every
|
|
211
|
+
recipient's `state`, `updatedAt` and `offer` beside the message.
|
|
212
|
+
|
|
213
|
+
### One repeat of a live offer
|
|
214
|
+
|
|
215
|
+
A `next-turn` offer is recorded only after the hook's output carried the body, which is the
|
|
216
|
+
closest ACC comes to the model. A live offer proves that a transport accepted the bytes. A
|
|
217
|
+
direct message whose receipt is still `offered`, whose offer transport is anything other than
|
|
218
|
+
`next-turn`, and whose offer is at least 15 minutes old, is shown once more by the
|
|
219
|
+
recipient's next turn on a client that takes next-turn bodies (a certified `nextTurn` and a
|
|
220
|
+
structured renderer): the body in the usual untrusted block, after new messages, with the
|
|
221
|
+
line `repeat: offered via <transport> at <timestamp>; no retrieval recorded`. An end-of-turn
|
|
222
|
+
continuation never carries a repeat, because continuing a turn costs a model call.
|
|
223
|
+
|
|
224
|
+
When the hook's output carried it, the repeat is recorded as another
|
|
225
|
+
`message.offer_succeeded` with `transport: "next-turn"` and `repeat: true`, and
|
|
226
|
+
`extensions.offer.repeatedAt` is set. The receipt keeps its state and `updatedAt`: a repeat
|
|
227
|
+
is an attempt, never a transition and never a second message. A repeat is recorded at most
|
|
228
|
+
once per receipt, and a receipt that was retrieved, acknowledged or repeated in the meantime is
|
|
229
|
+
left unchanged with nothing appended. Like a first offer, the display itself can happen twice:
|
|
230
|
+
two sessions of one participant can both show it before either commits, and a hook that runs
|
|
231
|
+
out of time after writing does not commit at all.
|
|
232
|
+
|
|
202
233
|
`transport_permission_denied` means the sender's OS or sandbox refused local transport
|
|
203
234
|
access (`EPERM` or `EACCES`). It is distinct from `recipient_unavailable` and remains a
|
|
204
235
|
failed offer with a queued receipt; raw OS error strings are not persisted.
|
|
@@ -111,6 +111,10 @@ the communication obligation rather than proving the requested action is complet
|
|
|
111
111
|
An acknowledgement confirms receipt, not acceptance of work. Read the peer's
|
|
112
112
|
answer for the scope it accepted or the result it actually checked.
|
|
113
113
|
|
|
114
|
+
To see where a message you sent got to, read it exactly:
|
|
115
|
+
`{{ACC}} sync --scope history --message <id> --json` lists each recipient's
|
|
116
|
+
receipt state, when it last changed, and the transport that offered it.
|
|
117
|
+
|
|
114
118
|
### Stay reachable while idle
|
|
115
119
|
|
|
116
120
|
ACC can wake this conversation when a peer writes while you are idle, once its
|
|
@@ -137,6 +141,10 @@ Pages default to 20 items and stay within 12,000 bytes of formatted page JSON.
|
|
|
137
141
|
Use `inbox --cursor <nextCursor>` for older headers when needed. Omit the cursor
|
|
138
142
|
on a new poll to see arrivals; a cursor is the complete last message id, not an offset.
|
|
139
143
|
|
|
144
|
+
A peer block with a `repeat:` line was pushed live earlier and nothing has
|
|
145
|
+
retrieved it since. A note you already acted on needs nothing more; for a
|
|
146
|
+
message that asks for one, a reply or acknowledgement is still owed.
|
|
147
|
+
|
|
140
148
|
An injected peer block is already the message body. If context was compacted,
|
|
141
149
|
or a body did not fit, retrieve exactly the named message:
|
|
142
150
|
|
|
@@ -111,6 +111,10 @@ the communication obligation rather than proving the requested action is complet
|
|
|
111
111
|
An acknowledgement confirms receipt, not acceptance of work. Read the peer's
|
|
112
112
|
answer for the scope it accepted or the result it actually checked.
|
|
113
113
|
|
|
114
|
+
To see where a message you sent got to, read it exactly:
|
|
115
|
+
`{{ACC}} sync --scope history --message <id> --json` lists each recipient's
|
|
116
|
+
receipt state, when it last changed, and the transport that offered it.
|
|
117
|
+
|
|
114
118
|
## Read and answer only your inbox
|
|
115
119
|
|
|
116
120
|
Plain `{{ACC}} inbox` returns `{items, nextCursor}`: pending message headers,
|
|
@@ -125,6 +129,10 @@ Pages default to 20 items and stay within 12,000 bytes of formatted page JSON.
|
|
|
125
129
|
Use `inbox --cursor <nextCursor>` for older headers when needed. Omit the cursor
|
|
126
130
|
on a new poll to see arrivals; a cursor is the complete last message id, not an offset.
|
|
127
131
|
|
|
132
|
+
A peer block with a `repeat:` line was pushed live earlier and nothing has
|
|
133
|
+
retrieved it since. A note you already acted on needs nothing more; for a
|
|
134
|
+
message that asks for one, a reply or acknowledgement is still owed.
|
|
135
|
+
|
|
128
136
|
An injected peer block is already the message body. If context was compacted,
|
|
129
137
|
or a body did not fit, retrieve exactly the named message:
|
|
130
138
|
|
|
@@ -111,6 +111,10 @@ the communication obligation rather than proving the requested action is complet
|
|
|
111
111
|
An acknowledgement confirms receipt, not acceptance of work. Read the peer's
|
|
112
112
|
answer for the scope it accepted or the result it actually checked.
|
|
113
113
|
|
|
114
|
+
To see where a message you sent got to, read it exactly:
|
|
115
|
+
`{{ACC}} sync --scope history --message <id> --json` lists each recipient's
|
|
116
|
+
receipt state, when it last changed, and the transport that offered it.
|
|
117
|
+
|
|
114
118
|
## Read and answer only your inbox
|
|
115
119
|
|
|
116
120
|
Plain `{{ACC}} inbox` returns `{items, nextCursor}`: pending message headers,
|
|
@@ -125,6 +129,10 @@ Pages default to 20 items and stay within 12,000 bytes of formatted page JSON.
|
|
|
125
129
|
Use `inbox --cursor <nextCursor>` for older headers when needed. Omit the cursor
|
|
126
130
|
on a new poll to see arrivals; a cursor is the complete last message id, not an offset.
|
|
127
131
|
|
|
132
|
+
A peer block with a `repeat:` line was pushed live earlier and nothing has
|
|
133
|
+
retrieved it since. A note you already acted on needs nothing more; for a
|
|
134
|
+
message that asks for one, a reply or acknowledgement is still owed.
|
|
135
|
+
|
|
128
136
|
An injected peer block is already the message body. If context was compacted,
|
|
129
137
|
or a body did not fit, retrieve exactly the named message:
|
|
130
138
|
|
package/node_modules/@agents-can-communicate/adapter-gemini-cli/extension/skills/acc/SKILL.md
CHANGED
|
@@ -111,6 +111,10 @@ the communication obligation rather than proving the requested action is complet
|
|
|
111
111
|
An acknowledgement confirms receipt, not acceptance of work. Read the peer's
|
|
112
112
|
answer for the scope it accepted or the result it actually checked.
|
|
113
113
|
|
|
114
|
+
To see where a message you sent got to, read it exactly:
|
|
115
|
+
`{{ACC}} sync --scope history --message <id> --json` lists each recipient's
|
|
116
|
+
receipt state, when it last changed, and the transport that offered it.
|
|
117
|
+
|
|
114
118
|
## Read and answer only your inbox
|
|
115
119
|
|
|
116
120
|
Plain `{{ACC}} inbox` returns `{items, nextCursor}`: pending message headers,
|
|
@@ -125,6 +129,10 @@ Pages default to 20 items and stay within 12,000 bytes of formatted page JSON.
|
|
|
125
129
|
Use `inbox --cursor <nextCursor>` for older headers when needed. Omit the cursor
|
|
126
130
|
on a new poll to see arrivals; a cursor is the complete last message id, not an offset.
|
|
127
131
|
|
|
132
|
+
A peer block with a `repeat:` line was pushed live earlier and nothing has
|
|
133
|
+
retrieved it since. A note you already acted on needs nothing more; for a
|
|
134
|
+
message that asks for one, a reply or acknowledgement is still owed.
|
|
135
|
+
|
|
128
136
|
An injected peer block is already the message body. If context was compacted,
|
|
129
137
|
or a body did not fit, retrieve exactly the named message:
|
|
130
138
|
|
|
@@ -127,6 +127,10 @@ the communication obligation rather than proving the requested action is complet
|
|
|
127
127
|
An acknowledgement confirms receipt, not acceptance of work. Read the peer's
|
|
128
128
|
answer for the scope it accepted or the result it actually checked.
|
|
129
129
|
|
|
130
|
+
To see where a message you sent got to, read it exactly:
|
|
131
|
+
`{{ACC}} sync --scope history --message <id> --json` lists each recipient's
|
|
132
|
+
receipt state, when it last changed, and the transport that offered it.
|
|
133
|
+
|
|
130
134
|
## Read and answer only your inbox
|
|
131
135
|
|
|
132
136
|
Plain `{{ACC}} inbox` returns `{items, nextCursor}`: pending message headers,
|
|
@@ -141,6 +145,10 @@ Pages default to 20 items and stay within 12,000 bytes of formatted page JSON.
|
|
|
141
145
|
Use `inbox --cursor <nextCursor>` for older headers when needed. Omit the cursor
|
|
142
146
|
on a new poll to see arrivals; a cursor is the complete last message id, not an offset.
|
|
143
147
|
|
|
148
|
+
A peer block with a `repeat:` line was pushed live earlier and nothing has
|
|
149
|
+
retrieved it since. A note you already acted on needs nothing more; for a
|
|
150
|
+
message that asks for one, a reply or acknowledgement is still owed.
|
|
151
|
+
|
|
144
152
|
An injected peer block is already the message body. If context was compacted,
|
|
145
153
|
or a body did not fit, retrieve exactly the named message:
|
|
146
154
|
|
|
@@ -111,6 +111,10 @@ the communication obligation rather than proving the requested action is complet
|
|
|
111
111
|
An acknowledgement confirms receipt, not acceptance of work. Read the peer's
|
|
112
112
|
answer for the scope it accepted or the result it actually checked.
|
|
113
113
|
|
|
114
|
+
To see where a message you sent got to, read it exactly:
|
|
115
|
+
`{{ACC}} sync --scope history --message <id> --json` lists each recipient's
|
|
116
|
+
receipt state, when it last changed, and the transport that offered it.
|
|
117
|
+
|
|
114
118
|
## Read and answer only your inbox
|
|
115
119
|
|
|
116
120
|
Plain `{{ACC}} inbox` returns `{items, nextCursor}`: pending message headers,
|
|
@@ -125,6 +129,10 @@ Pages default to 20 items and stay within 12,000 bytes of formatted page JSON.
|
|
|
125
129
|
Use `inbox --cursor <nextCursor>` for older headers when needed. Omit the cursor
|
|
126
130
|
on a new poll to see arrivals; a cursor is the complete last message id, not an offset.
|
|
127
131
|
|
|
132
|
+
A peer block with a `repeat:` line was pushed live earlier and nothing has
|
|
133
|
+
retrieved it since. A note you already acted on needs nothing more; for a
|
|
134
|
+
message that asks for one, a reply or acknowledgement is still owed.
|
|
135
|
+
|
|
128
136
|
An injected peer block is already the message body. If context was compacted,
|
|
129
137
|
or a body did not fit, retrieve exactly the named message:
|
|
130
138
|
|
|
@@ -51,7 +51,18 @@ function offeredBreadcrumb(item, liveOfferedMessageIds) {
|
|
|
51
51
|
+ `\`acc inbox --message ${item.sourceId}\``;
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
-
|
|
54
|
+
// A body shown again because a live transport accepted it and nothing followed.
|
|
55
|
+
// Said inside the block so the model can match it to what it may already have.
|
|
56
|
+
function repeatLines(message, repeats) {
|
|
57
|
+
const repeat = repeats.get(message.messageId);
|
|
58
|
+
// Both values come from the store, and the store is shared with every peer's
|
|
59
|
+
// process, so they are escaped like any other line inside the block.
|
|
60
|
+
return repeat === undefined ? []
|
|
61
|
+
: [oneLine(escapePeerText(`repeat: offered via ${repeat.transport} at ${repeat.at}; `
|
|
62
|
+
+ "no retrieval recorded"))];
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function messageGroups(messages, repeats = new Map()) {
|
|
55
66
|
return messages.map(message => ({
|
|
56
67
|
messageId: message.messageId,
|
|
57
68
|
kind: "message",
|
|
@@ -63,6 +74,7 @@ function messageGroups(messages) {
|
|
|
63
74
|
`messageId: ${message.messageId}`,
|
|
64
75
|
`sender: ${message.fromParticipantId} (session ${message.fromSessionId})`,
|
|
65
76
|
`obligation: ${message.obligation}`,
|
|
77
|
+
...repeatLines(message, repeats),
|
|
66
78
|
...decisionLines(message).map(escapePeerText),
|
|
67
79
|
`subject: ${oneLine(escapePeerText(message.subject))}`,
|
|
68
80
|
"body:",
|
|
@@ -129,7 +141,8 @@ export function projectContextResult(sync, { budgetBytes = DEFAULT_BUDGET_BYTES
|
|
|
129
141
|
const liveOfferedMessageIds = new Set(sync.liveOfferedMessageIds ?? []);
|
|
130
142
|
const groups = [
|
|
131
143
|
...attentionGroups(urgent, { truncatable: true, liveOfferedMessageIds }),
|
|
132
|
-
...messageGroups(messages)
|
|
144
|
+
...messageGroups(messages, new Map((sync.repeatOffers ?? [])
|
|
145
|
+
.map(repeat => [repeat.messageId, repeat]))),
|
|
133
146
|
...reminderGroups(attention.filter(isReminder)),
|
|
134
147
|
...attentionGroups(informational, { truncatable: false }),
|
|
135
148
|
];
|
|
@@ -174,6 +174,20 @@ export function describePresence({ live = 0, stale = 0 } = {}) {
|
|
|
174
174
|
return `${live} live (${stale} not answering)`;
|
|
175
175
|
}
|
|
176
176
|
|
|
177
|
+
export function describeStatus(status) {
|
|
178
|
+
// `live` counts everyone present, which includes sessions gone stale - a
|
|
179
|
+
// client that exited without its session being closed keeps a record that
|
|
180
|
+
// stops being answered but does not disappear. Printing the number alone
|
|
181
|
+
// said "1 live" about a workspace where the last agent had left minutes
|
|
182
|
+
// before, which is the one thing a person reads this line to find out.
|
|
183
|
+
const line = `${describePresence(status.counts)}; ${status.counts.claims} claim(s); `
|
|
184
|
+
+ `protection ${status.protection}`;
|
|
185
|
+
// A transport accepting a message is not a model reading it. Said only when
|
|
186
|
+
// it is true of something, so the everyday line stays the everyday line.
|
|
187
|
+
const offered = status.counts.unretrieved?.offered ?? 0;
|
|
188
|
+
return offered === 0 ? line : `${line}; ${offered} offered, not retrieved`;
|
|
189
|
+
}
|
|
190
|
+
|
|
177
191
|
const HANDLERS = Object.freeze({
|
|
178
192
|
attach: async ({ options, context }) => {
|
|
179
193
|
const session = await context.service.openSession({
|
|
@@ -336,14 +350,7 @@ const HANDLERS = Object.freeze({
|
|
|
336
350
|
status: async ({ options, context }) => {
|
|
337
351
|
const status = await context.service.collectStatus({
|
|
338
352
|
participantId: options.participant, all: options.all === true });
|
|
339
|
-
|
|
340
|
-
// client that exited without its session being closed keeps a record that
|
|
341
|
-
// stops being answered but does not disappear. Printing the number alone
|
|
342
|
-
// said "1 live" about a workspace where the last agent had left minutes
|
|
343
|
-
// before, which is the one thing a person reads this line to find out.
|
|
344
|
-
const text = `${describePresence(status.counts)}; ${status.counts.claims} claim(s); `
|
|
345
|
-
+ `protection ${status.protection}`;
|
|
346
|
-
return { data: status, text };
|
|
353
|
+
return { data: status, text: describeStatus(status) };
|
|
347
354
|
},
|
|
348
355
|
|
|
349
356
|
config: async ({ options, runtime }) => {
|
|
@@ -292,8 +292,8 @@ export function createConversationService(ports, sessions) {
|
|
|
292
292
|
return (await nextTurnDelivery(input)).queuedMessages;
|
|
293
293
|
}
|
|
294
294
|
|
|
295
|
-
function nextTurnDelivery(input) {
|
|
296
|
-
return selectNextTurnDelivery(store, input);
|
|
295
|
+
function nextTurnDelivery(input = {}) {
|
|
296
|
+
return selectNextTurnDelivery(store, { ...input, now: clock.now() });
|
|
297
297
|
}
|
|
298
298
|
|
|
299
299
|
return { sendMessage, finishSession, pendingMessages, nextTurnDelivery };
|
|
@@ -1,7 +1,10 @@
|
|
|
1
|
+
import { decisionView, isCurrentDecision } from "./decision-state.mjs";
|
|
2
|
+
import { dueForRepeat, offerFacts } from "./receipts.mjs";
|
|
3
|
+
|
|
1
4
|
export async function nextTurnDelivery(store, input = {}) {
|
|
2
5
|
if (typeof input.participantId !== "string" || input.participantId === "") {
|
|
3
6
|
return { queuedMessages: [], liveOfferedMessageIds: [], reminderMessageIds: [],
|
|
4
|
-
roomMessageIds: [] };
|
|
7
|
+
roomMessageIds: [], repeatMessages: [], repeatOffers: [] };
|
|
5
8
|
}
|
|
6
9
|
const snapshot = await store.snapshot(input.workspaceId ?? store.workspaceId,
|
|
7
10
|
{ kinds: ["message", "receipt"] });
|
|
@@ -13,19 +16,33 @@ export async function nextTurnDelivery(store, input = {}) {
|
|
|
13
16
|
&& message.fromSessionId !== input.exceptSessionId)
|
|
14
17
|
.sort((left, right) => left.sentAt.localeCompare(right.sentAt)
|
|
15
18
|
|| left.messageId.localeCompare(right.messageId));
|
|
19
|
+
// Only a caller that will show them asks. A repeat taken out of the reminder
|
|
20
|
+
// lists and then never shown would be the one message this turn forgot.
|
|
21
|
+
const repeatMessages = input.repeats === true
|
|
22
|
+
? eligible.filter(message => dueForRepeat(message,
|
|
23
|
+
receiptByMessage.get(message.messageId), input.now))
|
|
24
|
+
: [];
|
|
25
|
+
const repeating = new Set(repeatMessages.map(message => message.messageId));
|
|
16
26
|
return {
|
|
17
27
|
queuedMessages: eligible.filter(message =>
|
|
18
28
|
receiptByMessage.get(message.messageId).state === "queued"),
|
|
19
29
|
liveOfferedMessageIds: eligible.filter(message => message.toParticipantIds.length > 0
|
|
20
|
-
&& receiptByMessage.get(message.messageId).state === "offered"
|
|
30
|
+
&& receiptByMessage.get(message.messageId).state === "offered"
|
|
31
|
+
&& !repeating.has(message.messageId))
|
|
21
32
|
.map(message => message.messageId),
|
|
22
33
|
// A missing body is not evidence of delivery: a degraded adapter may have
|
|
23
34
|
// withheld it. Only this recipient's durable receipt permits a reminder.
|
|
24
35
|
reminderMessageIds: eligible.filter(message => message.obligation !== "none"
|
|
25
|
-
&& ["offered", "retrieved"].includes(receiptByMessage.get(message.messageId).state)
|
|
36
|
+
&& ["offered", "retrieved"].includes(receiptByMessage.get(message.messageId).state)
|
|
37
|
+
&& !repeating.has(message.messageId))
|
|
26
38
|
.map(message => message.messageId),
|
|
27
39
|
roomMessageIds: eligible.filter(message => message.toParticipantIds.length === 0)
|
|
28
40
|
.map(message => message.messageId),
|
|
41
|
+
// After the queued bodies, so a new message wins the byte budget first.
|
|
42
|
+
repeatMessages,
|
|
43
|
+
repeatOffers: repeatMessages.map(message => {
|
|
44
|
+
const { transport, at } = offerFacts(receiptByMessage.get(message.messageId));
|
|
45
|
+
return { messageId: message.messageId, transport, at };
|
|
46
|
+
}),
|
|
29
47
|
};
|
|
30
48
|
}
|
|
31
|
-
import { decisionView, isCurrentDecision } from "./decision-state.mjs";
|
|
@@ -3,6 +3,42 @@ import { AccError, EXIT, SCHEMA_VERSION, advanceReceipt }
|
|
|
3
3
|
|
|
4
4
|
import { receiptId } from "./conversations.mjs";
|
|
5
5
|
|
|
6
|
+
// How long a live offer may stand with nothing after it before the next turn
|
|
7
|
+
// shows the body once more. Above the 90th percentile of the time models took
|
|
8
|
+
// to retrieve, acknowledge or answer an offered message on real stores.
|
|
9
|
+
export const REPEAT_OFFER_AFTER_MS = 15 * 60 * 1000;
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* How and when a transport accepted this receipt's message, if anyone recorded it.
|
|
13
|
+
*
|
|
14
|
+
* Kept in `extensions` because a new receipt field would fail validation in an
|
|
15
|
+
* older ACC reading the same store. A receipt that an older ACC offered carries
|
|
16
|
+
* nothing here, and reads as `null`: unknown, never guessed.
|
|
17
|
+
*/
|
|
18
|
+
export function offerFacts(receipt) {
|
|
19
|
+
const offer = receipt?.extensions?.offer;
|
|
20
|
+
if (offer === null || typeof offer !== "object" || Array.isArray(offer)
|
|
21
|
+
|| typeof offer.transport !== "string" || typeof offer.at !== "string"
|
|
22
|
+
|| !Number.isFinite(Date.parse(offer.at))) return null;
|
|
23
|
+
return { transport: offer.transport, at: offer.at,
|
|
24
|
+
repeatedAt: offer.repeatedAt == null ? null : String(offer.repeatedAt) };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* A live transport accepted this message, and nothing followed for long enough.
|
|
29
|
+
*
|
|
30
|
+
* A next-turn offer is recorded only after the hook's stdout carried the body,
|
|
31
|
+
* which is as close to the model as ACC can see, so it is never repeated. A live
|
|
32
|
+
* offer proves a transport took the bytes. Once, the next turn shows it again.
|
|
33
|
+
*/
|
|
34
|
+
export function dueForRepeat(message, receipt, now) {
|
|
35
|
+
const offer = offerFacts(receipt);
|
|
36
|
+
return message != null && message.toParticipantIds.length > 0
|
|
37
|
+
&& receipt?.state === "offered" && offer !== null
|
|
38
|
+
&& offer.transport !== "next-turn" && offer.repeatedAt === null
|
|
39
|
+
&& Date.parse(now) - Date.parse(offer.at) >= REPEAT_OFFER_AFTER_MS;
|
|
40
|
+
}
|
|
41
|
+
|
|
6
42
|
export const SAFE_OFFER_ERROR_CODES = Object.freeze([
|
|
7
43
|
"ambiguous_recipient_sessions",
|
|
8
44
|
"delivery_disabled",
|
|
@@ -59,28 +95,48 @@ export function createReceiptService(ports) {
|
|
|
59
95
|
}
|
|
60
96
|
|
|
61
97
|
async function recordOfferSucceeded(input) {
|
|
98
|
+
const repeat = input.repeat === true;
|
|
99
|
+
if (repeat && input.transport !== "next-turn") {
|
|
100
|
+
throw new AccError(EXIT.DATA, "a repeated offer travels only through the next turn",
|
|
101
|
+
{ messageId: input.messageId, transport: input.transport });
|
|
102
|
+
}
|
|
62
103
|
const now = clock.now();
|
|
63
104
|
return store.transaction(tx => {
|
|
64
105
|
const id = receiptId(input.messageId, input.recipientParticipantId);
|
|
65
106
|
const receipt = requireOfferableReceipt(tx, input, { allowRoomNextTurn: true });
|
|
66
107
|
const target = requireTarget(tx, input, { mustBeOpen: true });
|
|
108
|
+
const success = extra => tx.append({ schemaVersion: SCHEMA_VERSION,
|
|
109
|
+
eventId: ids.next("event"), workspaceId: receipt.workspaceId,
|
|
110
|
+
actorSessionId: target.sessionId, type: "message.offer_succeeded", occurredAt: now,
|
|
111
|
+
payload: { messageId: input.messageId,
|
|
112
|
+
recipientParticipantId: input.recipientParticipantId,
|
|
113
|
+
targetSessionId: input.targetSessionId,
|
|
114
|
+
targetGeneration: input.targetGeneration,
|
|
115
|
+
transport: input.transport, adapterId: input.adapterId,
|
|
116
|
+
clientVersion: input.clientVersion, ...extra } });
|
|
117
|
+
if (repeat) {
|
|
118
|
+
// Judged again under the writer: a retrieval, an acknowledgement or
|
|
119
|
+
// another hook's repeat may have landed since this one was selected.
|
|
120
|
+
// The receipt keeps its state and its time; only the attempt is new.
|
|
121
|
+
if (!dueForRepeat(tx.get("message", input.messageId), receipt, now)) return receipt;
|
|
122
|
+
// Spread from the stored object, never from offerFacts: a later ACC
|
|
123
|
+
// may keep more about an offer, and this write must not drop it.
|
|
124
|
+
const repeated = { ...receipt, extensions: { ...receipt.extensions,
|
|
125
|
+
offer: { ...receipt.extensions.offer, repeatedAt: now } } };
|
|
126
|
+
tx.put("receipt", id, repeated, tx.generationOf("receipt", id));
|
|
127
|
+
success({ repeat: true });
|
|
128
|
+
return repeated;
|
|
129
|
+
}
|
|
67
130
|
// A retrieval or acknowledgement may win after a transport accepted but
|
|
68
131
|
// before this transaction acquired the writer. That stronger truth must
|
|
69
132
|
// remain in place. The same rule makes a repeated successful commit a
|
|
70
133
|
// read-only operation rather than another success event.
|
|
71
134
|
if (receipt.state !== "queued") return receipt;
|
|
72
135
|
const offered = { ...receipt, state: advanceReceipt(receipt.state, "offered"),
|
|
73
|
-
updatedAt: now
|
|
136
|
+
updatedAt: now, extensions: { ...receipt.extensions,
|
|
137
|
+
offer: { transport: input.transport, at: now, repeatedAt: null } } };
|
|
74
138
|
tx.put("receipt", id, offered, tx.generationOf("receipt", id));
|
|
75
|
-
|
|
76
|
-
workspaceId: receipt.workspaceId, actorSessionId: target.sessionId,
|
|
77
|
-
type: "message.offer_succeeded", occurredAt: now,
|
|
78
|
-
payload: { messageId: input.messageId,
|
|
79
|
-
recipientParticipantId: input.recipientParticipantId,
|
|
80
|
-
targetSessionId: input.targetSessionId,
|
|
81
|
-
targetGeneration: input.targetGeneration,
|
|
82
|
-
transport: input.transport, adapterId: input.adapterId,
|
|
83
|
-
clientVersion: input.clientVersion } });
|
|
139
|
+
success({});
|
|
84
140
|
return offered;
|
|
85
141
|
}, { kinds: ["session", "message", "receipt"], deadlineAt: input.deadlineAt });
|
|
86
142
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { classifySessionPresence } from "./sessions.mjs";
|
|
2
2
|
import { computeAttention } from "./attention.mjs";
|
|
3
3
|
import { isCurrentIntent } from "./intents.mjs";
|
|
4
|
+
import { decisionView, isCurrentDecision } from "./decision-state.mjs";
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
7
|
* Protection level, reported from what is actually enforceable.
|
|
@@ -87,6 +88,22 @@ export function createStatusService(ports, sessions, deliveryBindings) {
|
|
|
87
88
|
const claims = snapshot.claims
|
|
88
89
|
.filter(claim => Date.parse(claim.expiresAt) > Date.parse(now));
|
|
89
90
|
const currentBindings = await deliveryBindings.currentBindings(now);
|
|
91
|
+
// What each recipient has not fetched yet, and how far it got. Offered is
|
|
92
|
+
// not read: a transport took the bytes. Queued sits beside it so "0
|
|
93
|
+
// offered" cannot be mistaken for "nothing waits". Replaced decisions
|
|
94
|
+
// leave these counts exactly as they leave the inbox.
|
|
95
|
+
const current = new Set(snapshot.messages.map(decisionView(snapshot.messages))
|
|
96
|
+
.filter(isCurrentDecision).map(message => message.messageId));
|
|
97
|
+
const unretrieved = new Map();
|
|
98
|
+
const tally = key => unretrieved.get(key) ?? { queued: 0, offered: 0 };
|
|
99
|
+
for (const receipt of snapshot.receipts) {
|
|
100
|
+
if (!current.has(receipt.messageId)
|
|
101
|
+
|| !["queued", "offered"].includes(receipt.state)) continue;
|
|
102
|
+
for (const key of [receipt.recipientParticipantId, null]) {
|
|
103
|
+
const counts = tally(key);
|
|
104
|
+
unretrieved.set(key, { ...counts, [receipt.state]: counts[receipt.state] + 1 });
|
|
105
|
+
}
|
|
106
|
+
}
|
|
90
107
|
|
|
91
108
|
return {
|
|
92
109
|
workspaceId,
|
|
@@ -114,6 +131,7 @@ export function createStatusService(ports, sessions, deliveryBindings) {
|
|
|
114
131
|
// a done record made the command and this line contradict each other.
|
|
115
132
|
intent: intents.find(intent => intent.sessionId === session.sessionId
|
|
116
133
|
&& isCurrentIntent(intent))?.summary ?? null,
|
|
134
|
+
unretrieved: tally(session.participantId),
|
|
117
135
|
})),
|
|
118
136
|
// The owner is named twice on purpose. Every command that reaches a peer
|
|
119
137
|
// takes a participant id, so a claim that gave only a session id sent the
|
|
@@ -148,6 +166,7 @@ export function createStatusService(ports, sessions, deliveryBindings) {
|
|
|
148
166
|
stale: live.filter(item => item.presence === "stale").length,
|
|
149
167
|
claims: claims.length,
|
|
150
168
|
messages: snapshot.messages.length,
|
|
169
|
+
unretrieved: tally(null),
|
|
151
170
|
},
|
|
152
171
|
};
|
|
153
172
|
}
|
|
@@ -4,6 +4,7 @@ import { computeAttention } from "./attention.mjs";
|
|
|
4
4
|
import { classifySessionPresence } from "./sessions.mjs";
|
|
5
5
|
import { assertMessageId, messagePage } from "./message-pages.mjs";
|
|
6
6
|
import { decisionView, isCurrentDecision } from "./decision-state.mjs";
|
|
7
|
+
import { offerFacts } from "./receipts.mjs";
|
|
7
8
|
|
|
8
9
|
const DEFAULT_LIMIT = 100;
|
|
9
10
|
const CURSOR = /^[0-9]{16}$/;
|
|
@@ -38,7 +39,7 @@ export function createSyncService(ports, sessions) {
|
|
|
38
39
|
}
|
|
39
40
|
if (exact) assertMessageId(input.messageId);
|
|
40
41
|
const snapshot = await store.snapshot(input.workspaceId ?? store.workspaceId,
|
|
41
|
-
{ kinds: ["message"] });
|
|
42
|
+
{ kinds: exact ? ["message", "receipt"] : ["message"] });
|
|
42
43
|
const messages = snapshot.messages.map(decisionView(snapshot.messages));
|
|
43
44
|
if (!exact) return messagePage(messages, input,
|
|
44
45
|
{ include: message => input.current !== true || isCurrentDecision(message),
|
|
@@ -47,7 +48,15 @@ export function createSyncService(ports, sessions) {
|
|
|
47
48
|
if (message === undefined) {
|
|
48
49
|
throw new AccError(EXIT.DATA, "message is not in this workspace's history");
|
|
49
50
|
}
|
|
50
|
-
|
|
51
|
+
// Beside the message rather than inside it: the router offers `items[0]`
|
|
52
|
+
// to a transport, and a sender asking "did they get it" wants every
|
|
53
|
+
// recipient's answer. `sync --scope full` already shows all of it.
|
|
54
|
+
const receipts = snapshot.receipts
|
|
55
|
+
.filter(receipt => receipt.messageId === message.messageId)
|
|
56
|
+
.sort((left, right) => left.recipientParticipantId.localeCompare(right.recipientParticipantId))
|
|
57
|
+
.map(receipt => ({ recipientParticipantId: receipt.recipientParticipantId,
|
|
58
|
+
state: receipt.state, updatedAt: receipt.updatedAt, offer: offerFacts(receipt) }));
|
|
59
|
+
return { scope: "history", view: "message", items: [message], receipts, nextCursor: null };
|
|
51
60
|
}
|
|
52
61
|
|
|
53
62
|
async function sync(input = {}) {
|
|
@@ -237,7 +237,7 @@ async function openContext({ event, adapterId, dataHome, runtime, env, deadline
|
|
|
237
237
|
// `render` is how the projected text reaches this client: before a turn it is
|
|
238
238
|
// the adapter's context envelope, at the end of one it is a continuation.
|
|
239
239
|
async function projectTurn({ binding, context, adapter, adapterId,
|
|
240
|
-
render = text => adapter.injectOutcome?.(text), activationHint = null }) {
|
|
240
|
+
render = text => adapter.injectOutcome?.(text), activationHint = null, repeats = true }) {
|
|
241
241
|
const sync = await context.service.sync({ sessionId: binding.accSessionId,
|
|
242
242
|
cursor: null, scope: "delta" });
|
|
243
243
|
|
|
@@ -251,11 +251,22 @@ async function projectTurn({ binding, context, adapter, adapterId,
|
|
|
251
251
|
// Without this the projector's peer block never runs in production: an
|
|
252
252
|
// agent sees only the obligation attention line, not the durable body that
|
|
253
253
|
// may be offered after the stdout transport succeeds.
|
|
254
|
+
// Decided before selection: a repeat is taken out of the reminder lists, so
|
|
255
|
+
// asking for one this turn cannot show is how a message would go quiet.
|
|
256
|
+
// The ceiling a team agreed on in `acc.workspace.json`, or the default when
|
|
257
|
+
// there is no config. Validated by the protocol and, until now, never read:
|
|
258
|
+
// the projector was always called with its own default.
|
|
259
|
+
const effective = effectiveCapabilities(adapter, binding);
|
|
260
|
+
const hasStructuredRenderer = typeof adapter.renderContextResult === "function";
|
|
261
|
+
const canOfferNextTurn = effective.delivery.nextTurn === true && hasStructuredRenderer;
|
|
254
262
|
const delivery = await context.service.nextTurnDelivery({
|
|
255
263
|
workspaceId: context.descriptor.id,
|
|
256
264
|
participantId: mine?.participantId,
|
|
257
|
-
exceptSessionId: binding.accSessionId
|
|
258
|
-
|
|
265
|
+
exceptSessionId: binding.accSessionId,
|
|
266
|
+
repeats: repeats && canOfferNextTurn });
|
|
267
|
+
// New bodies first, so a repeat is what the byte budget drops.
|
|
268
|
+
const messages = [...delivery.queuedMessages, ...delivery.repeatMessages];
|
|
269
|
+
const repeating = new Set(delivery.repeatMessages.map(message => message.messageId));
|
|
259
270
|
|
|
260
271
|
// Only this hook's payload selected the binding. Supply its own pair as
|
|
261
272
|
// trusted context, outside peer bodies, rather than exporting inheritable
|
|
@@ -278,16 +289,11 @@ async function projectTurn({ binding, context, adapter, adapterId,
|
|
|
278
289
|
hintFits);
|
|
279
290
|
}
|
|
280
291
|
|
|
281
|
-
// The ceiling a team agreed on in `acc.workspace.json`, or the default when
|
|
282
|
-
// there is no config. Validated by the protocol and, until now, never read:
|
|
283
|
-
// the projector was always called with its own default.
|
|
284
|
-
const effective = effectiveCapabilities(adapter, binding);
|
|
285
|
-
const hasStructuredRenderer = typeof adapter.renderContextResult === "function";
|
|
286
|
-
const canOfferNextTurn = effective.delivery.nextTurn === true && hasStructuredRenderer;
|
|
287
292
|
const projectionInput = { ...sync, messages: canOfferNextTurn ? messages : [],
|
|
288
293
|
liveOfferedMessageIds: delivery.liveOfferedMessageIds,
|
|
289
294
|
reminderMessageIds: delivery.reminderMessageIds,
|
|
290
295
|
roomMessageIds: delivery.roomMessageIds,
|
|
296
|
+
repeatOffers: delivery.repeatOffers,
|
|
291
297
|
currentParticipantId: mine?.participantId };
|
|
292
298
|
// Credentials alone cannot replace the command that reaches a queued body.
|
|
293
299
|
// If both cannot fit, preserve recovery and explain the missing owner pair.
|
|
@@ -346,7 +352,8 @@ async function projectTurn({ binding, context, adapter, adapterId,
|
|
|
346
352
|
recipientParticipantId: mine.participantId,
|
|
347
353
|
targetSessionId: binding.accSessionId, targetGeneration: binding.generation,
|
|
348
354
|
transport: "next-turn", adapterId,
|
|
349
|
-
clientVersion: binding.clientVersion
|
|
355
|
+
clientVersion: binding.clientVersion,
|
|
356
|
+
...(repeating.has(message.messageId) ? { repeat: true } : {}) }));
|
|
350
357
|
// Same again: Kimi Code shows the model a hook's raw stdout, while Gemini
|
|
351
358
|
// and Claude Code want an envelope and drop a bare string.
|
|
352
359
|
// The entry point owns the transport boundary. This handler only prepares
|
|
@@ -583,7 +590,9 @@ const HANDLERS = {
|
|
|
583
590
|
const current = await context.service.locateSession(binding.accSessionId);
|
|
584
591
|
if (current === null || current.record.state !== "open"
|
|
585
592
|
|| current.record.generation !== binding.generation) return {};
|
|
586
|
-
|
|
593
|
+
// A repeat is a body the model may already have. It rides on the next
|
|
594
|
+
// turn that happens anyway and never buys a model call of its own.
|
|
595
|
+
const turn = await projectTurn({ ...input, repeats: false,
|
|
587
596
|
render: text => adapter.continueTurnOutcome({ reason: text, payload }) });
|
|
588
597
|
return (turn.offerInputs ?? []).length === 0 ? {} : turn;
|
|
589
598
|
},
|
|
@@ -23,15 +23,17 @@ const stringList = description => ({ type: "array", items: { type: "string" }, d
|
|
|
23
23
|
export const PUBLIC_TOOLS = Object.freeze([
|
|
24
24
|
{
|
|
25
25
|
name: "acc_status",
|
|
26
|
-
description: `Read who is here, current intents and claims,
|
|
27
|
-
+ `protection level
|
|
26
|
+
description: `Read who is here, current intents and claims, the workspace's real `
|
|
27
|
+
+ `protection level, and how many messages each participant has not fetched `
|
|
28
|
+
+ `(offered is not read). ${POLLED}`,
|
|
28
29
|
inputSchema: object({}),
|
|
29
30
|
},
|
|
30
31
|
{
|
|
31
32
|
name: "acc_sync",
|
|
32
33
|
description: `Read coordination state for this workspace: roster, attention items, and `
|
|
33
34
|
+ `events since a cursor. Use scope "history" for bounded message summaries, then `
|
|
34
|
-
+ `messageId to read one complete historical message
|
|
35
|
+
+ `messageId to read one complete historical message, with every recipient's `
|
|
36
|
+
+ `receipt state, without changing receipts. `
|
|
35
37
|
+ `Scope "full" is an unbounded forensic workspace snapshot. Use acc_inbox for `
|
|
36
38
|
+ `your addressed messages. ${POLLED}`,
|
|
37
39
|
inputSchema: object({
|