@commonlyai/cli 0.1.202 → 0.1.204

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.
@@ -200,7 +200,8 @@ class Commonly:
200
200
  if consecutive_auth_errors >= max_auth_errors:
201
201
  print(
202
202
  f"[commonly] runtime token rejected {consecutive_auth_errors}x in a row — exiting. "
203
- "The token is likely revoked; re-issue or uninstall the agent.",
203
+ "Check COMMONLY_TOKEN: it must hold the bare cm_agent_... value, not the "
204
+ "KEY=VALUE line from .commonly-env. If it is right, re-issue the token or uninstall the agent.",
204
205
  flush=True,
205
206
  )
206
207
  return
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@commonlyai/cli",
3
- "version": "0.1.202",
3
+ "version": "0.1.204",
4
4
  "license": "Apache-2.0",
5
5
  "description": "The Commonly CLI — connect agents, manage pods, iterate fast",
6
6
  "type": "module",
@@ -80,6 +80,7 @@ import {
80
80
  readPodFocus,
81
81
  } from '../lib/pod-focus.js';
82
82
  import { claimReleaseFor, ackResultFor, EMPTY_OUTPUT } from '../lib/claim-outcome.js';
83
+ import { createClaimedWorkGate } from '../lib/claimed-work-gate.js';
83
84
  import { prepareCommitAttribution } from '../lib/commit-attribution.js';
84
85
  import { detectBwrap } from '../lib/sandbox/bwrap.js';
85
86
  import { resolvePublicSandboxMode } from '../lib/sandbox/mode.js';
@@ -1536,6 +1537,7 @@ export const performRun = ({
1536
1537
  warn: (message) => log(`cascade config: ${message}`),
1537
1538
  });
1538
1539
  const cascadeGovernor = createCascadeGovernor(cascadeSettings);
1540
+ const claimedWorkGate = createClaimedWorkGate({ client });
1539
1541
  // Unconditional, because the only other cascade line on this path is the
1540
1542
  // warn callback above — which fires only when a value is BAD. Without this,
1541
1543
  // a seat running COMMONLY_CASCADE_CAP=8 logs exactly what a seat on the
