@astrosheep/square 0.3.57 → 0.3.61

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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "square",
3
- "version": "0.3.57",
3
+ "version": "0.3.61",
4
4
  "description": "Native Claude Code turn-boundary delivery for Square participants",
5
5
  "author": {
6
6
  "name": "Square"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "square",
3
- "version": "0.3.57",
3
+ "version": "0.3.61",
4
4
  "description": "Shared Square activity with reliable participant attention at Codex boundaries.",
5
5
  "author": {
6
6
  "name": "Square"
package/dist/activity.js CHANGED
@@ -3,14 +3,16 @@ import fs from 'node:fs';
3
3
  import path from 'node:path';
4
4
  import { setTimeout as sleep } from 'node:timers/promises';
5
5
  import { SquareError, isSquareError, validateName } from './model.js';
6
- import { expressHintLine, renderActivityBlocked, renderActivityLimit, renderExpressNoWait, renderExpressWaiting, renderPendingFeed, joinRecoveryCommand, withPathOutput, } from './presentation.js';
7
- import { nowMs, SLEEP_MS } from './runtime.js';
6
+ import { expressHintLine, formatRefusal, renderActivityBlocked, renderActivityLimit, renderExpressNoWait, renderExpressWaiting, renderPendingFeed, joinRecoveryCommand, withPathOutput, } from './presentation.js';
7
+ import { nowMs, inSquareCount, SLEEP_MS } from './runtime.js';
8
+ import { unreadActivitySummaries } from './decisions.js';
8
9
  import { openSquare } from './square-file-adapter.js';
9
10
  import { closeOpenSquare } from './open-square.js';
10
11
  import { Square } from './square-wiring.js';
11
12
  import { activityPresentation, resolveParticipant } from './views.js';
12
13
  import { formatActivityId } from './square-core.js';
13
- import { formatTimestamp } from './time.js';
14
+ import { formatDuration } from './time.js';
15
+ import { style } from './tty-style.js';
14
16
  import { createHostLedgerPort } from './host-ledger-file-adapter.js';
15
17
  import { createDefaultWakeTransport } from './notifications.js';
16
18
  function draftDirFor(squarePath) {
@@ -49,14 +51,15 @@ export async function cmdActivity(squarePath, name, activity, resolveBody, opts)
49
51
  knownName = (await resolveParticipant(reader, name)).name;
50
52
  }
51
53
  catch (err) {
54
+ const participantCount = inSquareCount((await reader.artifact.read()).state);
52
55
  await closeOpenSquare(reader);
53
56
  if (isSquareError(err)) {
54
57
  if (err.code === 'unknown_participant' || err.code === 'invalid_args') {
55
58
  const draftPath = saveActivityDraft(squarePath, name, rawInput);
56
- process.stderr.write(err.message + '\n');
57
- process.stderr.write(`draft kept: ${draftPath}\n`);
58
- if (/^Unknown participant/.test(err.message))
59
- process.stderr.write(`${joinRecoveryCommand(squarePath, name)}\n`);
59
+ const bodyLines = [err.message, `· draft kept: ${draftPath}`];
60
+ if (/^✕ .* has never stepped into/.test(err.message))
61
+ bodyLines.push(joinRecoveryCommand(squarePath, name));
62
+ process.stderr.write(formatRefusal(squarePath, bodyLines, { participantCount }));
60
63
  process.exit(2);
61
64
  }
62
65
  process.stderr.write(err.message + '\n');
@@ -81,7 +84,7 @@ export async function cmdActivity(squarePath, name, activity, resolveBody, opts)
81
84
  const pendingPublic = before.pendingPublic;
82
85
  const pendingRoomChanges = before.pendingRoomChanges;
83
86
  try {
84
- await participant.express(body, {
87
+ const landed = await participant.express(body, {
85
88
  force,
86
89
  ...(opts.mentions === undefined ? {} : { mentions: opts.mentions }),
87
90
  ...(reach === undefined ? {} : { reach }),
@@ -95,8 +98,8 @@ export async function cmdActivity(squarePath, name, activity, resolveBody, opts)
95
98
  const hasPending = pendingPublic.length > 0 || pendingRoomChanges.length > 0;
96
99
  const pending = hasPending ? `\n\n${renderPendingFeed([...fresh.activities], [...pendingPublic], [...pendingRoomChanges], knownName, fresh.state)}` : '';
97
100
  const hint = expressHintLine(ownActCount);
98
- const confirmation = `● heads turn your way — #${ownActCount}`;
99
- const withHint = hint ? `${confirmation}\n${hint}` : confirmation;
101
+ const confirmation = `● your activity lands${style('dim', ` — #${ownActCount} · ${landed.activity.id}`)}`;
102
+ const withHint = hint ? `${confirmation}\n${style('dim', hint)}` : confirmation;
100
103
  process.stdout.write(withPathOutput(squarePath, withHint + pending, { participantCount: headerCount, held }));
101
104
  return;
102
105
  }
@@ -113,8 +116,8 @@ export async function cmdActivity(squarePath, name, activity, resolveBody, opts)
113
116
  squarePath,
114
117
  name: knownName,
115
118
  forceCommand: opts.forceCommand,
116
- activitySummaries: [],
117
- unreadRoomChanges: [...pendingRoomChanges],
119
+ activitySummaries: unreadActivitySummaries(fresh.state, knownName, nowMs()),
120
+ unreadRoomChanges: [],
118
121
  draftPath,
119
122
  participantCount: headerCount,
120
123
  held,
@@ -164,8 +167,7 @@ export async function cmdActivity(squarePath, name, activity, resolveBody, opts)
164
167
  continue;
165
168
  }
166
169
  if (error.code === 'bell_quota') {
167
- const nextAt = nowMs() + (error.facts?.retryAfterMs ?? 1);
168
- process.stdout.write(withPathOutput(squarePath, [`✕ the bell stays quiet for now`, ` · you can ring it again at ${formatTimestamp(nextAt)}`].join('\n'), { participantCount: headerCount, held }));
170
+ process.stdout.write(withPathOutput(squarePath, [`✕ the bell stays quiet for now`, ` · you can ring it again in ${formatDuration(error.facts?.retryAfterMs ?? 1)}`].join('\n'), { participantCount: headerCount, held }));
169
171
  process.exit(1);
170
172
  return;
171
173
  }
@@ -17,29 +17,29 @@ export function displayAttentionPath(squarePath) {
17
17
  : squarePath;
18
18
  }
19
19
  export function renderAttentionPreview(attention) {
20
- const attentionKind = attention.route === 'bell' ? 'bell' : 'attention';
21
- const attributes = {
22
- location: displayAttentionPath(attention.squarePath),
23
- id: formatActivityId(attention.actIndex),
24
- from: attention.actor,
25
- to: attention.recipient,
26
- kind: attentionKind,
27
- };
28
- const entries = Object.entries(attributes);
29
- const [lastKey, lastValue] = entries[entries.length - 1];
20
+ // A boundary preview only ever reaches the recipient's own session, so the
21
+ // recipient is always "you"; the name follows in parens for recognition.
22
+ const route = attention.route === 'bell'
23
+ ? `${attention.actor} rang the bell for you (${attention.recipient})`
24
+ : `${attention.actor} called your name (${attention.recipient})`;
25
+ // The fence is what keeps the body from being re-rendered by Markdown. It grows past any
26
+ // backtick run in the body so a body containing a fence cannot break out of this one.
27
+ // Everything outside the fence is the system's voice, which participant bodies cannot fake.
28
+ const fence = '`'.repeat(Math.max(3, longestBacktickRun(attention.body) + 1));
30
29
  return [
31
- '<square-activity',
32
- // The opening tag must never emit a standalone `>` line: Markdown renders it as a blockquote.
33
- ...entries.slice(0, -1).map(([key, value]) => ` ${key}="${escapeAttribute(value)}"`),
34
- ` ${lastKey}="${escapeAttribute(lastValue)}">`,
30
+ `${fence}square-activity`,
31
+ `· ${displayAttentionPath(attention.squarePath)} · ${formatActivityId(attention.actIndex)}`,
32
+ `● ${route}`,
33
+ '',
35
34
  previewAttentionBody(attention.body),
36
- '</square-activity>',
35
+ fence,
36
+ // A clipped body ends with the full command to read it all: a labeled caption, then the
37
+ // command bare on its own line so it can be copied and run as it is.
37
38
  ...(attentionBodyIsClipped(attention.body)
38
- ? [`» ${participantCommandPrefix(attention.squarePath, attention.recipient)} catch --id ${formatActivityId(attention.actIndex)}`]
39
+ ? ['· clipped — read it all:', `${participantCommandPrefix(attention.squarePath, attention.recipient)} catch --id ${formatActivityId(attention.actIndex)}`]
39
40
  : []),
40
41
  ].join('\n');
41
42
  }
42
- function escapeAttribute(value) {
43
- return value.replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
44
- .replace(/\r/g, '&#13;').replace(/\n/g, '&#10;').replace(/\t/g, '&#9;');
43
+ function longestBacktickRun(value) {
44
+ return value.match(/`+/g)?.reduce((longest, run) => Math.max(longest, run.length), 0) ?? 0;
45
45
  }
@@ -71,10 +71,10 @@ export async function automaticSessionStart(provider, sessionId, cwd, env = proc
71
71
  await closeOpenSquare(reader);
72
72
  }
73
73
  if (bindings.some((binding) => binding.session !== sessionId)) {
74
- throw new SquareError('already_joined', `${name} is already bound to another session`);
74
+ throw new SquareError('already_joined', `✕ ${name} already stands here — another session holds the name`);
75
75
  }
76
76
  if (entry.joined && !bindings.some((binding) => binding.session === sessionId)) {
77
- throw new SquareError('already_joined', `${name} is already joined by another session`);
77
+ throw new SquareError('already_joined', `✕ ${name} already stands here — another session holds the name`);
78
78
  }
79
79
  const scopedEnv = operationEnv(provider, sessionId, env);
80
80
  const claim = await claimSessionParticipant(squarePath, name, scopedEnv);
@@ -17,7 +17,7 @@ function resolveCatchName(state, requestedName) {
17
17
  validateName(requestedName);
18
18
  const known = resolveRosterName(state, requestedName);
19
19
  if (known === undefined) {
20
- throw new SquareError('invalid_args', `Unknown participant "${participantIdentity(requestedName)}".`);
20
+ throw new SquareError('invalid_args', `✕ ${participantIdentity(requestedName)} has never stepped into this square`);
21
21
  }
22
22
  return known;
23
23
  }
@@ -35,66 +35,66 @@ export function usage(command) {
35
35
  export function requireValue(args, index, flag) {
36
36
  const value = args[index + 1];
37
37
  if (value === undefined || value.startsWith('--'))
38
- fail(`Missing value for ${flag}.`);
38
+ fail(`✕ missing value for ${flag}`);
39
39
  return value;
40
40
  }
41
41
  export function parseDurationMs(value, flag) {
42
42
  const match = value.match(/^([1-9]\d*)(ms|s|m|h)$/);
43
43
  if (!match)
44
- fail(`Invalid ${flag}: expected a positive duration with unit ms, s, m, or h (for example 500ms, 30s, 3m, 1h).`);
44
+ fail(`✕ invalid ${flag}: expected a positive duration with unit ms, s, m, or h (for example 500ms, 30s, 3m, 1h)`);
45
45
  const amount = Number.parseInt(match[1], 10);
46
46
  const multiplier = { ms: 1, s: 1000, m: 60000, h: 3600000 };
47
47
  const milliseconds = amount * multiplier[match[2]];
48
48
  if (!Number.isSafeInteger(milliseconds))
49
- fail(`Invalid ${flag}: duration is too large.`);
49
+ fail(`✕ invalid ${flag}: duration is too large`);
50
50
  return milliseconds;
51
51
  }
52
52
  export function parsePositiveInteger(value, flag) {
53
53
  if (!/^[1-9]\d*$/.test(value))
54
- fail(`Invalid ${flag}: expected a positive integer.`);
54
+ fail(`✕ invalid ${flag}: expected a positive integer`);
55
55
  const parsed = Number(value);
56
56
  if (!Number.isSafeInteger(parsed))
57
- fail(`Invalid ${flag}: value is too large.`);
57
+ fail(`✕ invalid ${flag}: value is too large`);
58
58
  return parsed;
59
59
  }
60
60
  export function parseBoundedLimit(value, flag, max, retry) {
61
61
  if (value === undefined || value.startsWith('--') || !/^[1-9]\d*$/.test(value) || !Number.isSafeInteger(Number(value))) {
62
- fail(`✕ ${flag} needs a positive integer\n» ${retry}`);
62
+ fail(`✕ ${flag} needs a positive integer\n${retry}`);
63
63
  }
64
64
  const parsed = Number(value);
65
65
  if (parsed > max)
66
- fail(`✕ ${flag} is capped at ${max}\n» ${retry}`);
66
+ fail(`✕ ${flag} is capped at ${max}\n${retry}`);
67
67
  return parsed;
68
68
  }
69
69
  export function parseNonNegativeInteger(value, flag) {
70
70
  if (!/^\d+$/.test(value))
71
- fail(`Invalid ${flag}: expected a non-negative integer.`);
71
+ fail(`✕ invalid ${flag}: expected a non-negative integer`);
72
72
  const parsed = Number(value);
73
73
  if (!Number.isSafeInteger(parsed))
74
- fail(`Invalid ${flag}: value is too large.`);
74
+ fail(`✕ invalid ${flag}: value is too large`);
75
75
  return parsed;
76
76
  }
77
77
  export function parseHardCap(value) {
78
78
  if (value === 'unlimited')
79
79
  return null;
80
80
  if (!/^[1-9]\d*$/.test(value))
81
- fail('Invalid build option: --cap must be a positive integer or unlimited.');
81
+ fail('✕ invalid build option: --cap must be a positive integer or unlimited');
82
82
  const parsed = Number(value);
83
83
  if (!Number.isSafeInteger(parsed))
84
- fail('Invalid build option: --cap must be a positive integer or unlimited.');
84
+ fail('✕ invalid build option: --cap must be a positive integer or unlimited');
85
85
  return parsed;
86
86
  }
87
87
  export function parseNameList(value, flag) {
88
88
  const names = parseParticipantList(value);
89
89
  if (names.length === 0)
90
- fail(`Invalid ${flag}: expected at least one participant name.`);
90
+ fail(`✕ invalid ${flag}: expected at least one participant name`);
91
91
  for (const name of names)
92
92
  validateName(name);
93
93
  return names;
94
94
  }
95
95
  export function requireParticipant(name) {
96
96
  if (!name)
97
- fail('Missing required option: --as <name>.');
97
+ fail('✕ missing required option: --as <name>');
98
98
  validateName(name);
99
99
  return name;
100
100
  }
@@ -157,7 +157,7 @@ export async function parseGlobalArgs(rawArgs) {
157
157
  }
158
158
  const command = args[0];
159
159
  if (command === 'history' && hasExplicitName) {
160
- fail(`✕ history is an archive and does not use --as\n» ${withoutAsCommand(rawArgs)}`);
160
+ fail(`✕ history is an archive and does not use --as\n${withoutAsCommand(rawArgs)}`);
161
161
  }
162
162
  if (name === undefined)
163
163
  name = configuredName();
@@ -166,7 +166,7 @@ export async function parseGlobalArgs(rawArgs) {
166
166
  const explicitSquarePath = requestedPath !== undefined;
167
167
  const configured = configuredLocation();
168
168
  if (command !== undefined && !['--help', '-h'].includes(command) && locationIsRequired(command) && requestedPath === undefined && configured === undefined) {
169
- fail(`✕ ${command} needs a square location\n» square ls`);
169
+ fail(`✕ ${command} needs a square location\nsquare ls`);
170
170
  }
171
171
  const squarePath = requestedPath ?? configured;
172
172
  if (name === undefined && squarePath !== undefined && command !== undefined && locationIsRequired(command)) {
@@ -189,6 +189,6 @@ export function defaultContext(command, squarePath, name) {
189
189
  }
190
190
  export function requireSquarePath(context) {
191
191
  if (context.squarePath === undefined)
192
- fail(`✕ ${context.command} needs a square location\n» square ls`);
192
+ fail(`✕ ${context.command} needs a square location\nsquare ls`);
193
193
  return context.squarePath;
194
194
  }
@@ -6,6 +6,7 @@ import { cmdListSquares } from '../list.js';
6
6
  import { nameKey, parseActivityId, sameName } from '../model.js';
7
7
  import { commandPrefix, participantIdentity, renderGrepActivitiesView, renderEventCli, renderAmbientEvent, renderPresenceAnchor, truncateChars, withPathOutput, quoteShell, } from '../presentation.js';
8
8
  import { actId, nowMs } from '../runtime.js';
9
+ import { style } from '../tty-style.js';
9
10
  import { cmdStream, cmdStreamNdjson } from '../stream.js';
10
11
  import { formatRelativeTime, formatTimestamp, parseTimeOrRelative } from '../time.js';
11
12
  import { cmdWatch } from '../watch.js';
@@ -15,6 +16,8 @@ import { historyPresentation, participantsPresentation, statusPresentation } fro
15
16
  import { fail, parseBoundedLimit, parseDurationMs, parseNameList, parseNonNegativeInteger, readStdin, requireParticipant, requireSquarePath, requireValue, usage, } from './context.js';
16
17
  import { CATCH_DEFAULT_LIMIT, CATCH_MAX_LIMIT } from '../catch-decisions.js';
17
18
  const STATUS_PARTICIPANT_PREVIEW_LIMIT = 10;
19
+ /** A participant counts as here when their last sign of life sits inside this window. */
20
+ const STATUS_HERE_WINDOW_MS = 30 * 60_000;
18
21
  const HISTORY_DEFAULT_LIMIT = 10;
19
22
  const HISTORY_MAX_LIMIT = 100;
20
23
  const PARTICIPANTS_DEFAULT_LIMIT = 20;
@@ -94,10 +97,10 @@ export const catchCommand = {
94
97
  for (let index = 0; index < argv.length; index++) {
95
98
  if (argv[index] === '--id') {
96
99
  if (id !== undefined)
97
- fail('✕ catch accepts one --id\n» square catch --help');
100
+ fail('✕ catch accepts one --id\nsquare catch --help');
98
101
  const value = requireValue(argv, index, '--id');
99
102
  if (parseActivityId(value) === undefined)
100
- fail('✕ invalid --id: expected an activity id like act/12\n» square catch --help');
103
+ fail('✕ invalid --id: expected an activity id like act/12\nsquare catch --help');
101
104
  id = value;
102
105
  index += 1;
103
106
  }
@@ -121,11 +124,11 @@ export const catchCommand = {
121
124
  else if (argv[index] === '--now')
122
125
  now = true;
123
126
  else
124
- fail(`✕ catch does not know ${argv[index]}\n» square catch --help`);
127
+ fail(`✕ catch does not know ${argv[index]}\nsquare catch --help`);
125
128
  }
126
129
  if (id !== undefined) {
127
130
  if (argv.some((flag) => ['--idle', '--from', '--mention', '--limit', '--replace'].includes(flag))) {
128
- fail('✕ --id cannot be combined with --idle, --from, --mention, --limit or --replace\n» square catch --help');
131
+ fail('✕ --id cannot be combined with --idle, --from, --mention, --limit or --replace\nsquare catch --help');
129
132
  }
130
133
  return { id, now: true };
131
134
  }
@@ -271,7 +274,7 @@ function parseHistory(argv, context) {
271
274
  else if (flag === '--json')
272
275
  json = true;
273
276
  else
274
- fail(`✕ history does not know ${flag}\n» square history --help`);
277
+ fail(`✕ history does not know ${flag}\nsquare history --help`);
275
278
  }
276
279
  if (grep !== undefined && fixed !== undefined)
277
280
  fail('--grep and --fixed cannot be combined.');
@@ -359,7 +362,7 @@ function renderHistoryProjection(projection, visible, noTruncate, squarePath) {
359
362
  if (chunks.length === 0)
360
363
  return 'latest\n ○ no public activity in this view';
361
364
  if (preview !== undefined && shown.some((activity) => activity.kind === 'say' && activity.body.length > preview)) {
362
- chunks.push(`» ${commandPrefix(squarePath)} history --no-truncate`);
365
+ chunks.push(`${commandPrefix(squarePath)} history --no-truncate`);
363
366
  }
364
367
  return chunks.join('\n\n');
365
368
  }
@@ -374,7 +377,7 @@ export const historyCommand = {
374
377
  const projection = await historyPresentation(square, { ...options, order: 'asc' });
375
378
  let events = [...projection.activities];
376
379
  if (options.lastN === null && events.length > HISTORY_MAX_LIMIT) {
377
- fail(`✕ history is capped at ${HISTORY_MAX_LIMIT} activities\n» ${boundedHistoryCommand(options, squarePath)}`);
380
+ fail(`✕ history is capped at ${HISTORY_MAX_LIMIT} activities\n${boundedHistoryCommand(options, squarePath)}`);
378
381
  }
379
382
  const searching = options.grep !== undefined || options.fixed !== undefined;
380
383
  const totalMatches = searching ? events.length : 0;
@@ -403,7 +406,7 @@ export const historyCommand = {
403
406
  : allPublic.some((item) => item.index < (pageMin ?? Infinity)));
404
407
  const cursorDirection = options.afterIndex !== undefined ? '--after' : '--before';
405
408
  const cursorIndex = cursorDirection === '--after' ? Math.max(...publicEvents.map((item) => item.index)) : Math.min(...publicEvents.map((item) => item.index));
406
- const continuation = hasMore ? `\n\n» ${historyContinuationCommand(options, squarePath, cursorDirection, cursorIndex)}` : '';
409
+ const continuation = hasMore ? `\n\n${historyContinuationCommand(options, squarePath, cursorDirection, cursorIndex)}` : '';
407
410
  return withPathOutput(squarePath, output + continuation, { participantCount: projection.participantCount });
408
411
  }
409
412
  finally {
@@ -434,18 +437,19 @@ export const participantsCommand = {
434
437
  const now = nowMs();
435
438
  const participants = await participantsPresentation(square);
436
439
  const lines = participants.slice(0, intent.limit).map((participant) => {
437
- const glyph = participant.state === 'done' ? '○' : participant.presence === 'watching' ? '◎' : participant.activityCount > 0 ? '●' : '○';
440
+ const recent = participant.lastActiveAt !== undefined && now - participant.lastActiveAt <= STATUS_HERE_WINDOW_MS;
441
+ const glyph = participant.state === 'done' ? '○' : participant.presence === 'watching' ? '◎' : participant.activityCount > 0 && recent ? '●' : '○';
438
442
  const state = participant.state === 'done' ? 'done' : participant.presence === 'watching' ? 'catching' : participant.state;
439
443
  const last = participant.lastActiveAt === undefined ? '—' : formatRelativeTime(participant.lastActiveAt, now);
440
- return ` ${glyph} ${participant.name} · ${state} · ${participant.activityCount} ${participant.activityCount === 1 ? 'activity' : 'activities'} · ${last}`;
444
+ return ` ${glyph} ${participant.name}${style('dim', ` · ${state} · ${participant.activityCount} ${participant.activityCount === 1 ? 'activity' : 'activities'} · ${last}`)}`;
441
445
  });
442
446
  const participantCount = participants.filter((participant) => participant.state === 'active').length;
443
447
  const tail = participants.length <= intent.limit
444
448
  ? []
445
449
  : [
446
- ` ○ ${lines.length} of ${participants.length} participants shown`,
450
+ style('dim', ` ○ ${lines.length} of ${participants.length} participants shown`),
447
451
  ...(participants.length <= PARTICIPANTS_MAX_LIMIT ? [
448
- `» ${participantsLimitCommand(squarePath, participants.length)}`,
452
+ `${participantsLimitCommand(squarePath, participants.length)}`,
449
453
  ] : []),
450
454
  ];
451
455
  return withPathOutput(squarePath, ['participants', ...lines, ...tail].join('\n'), {
@@ -468,36 +472,55 @@ export const statusCommand = {
468
472
  const presentation = await statusPresentation(square);
469
473
  const result = presentation.status;
470
474
  const active = result.participants.filter((participant) => participant.state === 'active');
471
- const people = active.length === 0 ? [' ○ nobody in the square'] : active.slice(0, STATUS_PARTICIPANT_PREVIEW_LIMIT).map((participant) => {
472
- const glyph = participant.presence === 'watching'
473
- ? '◎'
474
- : participant.activityCount > 0 ? '●' : '○';
475
- const summary = participant.activityCount > 0
476
- ? `${participant.activityCount} ${participant.activityCount === 1 ? 'activity' : 'activities'} · ${participant.lastActiveAt === undefined
477
- ? 'just now'
478
- : formatRelativeTime(participant.lastActiveAt, result.now)}`
479
- : `quiet · ${participant.lastActiveAt === undefined ? '—' : formatRelativeTime(participant.lastActiveAt, result.now)}`;
480
- const showAttention = context.name === undefined || sameName(participant.name, context.name);
481
- const attention = !showAttention
482
- ? ''
483
- : participant.pendingMentionCount > 0
484
- ? `${participant.pendingMentionCount} attention${participant.pendingMentionCount === 1 ? '' : 's'} waiting`
485
- : participant.unreadActivityCount > 0
486
- ? `${participant.unreadActivityCount} change${participant.unreadActivityCount === 1 ? '' : 's'} waiting`
487
- : 'caught up';
488
- return ` ${glyph} ${participantIdentity(participant.name)} · ${summary}${attention === '' ? '' : ` · ${attention}`}`;
489
- });
490
- if (active.length > STATUS_PARTICIPANT_PREVIEW_LIMIT) {
491
- people.push(` ○ … ${active.length - STATUS_PARTICIPANT_PREVIEW_LIMIT} more participants`);
492
- people.push(`» ${result.participants.length <= PARTICIPANTS_MAX_LIMIT
493
- ? participantsLimitCommand(squarePath, result.participants.length)
494
- : `${commandPrefix(squarePath)} participants`}`);
475
+ const here = active.filter((participant) => participant.presence === 'watching'
476
+ || (participant.lastActiveAt !== undefined && result.now - participant.lastActiveAt <= STATUS_HERE_WINDOW_MS));
477
+ const lingering = active.length - here.length;
478
+ const orderedHere = [
479
+ ...here.filter((participant) => participant.presence === 'watching'),
480
+ ...here.filter((participant) => participant.presence !== 'watching'),
481
+ ];
482
+ const people = [];
483
+ if (active.length === 0) {
484
+ people.push(' ○ nobody in the square');
495
485
  }
496
- const cap = result.hardCap === null ? 'unlimited' : String(result.hardCap);
486
+ else {
487
+ if (orderedHere.length === 0)
488
+ people.push(' ○ nobody here right now');
489
+ people.push(...orderedHere.slice(0, STATUS_PARTICIPANT_PREVIEW_LIMIT).map((participant) => {
490
+ const glyph = participant.presence === 'watching'
491
+ ? '◎'
492
+ : participant.activityCount > 0 ? '●' : '○';
493
+ const summary = participant.presence === 'watching'
494
+ ? 'catching'
495
+ : participant.activityCount > 0
496
+ ? formatRelativeTime(participant.lastActiveAt ?? result.now, result.now)
497
+ : `quiet · ${participant.lastActiveAt === undefined ? 'just now' : formatRelativeTime(participant.lastActiveAt, result.now)}`;
498
+ const showAttention = context.name === undefined || sameName(participant.name, context.name);
499
+ const attention = !showAttention
500
+ ? ''
501
+ : participant.pendingMentionCount > 0
502
+ ? `${participant.pendingMentionCount} ${participant.pendingMentionCount === 1 ? 'mention' : 'mentions'} waiting`
503
+ : participant.unreadActivityCount > 0
504
+ ? `${participant.unreadActivityCount} change${participant.unreadActivityCount === 1 ? '' : 's'} waiting`
505
+ : 'caught up';
506
+ return ` ${glyph} ${participantIdentity(participant.name)}${style('dim', ` · ${summary}${attention === '' ? '' : ` · ${attention}`}`)}`;
507
+ }));
508
+ const unshown = Math.max(0, orderedHere.length - STATUS_PARTICIPANT_PREVIEW_LIMIT) + lingering;
509
+ if (orderedHere.length > STATUS_PARTICIPANT_PREVIEW_LIMIT) {
510
+ people.push(` ○ … ${orderedHere.length - STATUS_PARTICIPANT_PREVIEW_LIMIT} more here`);
511
+ }
512
+ if (lingering > 0)
513
+ people.push(` ○ … ${lingering} lingering`);
514
+ if (unshown > 0) {
515
+ people.push(`${result.participants.length <= PARTICIPANTS_MAX_LIMIT
516
+ ? participantsLimitCommand(squarePath, result.participants.length)
517
+ : `${commandPrefix(squarePath)} participants`}`);
518
+ }
519
+ }
520
+ const cap = result.hardCap === null ? 'unlimited' : `${result.hardCap} each`;
521
+ const holdTime = result.holdAt === undefined ? 'just now' : formatRelativeTime(result.holdAt, result.now);
497
522
  const hold = result.holdActive
498
- ? `· ${result.holdActor === undefined ? 'someone' : participantIdentity(result.holdActor)} raised a hand${result.holdReason ? ` — ${result.holdReason}` : ''} · ${result.holdAt === undefined
499
- ? 'just now'
500
- : formatRelativeTime(result.holdAt, result.now)}`
523
+ ? `· ${result.holdActor === undefined ? 'someone' : participantIdentity(result.holdActor)} raised a hand${result.holdReason ? ` — ${result.holdReason}` : ''}${style('dim', ` · ${holdTime}`)}`
501
524
  : undefined;
502
525
  const visible = result.latestAct === undefined
503
526
  ? ''
@@ -510,14 +533,31 @@ export const statusCommand = {
510
533
  const latest = visible === ''
511
534
  ? [result.latestAct === undefined
512
535
  ? ' ○ no public activity yet'
513
- : ' · latest activity is private to another participant']
536
+ : ' · the latest words were meant for other ears']
514
537
  : [` ${visible.replace(/\n/g, '\n ')}`];
538
+ // Presence events after the latest public act: otherwise the roster can say someone is
539
+ // here while the latest line shows them leaving, with nothing to reconcile the two.
540
+ const latestIndex = result.latestAct?.index ?? -1;
541
+ const stepsIn = presentation.state.acts.filter((act) => act.kind === 'join' && act.index > latestIndex);
542
+ for (const act of stepsIn.slice(-3)) {
543
+ const rejoined = presentation.state.acts.some((other) => other.kind === 'done' && other.actor === act.actor && other.index < act.index);
544
+ latest.push(style('dim', ` → ${participantIdentity(act.actor)} ${rejoined ? 'stepped back in' : 'stepped in'} · ${formatRelativeTime(act.at, result.now)}`));
545
+ }
546
+ if (stepsIn.length > 3)
547
+ latest.push(style('dim', ` → … ${stepsIn.length - 3} more stepped in`));
515
548
  if (visible.includes('more chars') && result.latestAct !== undefined) {
516
- latest.push(`» ${commandPrefix(squarePath)} history --at ${actId(result.latestAct)} -C 2 --no-truncate`);
549
+ latest.push(`${commandPrefix(squarePath)} history --at ${actId(result.latestAct)} -C 2 --no-truncate`);
517
550
  }
551
+ const counts = [
552
+ `${here.length} here`,
553
+ ...(lingering > 0 ? [`${lingering} lingering`] : []),
554
+ `${result.doneCount} done`,
555
+ `cap ${cap}`,
556
+ `throttle ${result.throttlePerMinute === undefined ? 'none' : `${result.throttlePerMinute}/min`}`,
557
+ ].join(' · ');
518
558
  const output = [
519
- `${result.activeCount} active · ${result.doneCount} done · cap ${cap} · throttle ${result.throttlePerMinute === undefined ? 'none' : `${result.throttlePerMinute}/min`}`,
520
- ...(hold === undefined ? [] : ['', hold]), '', 'around the square', ...people, '', 'latest', ...latest,
559
+ style('dim', counts),
560
+ ...(hold === undefined ? [] : ['', hold]), '', style('dim', 'around the square'), ...people, '', style('dim', 'latest'), ...latest,
521
561
  ].join('\n');
522
562
  return withPathOutput(squarePath, output, { participantCount: result.activeCount, held: result.holdActive });
523
563
  }
@@ -558,7 +598,7 @@ export const inboxCommand = {
558
598
  fail('inbox requires --for-session <session-id>.');
559
599
  const retry = inboxLimitCommand(sessionId, json);
560
600
  if (duplicateLimit)
561
- fail(`✕ inbox accepts one --limit\n» ${retry}`);
601
+ fail(`✕ inbox accepts one --limit\n${retry}`);
562
602
  const limit = hasLimit
563
603
  ? parseBoundedLimit(limitValue, '--limit', INBOX_MAX_LIMIT, retry)
564
604
  : INBOX_DEFAULT_LIMIT;
@@ -1,20 +1,44 @@
1
1
  import { helpRequest } from '../help.js';
2
2
  import { isSquareError } from '../model.js';
3
- import { joinRecoveryCommand, participantsRecoveryCommand } from '../presentation.js';
3
+ import { formatRefusal, joinRecoveryCommand, participantsRecoveryCommand } from '../presentation.js';
4
+ import { inSquareCount, nowMs } from '../runtime.js';
5
+ import { openSquare } from '../square-file-adapter.js';
6
+ import { closeOpenSquare } from '../open-square.js';
4
7
  import { defaultContext, parseGlobalArgs } from './context.js';
5
8
  import { executeRegisteredCommand, findCommand } from './registry.js';
6
- function handleSquareError(error, squarePath, name) {
9
+ async function participantCountFor(squarePath) {
10
+ try {
11
+ const square = await openSquare(squarePath, { clock: nowMs });
12
+ try {
13
+ return inSquareCount((await square.artifact.read()).state);
14
+ }
15
+ finally {
16
+ await closeOpenSquare(square);
17
+ }
18
+ }
19
+ catch {
20
+ return undefined;
21
+ }
22
+ }
23
+ async function handleSquareError(error, squarePath, name) {
7
24
  if (isSquareError(error)) {
8
- process.stderr.write(`${error.message}\n`);
9
- if (squarePath !== undefined && /^Unknown participant/.test(error.message)) {
25
+ const existing = [error.message];
26
+ if (squarePath !== undefined && /^✕ .* has never stepped into/.test(error.message)) {
10
27
  // The caller's name has never joined this square; only a join admits it.
11
28
  if (name !== undefined)
12
- process.stderr.write(`${joinRecoveryCommand(squarePath, name)}\n`);
29
+ existing.push(joinRecoveryCommand(squarePath, name));
13
30
  else
14
- process.stderr.write(`${participantsRecoveryCommand(squarePath)}\n`);
31
+ existing.push(participantsRecoveryCommand(squarePath));
32
+ }
33
+ else if (squarePath !== undefined && /^✕ .* is not standing in this square/.test(error.message)) {
34
+ existing.push(participantsRecoveryCommand(squarePath));
35
+ }
36
+ if (squarePath === undefined) {
37
+ process.stderr.write(`${existing.join('\n')}\n`);
15
38
  }
16
- else if (squarePath !== undefined && /^Unknown mention target/.test(error.message)) {
17
- process.stderr.write(`${participantsRecoveryCommand(squarePath)}\n`);
39
+ else {
40
+ const participantCount = await participantCountFor(squarePath);
41
+ process.stderr.write(formatRefusal(squarePath, existing, participantCount === undefined ? {} : { participantCount }));
18
42
  }
19
43
  process.exit(error.code === 'not_found' ? 1 : 2);
20
44
  }
@@ -45,6 +69,6 @@ export async function runCli(rawArgs = process.argv.slice(2)) {
45
69
  await executeRegisteredCommand(command, parsed.args.slice(1), defaultContext(command, parsed.squarePath, parsed.name));
46
70
  }
47
71
  catch (error) {
48
- handleSquareError(error, squarePath, requestedName);
72
+ await handleSquareError(error, squarePath, requestedName);
49
73
  }
50
74
  }