@@ -1691,6 +1693,13 @@ export const performRun = ({
1691
1693
  // Recording a human trigger sets the streak to 0, so this call and the
1692
1694
  // completion-time one are idempotent with each other.
1693
1695
  if (trigger === 'human') cascadeGovernor.record(eventPodId, trigger);
1696
+ // Defer-while-claimed (TASK-640), before the cascade check so a deferred
1697
+ // broadcast spends none of the addressed grace.
1698
+ if (await claimedWorkGate.shouldDefer(eventPodId, [{ event, trigger }])) {
1699
+ log(`[${event.type}] holding a claimed row in pod ${eventPodId} — broadcast`
1700
+ + (event?.payload?.messageId ? ` ${event.payload.messageId}` : '') + ' acked without a spawn');
1701
+ return { outcome: 'no_action', reason: 'claimed-work', details: { messageId: event?.payload?.messageId || null } };
1702
+ }
1694
1703
  const admission = cascadeGovernor.admit(eventPodId, trigger, event.type, event.payload);
1695
1704
  if (!admission.allowed) {
1696
1705
  // Name the MESSAGE, not just the pod. Without this the refusal is silent at
@@ -2743,6 +2752,17 @@ export const performRun = ({
2743
2752
  const trigger = triggers.includes('human') ? 'human'
2744
2753
  : (triggers.includes('agent') ? 'agent' : 'unknown');
2745
2754
  if (trigger === 'human') cascadeGovernor.record(eventPodId, trigger);
2755
+ if (await claimedWorkGate.shouldDefer(eventPodId, wakingEntries.map((entry, index) => ({
2756
+ event: entry.event, trigger: triggers[index],
2757
+ })))) {
2758
+ log(`[inbox.batch] holding a claimed row in pod ${eventPodId} — ${wakingEntries.length} broadcast`
2759
+ + `${wakingEntries.length === 1 ? '' : 's'} acked without a spawn`);
2760
+ // Digests are left as the cascade refusal leaves them: unread.
2761
+ for (const entry of wakingEntries) {
2762
+ entry.result = { outcome: 'no_action', reason: 'claimed-work', details: { messageId: entry.event.payload?.messageId || null } };
2763
+ }
2764
+ return { entries };
2765
+ }
2746
2766
  const admissionEvent = wakingEntries.find((entry) => (
2747
2767
  ADDRESSED_EVENT_TYPES.has(entry.event.type) || entry.event.payload?.addressed === true
2748
2768
  ))
@@ -3354,7 +3374,11 @@ const SDK_SOURCES = {
3354
3374
  botSrc: () => sdkAsset('python/bot.py', join('hello-world-python', 'bot.py')),
3355
3375
  sdkOut: 'commonly.py',
3356
3376
  botOut: (name) => `${name}.py`,
3357
- runHint: (name) => `COMMONLY_TOKEN=$(cat .commonly-env) python3 ${name}.py`,
3377
+ // .commonly-env is already KEY=VALUE. Sourcing it under `set -a` exports
3378
+ // the key as written and keeps the token out of argv (`env $(cat …)` would
3379
+ // put it on the command line). `COMMONLY_TOKEN=$(cat …)` would set the
3380
+ // variable to the literal `COMMONLY_TOKEN=cm_agent_…` and the bot would 401.
3381
+ runHint: (name) => `(set -a; . ./.commonly-env; exec python3 ${name}.py)`,
3358
3382
  },
3359
3383
  };
3360
3384
 
@@ -3409,6 +3433,9 @@ export const performInit = async ({
3409
3433
  });
3410
3434
  const installation = installResult.installation || installResult;
3411
3435
  const instanceId = installation.instanceId || 'default';
3436
+ // Same rule as `attach`: the mention handle is the instanceId when there is
3437
+ // one, otherwise the registry agentName.
3438
+ const mentionHandle = instanceId !== 'default' ? instanceId : (installation.agentName || agentName);
3412
3439
 
3413
3440
  let runtimeToken = installResult.runtimeToken;
3414
3441
  if (!runtimeToken) {
@@ -3434,6 +3461,7 @@ export const performInit = async ({
3434
3461
  return {
3435
3462
  installation,
3436
3463
  instanceId,
3464
+ mentionHandle,
3437
3465
  runtimeToken,
3438
3466
  files: {
3439
3467
  sdk: sdkOutPath,
@@ -4171,6 +4199,7 @@ Docs:
4171
4199
  console.log('\nNext:');
4172
4200
  console.log(` 1. Edit ${opts.name}.py to handle events.`);
4173
4201
  console.log(` 2. Run: ${result.runHint}`);
4202
+ console.log(` 3. In pod ${opts.pod}, @mention ${result.mentionHandle} to talk to it.`);
4174
4203
  } catch (err) {
4175
4204
  console.error(`Init failed: ${err.message}`);
4176
4205
  process.exit(1);
@@ -0,0 +1,73 @@
1
+ // Defer-while-claimed (TASK-640). While a seat holds a claimed board row in a
2
+ // pod, a broadcast wake there that no person sent and that does not concern
3
+ // its row is acked without a spawn. Measured on one seat, 2026-10-30: most of
4
+ // its hourly wake-mode turns were pod broadcasts while it held one row, and
5
+ // most of those ended in NO_REPLY.
6
+ //
7
+ // Mentions, DMs, human posts, heartbeats and digests are never deferred, and
8
+ // neither is a broadcast about a row the seat holds. Every failure fails OPEN:
9
+ // a kernel that does not mark `claimedByMe`, or a board read that errors,
10
+ // means nothing is deferred.
11
+
12
+ export const CLAIMED_WORK_TTL_MS = 60_000;
13
+
14
+ // Pure: may this waking event wait while the seat holds `claimedTaskIds`?
15
+ // `trigger` is classifyTrigger's verdict for the event.
16
+ export const isDeferrableWhileClaimed = (event, trigger, claimedTaskIds) => {
17
+ if (!event || event.deliveryMode === 'digest') return false;
18
+ if (event.type !== 'message.posted') return false;
19
+ const payload = event.payload || {};
20
+ if (payload.addressed === true || payload.repliesToYourMessage === true) return false;
21
+ // A lease warning is about the seat's own row, though it names it differently.
22
+ if (payload.leaseWarningTaskId) return false;
23
+ // A board wake with a reason is a change the seat must see: a dependency of
24
+ // its row finished, an edit to its row folded into another row's wake (so
25
+ // taskId names the first row), or an agent assigned it a new row. Only the
26
+ // reasonless found-work nudge may wait.
27
+ if (payload.boardWake === true && payload.wakeReason) return false;
28
+ if (trigger !== 'agent' && trigger !== 'kernel') return false;
29
+ if (payload.taskId && claimedTaskIds?.has(String(payload.taskId))) return false;
30
+ return true;
31
+ };
32
+
33
+ export const createClaimedWorkGate = ({ client, ttlMs = CLAIMED_WORK_TTL_MS, now = () => Date.now() } = {}) => {
34
+ const cache = new Map();
35
+
36
+ // The ids of this seat's claimed rows in `podId`, or null when it holds none
37
+ // or the board cannot say.
38
+ const claimedTaskIds = async (podId) => {
39
+ if (!client || !podId) return null;
40
+ const cached = cache.get(podId);
41
+ if (cached && now() - cached.at < ttlMs) return cached.ids;
42
+ let ids = null;
43
+ try {
44
+ const { tasks = [] } = await client.get(`/api/v1/tasks/${podId}`, { status: 'claimed' });
45
+ // A lapsed lease still carries claimedBy; only a live one holds the seat.
46
+ const mine = tasks.filter((task) => task?.claimedByMe === true && task?.leaseState === 'held')
47
+ .map((task) => String(task.taskId));
48
+ ids = mine.length > 0 ? new Set(mine) : null;
49
+ } catch {
50
+ ids = null;
51
+ }
52
+ cache.set(podId, { at: now(), ids });
53
+ return ids;
54
+ };
55
+
56
+ // True when every waking event in the group may wait. An empty group is
57
+ // never deferred.
58
+ const shouldDefer = async (podId, wakes) => {
59
+ if (!Array.isArray(wakes) || wakes.length === 0) return false;
60
+ // Only read the board when the group could be deferred at all. A group
61
+ // that can't be deferred spawns a turn, and that turn may complete or
62
+ // release the row, so the next read must not trust the cached claim.
63
+ if (!wakes.every(({ event, trigger }) => isDeferrableWhileClaimed(event, trigger, null))) {
64
+ cache.delete(podId);
65
+ return false;
66
+ }
67
+ const ids = await claimedTaskIds(podId);
68
+ if (!ids) return false;
69
+ return wakes.every(({ event, trigger }) => isDeferrableWhileClaimed(event, trigger, ids));
70
+ };
71
+
72
+ return { shouldDefer };
73
+ };