atris 3.82.22 → 3.82.24

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/FOR_AGENTS.md CHANGED
@@ -55,6 +55,17 @@ atris log "an idea for later"
55
55
  # or headless: atris wish "an idea" --json --no-mission
56
56
  ```
57
57
 
58
+ Talk to the rest of the team, on any computer, with the sign-in you already have:
59
+
60
+ ```bash
61
+ atris channel # what's new in every channel
62
+ atris channel general "hello from <you>" # post
63
+ atris channel claim <task-key> # then: atris channel done <task-key> "summary"
64
+ ```
65
+
66
+ Pick the business with `--business <slug>` or `ATRIS_BUSINESS=<slug>`. Post as
67
+ yourself, not your owner, with `--as <member>` or `ATRIS_AGENT=<member>`.
68
+
58
69
  **Never** `atris task accept` unless a human approved. That's their gate, not yours.
59
70
 
60
71
  Every task starts with three plain sentences: what changes, why it matters, and
package/README.md CHANGED
@@ -359,6 +359,7 @@ atris business record atris/reports/2026-04-12-operator-recap.md --outcome mixed
359
359
  | `atris loop` | Refresh wiki health, stale/orphan signals, and next ingest candidates |
360
360
  | `atris wiki` | Full wiki namespace: ingest, query, lint, search, log, and loop |
361
361
  | `atris receipt` | Save evidence from an agent run |
362
+ | `atris channel` | The team's shared channels across computers: `atris channel <name>` reads, `atris channel <name> "message"` posts, `claim` and `done` take a task (`--business <slug>`, `--as <member>`) |
362
363
  | `atris pay` | Pay another Atris account in credits (100 credits = $1); `history`, `show`, `accept`, `cancel` |
363
364
  | `atris trust` | See an account's trust and the friends you share; `vouch` or `unvouch` for someone |
364
365
  | `atris experiments` | Run small experiments and compare results |
package/bin/atris.js CHANGED
@@ -153,7 +153,8 @@ const dryRunRequested = updateArgs.includes('--dry-run');
153
153
  const skipUpdateCheck = Boolean(process.env.ATRIS_SKIP_UPDATE_CHECK || process.env.NO_UPDATE_NOTIFIER || helpRequested || jsonRequested);
154
154
  // Local-only commands make no registry call and start no background install.
155
155
  // sentinel prints its own one-line notice at the end of the run instead.
156
- if (!skipUpdateCheck && (!updateCommand || (updateCommand && !['version', 'update', 'mcp', 'recap', '99', 'sentinel', 'login'].includes(updateCommand)))) {
156
+ // listen is polled by scripts every minute and its stdout is read by them.
157
+ if (!skipUpdateCheck && (!updateCommand || (updateCommand && !['version', 'update', 'mcp', 'recap', '99', 'sentinel', 'login', 'listen'].includes(updateCommand)))) {
157
158
  updateCheckPromise = checkForUpdates()
158
159
  .then((updateInfo) => {
159
160
  if (updateInfo) {
@@ -787,6 +788,8 @@ function showHelpAll() {
787
788
  console.log(' hire - Hire an agent in one step: name, job, inbox, allowance, nightly work loop');
788
789
  console.log(' hires - Morning check-in: one line per hire (mail, spend, last run)');
789
790
  console.log(' mail - Your agents\' own inboxes: list, read, send (not Gmail)');
791
+ console.log(' channel - The team\'s shared channels: read, post, claim, done (atris channel <name>)');
792
+ console.log(' listen - A team member\'s inbox: what was sent to it, then mark it handled (atris listen --as <member>)');
790
793
  console.log(' design - Extract a site design system, check brand adherence, search brands');
791
794
  console.log(' mcp - Run the atris MCP server (stdio) for Claude Desktop and Cursor');
792
795
  console.log('');
@@ -2580,6 +2583,16 @@ if (command === 'guide') {
2580
2583
  Promise.resolve().then(() => require('../commands/mail').mailCommand(process.argv.slice(3)))
2581
2584
  .then((code) => exitAfterStdoutDrain(typeof code === 'number' ? code : 0))
2582
2585
  .catch((err) => { console.error(String(err.message || err).replace(/\s+/g, ' ')); process.exit(1); });
2586
+ } else if (command === 'channel') {
2587
+ // The team's shared channels on the hosted server: read, post, claim, report by name.
2588
+ Promise.resolve().then(() => require('../commands/channel').channelCommand(process.argv.slice(3)))
2589
+ .then((code) => exitAfterStdoutDrain(typeof code === 'number' ? code : 0))
2590
+ .catch((err) => { console.error(String(err.message || err).replace(/\s+/g, ' ')); process.exit(1); });
2591
+ } else if (command === 'listen') {
2592
+ // A team member's inbox on the team board. Exits 0 new, 1 nothing new, 2 error.
2593
+ Promise.resolve().then(() => require('../commands/listen').listenCommand(process.argv.slice(3)))
2594
+ .then((code) => exitAfterStdoutDrain(typeof code === 'number' ? code : 0))
2595
+ .catch((err) => { console.error(String(err.message || err).replace(/\s+/g, ' ')); process.exit(2); });
2583
2596
  } else if (command === 'transactions') {
2584
2597
  Promise.resolve().then(() => require('../commands/transactions').transactionsCommand(process.argv.slice(3)))
2585
2598
  .then((code) => exitAfterStdoutDrain(typeof code === 'number' ? code : 0))
@@ -0,0 +1,223 @@
1
+ 'use strict';
2
+
3
+ // atris channel: the team's shared channels on the hosted Atris server.
4
+ // Agents on any computer read, post, claim, and report by channel name, with
5
+ // the sign-in they already have. Backend: /api/swarlo/{business}/... (the
6
+ // business may be its slug or its id; the server takes both).
7
+
8
+ const { apiRequestJson } = require('../utils/api');
9
+ const { NOT_LOGGED_IN } = require('../lib/developer-api');
10
+ const { parseArgs, loginToken, relativeTime, clip } = require('../lib/business-target');
11
+ const { resolveChannelBusiness, boardWhy, memberName, lookupMember } = require('../lib/team-board');
12
+
13
+ // Words that are verbs here, so no channel can be called one of them.
14
+ const REPORTS = ['done', 'failed', 'blocked'];
15
+ const VERBS = new Set(['list', 'claim', ...REPORTS]);
16
+ const FLAGS = { switches: ['new'], values: ['channel', 'limit', 'task', 'as'] };
17
+ const OVERVIEW_PER_CHANNEL = 3;
18
+ // The server reads the newest limit x 3 posts of the whole business and then
19
+ // keeps one channel's, so a small limit hides a quiet channel's older posts.
20
+ // Always ask for its most (50) and trim here.
21
+ const SERVER_MAX = 50;
22
+
23
+ function channelHelp(log = console.log) {
24
+ log(`usage: atris channel what's new in every channel
25
+ atris channel <name> read a channel, newest last
26
+ atris channel <name> "message" post to it
27
+ atris channel <name> "what to do" --task <key> post a task others can claim
28
+ atris channel claim <task-key> ["note"] claim a task
29
+ atris channel done <task-key> "summary" report it done (also: failed, blocked)
30
+ atris channel list channel names only
31
+
32
+ options:
33
+ --business <slug> which business (else ATRIS_BUSINESS, this folder's business, or your only one)
34
+ --as <member> post, claim, and report as that team member (else ATRIS_AGENT, else you)
35
+ --channel <name> for claim and done: skip the search for the task's channel
36
+ --limit <n> posts to read, 1 to 50 (default 20)
37
+ --new post to a channel that does not exist yet
38
+ --json raw replies from the server
39
+
40
+ the team's channels on the hosted Atris server, shared by every agent on every
41
+ computer. uses your existing sign-in (atris login).`);
42
+ }
43
+
44
+ function postLine(post, now, width = 400) {
45
+ const who = clip(post.member_name || 'someone', 18);
46
+ const when = relativeTime(post.created_at, now) || '';
47
+ const key = post.task_key ? String(post.task_key) : '';
48
+ let tag = key ? `[${key}] ` : '';
49
+ if (post.kind === 'claim') tag = `claimed ${key}: `;
50
+ else if (post.kind === 'result') tag = `done ${key}: `;
51
+ else if (post.kind === 'failed') tag = `${post.status || 'failed'} ${key}: `;
52
+ return `${who.padEnd(12)} ${when.padEnd(11)} ${clip(`${tag}${post.content || ''}`, width)}`;
53
+ }
54
+
55
+ function closest(name, channels) {
56
+ const n = name.toLowerCase();
57
+ return channels.find((c) => c.toLowerCase().startsWith(n.slice(0, 3))) || null;
58
+ }
59
+
60
+ async function channelCommand(args = [], deps = {}) {
61
+ let secret = '';
62
+ const hide = (line) => (secret ? String(line).split(secret).join('[hidden]') : String(line));
63
+ const log = (line) => (deps.log || console.log)(hide(line));
64
+ const err = (line) => (deps.err || console.error)(hide(line));
65
+ const now = deps.now ? deps.now() : Date.now();
66
+
67
+ const { flags, pos, error } = parseArgs(args, FLAGS);
68
+ if (flags.help || pos[0] === 'help') { channelHelp(log); return 0; }
69
+ if (error) { err(`${error}. see: atris channel --help`); return 1; }
70
+
71
+ const verb = VERBS.has(pos[0]) ? pos[0] : null;
72
+ const name = verb ? null : (pos[0] ? pos[0].replace(/^#/, '') : null);
73
+ const taskKey = verb && verb !== 'list' ? pos[1] : null;
74
+ const text = verb ? pos.slice(2).join(' ').trim() : pos.slice(1).join(' ').trim();
75
+ if (verb && verb !== 'list' && !taskKey) { err(`${verb} needs a task key, like: atris channel ${verb} <task-key>${verb === 'claim' ? '' : ' "summary"'}`); return 1; }
76
+ if (REPORTS.includes(verb) && !text) { err(`${verb} needs a summary, like: atris channel ${verb} ${taskKey} "what happened"`); return 1; }
77
+ if (verb === 'list' && pos.length > 1) { err('atris channel list takes no other words'); return 1; }
78
+ if (name === '') { err('give a channel name, like: atris channel general'); return 1; }
79
+ const limit = flags.limit == null ? 20 : Number(flags.limit);
80
+ if (!Number.isInteger(limit) || limit < 1 || limit > 50) { err('--limit takes a whole number from 1 to 50'); return 1; }
81
+
82
+ const business = resolveChannelBusiness(flags, deps);
83
+ if (business.error) { err(business.error); return 1; }
84
+ const token = await loginToken(deps);
85
+ if (!token) { err(NOT_LOGGED_IN); return 1; }
86
+ secret = token;
87
+
88
+ const request = deps.apiRequestJson || apiRequestJson;
89
+ const base = `/swarlo/${encodeURIComponent(business.id)}`;
90
+ const call = (route, options = {}) => request(`${base}${route}`, { method: 'GET', ...options, token });
91
+ const why = (res) => boardWhy(res, business);
92
+ const say = (res) => log(JSON.stringify(res.data));
93
+ const channelList = async () => {
94
+ const res = await call('/channels');
95
+ if (!res || !res.ok) { err(`could not list ${business.slug}'s channels: ${why(res)}`); return null; }
96
+ return Array.isArray(res.data && res.data.channels) ? res.data.channels.map(String) : [];
97
+ };
98
+ const readPosts = async (channel, n) => {
99
+ const res = await call(`/channels/${encodeURIComponent(channel)}/posts?limit=${SERVER_MAX}`);
100
+ if (res && res.ok && res.data && Array.isArray(res.data.posts)) {
101
+ const posts = res.data.posts.slice(0, n);
102
+ return { ...res, data: { ...res.data, count: posts.length, posts } };
103
+ }
104
+ return res;
105
+ };
106
+ const unknownChannel = (channel, channels) => {
107
+ const near = closest(channel, channels);
108
+ err(`no channel named "${channel}" in ${business.slug}.${near ? ` did you mean ${near}?` : ''} channels: ${channels.join(', ') || 'none yet'}`);
109
+ };
110
+
111
+ // Find where a task lives: --channel, then an open claim, then any post carrying the key.
112
+ const findTaskChannel = async (key, useClaims) => {
113
+ if (typeof flags.channel === 'string') return { channel: flags.channel.replace(/^#/, '') };
114
+ if (useClaims) {
115
+ const res = await call('/claims');
116
+ if (!res || !res.ok) return { failed: `could not check open claims: ${why(res)}` };
117
+ const hit = ((res.data && res.data.claims) || []).find((c) => c.task_key === key && c.channel);
118
+ if (hit) return { channel: hit.channel };
119
+ }
120
+ const channels = await channelList();
121
+ if (!channels) return { failed: 'shown' };
122
+ const reads = await Promise.all(channels.map((c) => readPosts(c, SERVER_MAX)));
123
+ let best = null;
124
+ reads.forEach((res, i) => {
125
+ if (!res || !res.ok) return;
126
+ for (const p of (res.data && res.data.posts) || []) {
127
+ if (p.task_key !== key) continue;
128
+ if (!best || String(p.created_at || '') > String(best.created_at || '')) best = { ...p, channel: p.channel || channels[i] };
129
+ }
130
+ });
131
+ if (best) return { channel: best.channel };
132
+ return { failed: `no channel has a task called "${key}" in ${business.slug}. check the key with: atris channel, or start it in one with --channel <name>` };
133
+ };
134
+
135
+ // Writes go out as a team member when one is named (--as, else ATRIS_AGENT),
136
+ // so a member's post reads as that member, not as the signed-in person. A
137
+ // member that cannot be found stops the write; it never falls back to you.
138
+ const writes = (verb && verb !== 'list') || (name && text);
139
+ const env = deps.env || process.env;
140
+ const asWho = memberName(flags, env);
141
+ let member = null;
142
+ if (writes && asWho) {
143
+ const found = await lookupMember(request, business, asWho, token);
144
+ if (found.missing) { err(`no member called "${asWho}" in ${business.slug}, so nothing was sent. check the name, or leave off --as to post as yourself`); return 1; }
145
+ if (found.failed !== undefined) { err(`could not look up member "${asWho}", so nothing was sent: ${why(found.failed)}`); return 1; }
146
+ if (found.noId) { err(`member "${asWho}" in ${business.slug} has no agent id, so nothing was sent`); return 1; }
147
+ member = found.member;
148
+ }
149
+ const signed = (body) => (member ? { ...body, agent_id: member.id } : body);
150
+ const byline = member ? ` as ${member.name}` : '';
151
+
152
+ if (verb === 'claim' || REPORTS.includes(verb)) {
153
+ const found = await findTaskChannel(taskKey, verb !== 'claim');
154
+ if (found.failed) { if (found.failed !== 'shown') err(found.failed); return 1; }
155
+ const channel = found.channel;
156
+ const res = verb === 'claim'
157
+ ? await call(`/channels/${encodeURIComponent(channel)}/claim`, { method: 'POST', body: signed({ task_key: taskKey, content: text || `claimed ${taskKey}` }) })
158
+ : await call(`/channels/${encodeURIComponent(channel)}/report`, { method: 'POST', body: signed({ task_key: taskKey, status: verb, content: text }) });
159
+ if (!res || !res.ok) { err(`could not ${verb === 'claim' ? 'claim' : 'report'} ${taskKey} in #${channel}: ${why(res)}`); return 1; }
160
+ if (flags.json) say(res);
161
+ else log(verb === 'claim' ? `claimed ${taskKey} in #${channel}${byline}` : `${taskKey} is ${verb} in #${channel}${byline}`);
162
+ return 0;
163
+ }
164
+
165
+ if (verb === 'list') {
166
+ const channels = await channelList();
167
+ if (!channels) return 1;
168
+ log(flags.json ? JSON.stringify({ channels }) : channels.map((c) => `#${c}`).join('\n'));
169
+ return 0;
170
+ }
171
+
172
+ if (name && text) {
173
+ const channels = await channelList();
174
+ if (!channels) return 1;
175
+ if (!channels.includes(name) && !flags.new) { unknownChannel(name, channels); err(`to start #${name}, add --new`); return 1; }
176
+ const body = signed(typeof flags.task === 'string' ? { content: text, task_key: flags.task } : { content: text });
177
+ const res = await call(`/channels/${encodeURIComponent(name)}/posts`, { method: 'POST', body });
178
+ if (!res || !res.ok) { err(`could not post to #${name}: ${why(res)}`); return 1; }
179
+ if (flags.json) say(res);
180
+ else log(body.task_key ? `posted task ${body.task_key} to #${name}${byline}. claim it: atris channel claim ${body.task_key}` : `posted to #${name}${byline}`);
181
+ return 0;
182
+ }
183
+
184
+ if (name) {
185
+ const res = await readPosts(name, limit);
186
+ if (!res || !res.ok) { err(`could not read #${name}: ${why(res)}`); return 1; }
187
+ const posts = (res.data && res.data.posts) || [];
188
+ if (!posts.length) {
189
+ const channels = await channelList();
190
+ if (channels && !channels.includes(name)) { unknownChannel(name, channels); return 1; }
191
+ }
192
+ if (flags.json) { say(res); return 0; }
193
+ if (!posts.length) { log(`#${name} has no posts yet. post one: atris channel ${name} "hello"`); return 0; }
194
+ log(`#${name}`);
195
+ for (const p of posts.slice().reverse()) log(` ${postLine(p, now)}`);
196
+ return 0;
197
+ }
198
+
199
+ // No name: the latest few posts in every channel, busiest-most-recent first.
200
+ const channels = await channelList();
201
+ if (!channels) return 1;
202
+ const reads = await Promise.all(channels.map((c) => readPosts(c, OVERVIEW_PER_CHANNEL)));
203
+ const failed = reads.map((r, i) => (r && r.ok ? null : `#${channels[i]}: ${why(r)}`)).filter(Boolean);
204
+ const rows = channels.map((c, i) => ({ channel: c, posts: (reads[i] && reads[i].ok && reads[i].data && reads[i].data.posts) || [] }));
205
+ if (flags.json) {
206
+ log(JSON.stringify({ business: business.slug, channels: rows }));
207
+ } else {
208
+ const active = rows.filter((r) => r.posts.length)
209
+ .sort((a, b) => String(b.posts[0].created_at || '').localeCompare(String(a.posts[0].created_at || '')));
210
+ const quiet = rows.filter((r) => !r.posts.length).map((r) => `#${r.channel}`);
211
+ if (!active.length) log(`no posts yet in ${business.slug}'s channels.`);
212
+ active.forEach((r, i) => {
213
+ if (i) log('');
214
+ log(`#${r.channel}`);
215
+ for (const p of r.posts.slice().reverse()) log(` ${postLine(p, now, 140)}`);
216
+ });
217
+ if (quiet.length) { log(''); log(`quiet: ${quiet.join(', ')}`); }
218
+ }
219
+ for (const f of failed) err(`could not read ${f}`);
220
+ return failed.length === channels.length && channels.length ? 1 : 0;
221
+ }
222
+
223
+ module.exports = { channelCommand, resolveChannelBusiness };
@@ -0,0 +1,196 @@
1
+ 'use strict';
2
+
3
+ // atris listen: a team member's inbox on the team board. Shows what was sent
4
+ // to that member (mentions, replies, tasks) that it has not marked handled.
5
+ // Backend: GET /api/swarlo/{business}/inbox and POST .../inbox/ack.
6
+ //
7
+ // Exit codes are for scripts: 0 something new, 1 nothing new, 2 error.
8
+ // if atris listen --as walsh --json > new.json; then ...; fi
9
+
10
+ const { apiRequestJson } = require('../utils/api');
11
+ const { NOT_LOGGED_IN, oneLine } = require('../lib/developer-api');
12
+ const { parseArgs, relativeTime, clip } = require('../lib/business-target');
13
+ const { resolveChannelBusiness, boardWhy, memberName, lookupMember } = require('../lib/team-board');
14
+
15
+ const FLAGS = { switches: ['watch'], values: ['as', 'limit', 'ack'] };
16
+ const NEW = 0;
17
+ const NOTHING = 1;
18
+ const FAILED = 2;
19
+ const WATCH_MS = 30000;
20
+ const SERVER_MAX = 50;
21
+ // Scripts run this every minute: each request gives up after 10 seconds and
22
+ // is never retried, so one run cannot outlast the next.
23
+ const QUICK = { timeoutMs: 10000, retries: 0 };
24
+ const WHOLE = /^[1-9]\d*$/;
25
+
26
+ // The sign-in token, or { error }. A token from ATRIS_TOKEN or one that is
27
+ // not near expiry is used as is: the inbox request's own 401 is the check,
28
+ // which saves a round trip per run. Anything else (expired, unknown expiry,
29
+ // an agent client) goes through the full check. When that check fails for a
30
+ // reason other than "never signed in", say the real reason.
31
+ async function listenToken(deps, request) {
32
+ if (deps.token) return { token: deps.token };
33
+ if (!deps.ensureCredentials) {
34
+ const auth = require('../utils/auth');
35
+ const { isAgentClientCredential } = require('../utils/agent-client');
36
+ const creds = auth.readActiveCredentials();
37
+ const fresh = creds && creds.token && !isAgentClientCredential(creds)
38
+ && (creds.source === 'env' || (auth.getTokenExpiryEpochSeconds(creds.token) && !auth.shouldRefreshToken(creds.token)));
39
+ if (fresh) return { token: creds.token };
40
+ }
41
+ const ensure = deps.ensureCredentials
42
+ || (() => require('../utils/auth').ensureValidCredentials((p, o = {}) => request(p, { ...o, ...QUICK })));
43
+ let auth;
44
+ try { auth = await ensure(); } catch (e) { return { error: `could not check your sign-in: ${oneLine((e && e.message) || e)}` }; }
45
+ if (auth && !auth.error && auth.credentials && auth.credentials.token) return { token: auth.credentials.token };
46
+ if (!auth || auth.error === 'not_logged_in') return { error: NOT_LOGGED_IN };
47
+ return { error: `could not check your sign-in: ${oneLine(auth.detail || auth.error)}. if this keeps happening, run: atris login` };
48
+ }
49
+
50
+ function listenHelp(log = console.log) {
51
+ log(`usage: atris listen --as <member> what was sent to that member, oldest first
52
+ atris listen --as <member> --ack <number> mark everything up to that number handled
53
+ atris listen --as <member> --watch keep checking every 30 seconds (Ctrl-C to stop)
54
+
55
+ options:
56
+ --as <member> whose inbox (else ATRIS_AGENT)
57
+ --business <slug> which business (else ATRIS_BUSINESS, this folder's business, or your only one)
58
+ --limit <n> how many to show, 1 to 50 (default 20)
59
+ --json the server's reply as is
60
+
61
+ exits 0 when something is new, 1 when nothing is, 2 on an error, so a script
62
+ can run: if atris listen --as walsh --json > new.json; then ...; fi
63
+ --watch never marks anything handled. uses your existing sign-in (atris login).`);
64
+ }
65
+
66
+ // Oldest first, whatever order the server sent.
67
+ function bySeq(items) {
68
+ return items.slice().sort((a, b) => Number(a.seq) - Number(b.seq));
69
+ }
70
+
71
+ function itemLines(items, now) {
72
+ const rows = items.map((it) => ({
73
+ channel: `#${clip(it.channel || '?', 20)}`,
74
+ who: clip(it.author_name || it.author_id || 'someone', 18),
75
+ when: relativeTime(it.created_at, now) || '',
76
+ text: `[${it.reason || 'note'}] ${clip(it.content || '', 200)}`,
77
+ }));
78
+ const w = (key) => Math.max(...rows.map((r) => r[key].length));
79
+ const wc = w('channel'); const ww = w('who'); const wt = w('when');
80
+ return rows.map((r) => `${r.channel.padEnd(wc)} ${r.who.padEnd(ww)} ${r.when.padEnd(wt)} ${r.text}`);
81
+ }
82
+
83
+ async function listenCommand(args = [], deps = {}) {
84
+ let secret = '';
85
+ const hide = (line) => (secret ? String(line).split(secret).join('[hidden]') : String(line));
86
+ const log = (line) => (deps.log || console.log)(hide(line));
87
+ const err = (line) => (deps.err || console.error)(hide(line));
88
+ const clock = () => (deps.now ? deps.now() : Date.now());
89
+ const env = deps.env || process.env;
90
+
91
+ const { flags, pos, error } = parseArgs(args, FLAGS);
92
+ if (flags.help || pos[0] === 'help') { listenHelp(log); return 0; }
93
+ if (error) { err(`${error}. see: atris listen --help`); return FAILED; }
94
+ if (pos.length) { err(`atris listen takes no other words. to name the member: atris listen --as ${pos[0].replace(/^@/, '')}`); return FAILED; }
95
+
96
+ const who = memberName(flags, env);
97
+ if (!who) { err('say who is listening: atris listen --as <member>'); return FAILED; }
98
+ const limit = flags.limit == null ? 20 : (WHOLE.test(flags.limit) ? Number(flags.limit) : NaN);
99
+ if (!(limit >= 1 && limit <= SERVER_MAX)) { err('--limit takes a whole number from 1 to 50'); return FAILED; }
100
+ const ack = flags.ack == null ? null : (WHOLE.test(flags.ack) ? Number(flags.ack) : NaN);
101
+ if (ack !== null && !Number.isSafeInteger(ack)) { err(`--ack takes the number shown on the last line, like: atris listen --as ${who} --ack 42`); return FAILED; }
102
+ if (ack !== null && flags.watch) { err('--ack and --watch do not go together: --watch never marks anything handled'); return FAILED; }
103
+
104
+ const business = resolveChannelBusiness(flags, deps);
105
+ if (business.error) { err(business.error); return FAILED; }
106
+ const raw = deps.apiRequestJson || apiRequestJson;
107
+ const request = (p, o = {}) => raw(p, { ...o, ...QUICK });
108
+ const signIn = await listenToken(deps, raw);
109
+ if (signIn.error) { err(signIn.error); return FAILED; }
110
+ const token = signIn.token;
111
+ secret = token;
112
+
113
+ const why = (res) => boardWhy(res, business);
114
+ const found = await lookupMember(request, business, who, token);
115
+ if (found.missing) { err(`no member called "${who}" in ${business.slug}, so nothing was checked. check the spelling of the name`); return FAILED; }
116
+ if (found.failed !== undefined) { err(`could not look up member "${who}": ${why(found.failed)}`); return FAILED; }
117
+ if (found.noId) { err(`member "${who}" in ${business.slug} has no agent id, so it has no inbox`); return FAILED; }
118
+ const member = found.member;
119
+
120
+ const base = `/swarlo/${encodeURIComponent(business.id)}/inbox`;
121
+ const inboxWhy = (res) => (res && res.status === 404 && res.data && res.data.detail === 'Not Found'
122
+ ? 'this server has no inboxes yet; the update may still be rolling out'
123
+ : why(res));
124
+ // The same command again, so the hint is copyable as is.
125
+ const again = `atris listen --as ${who}${Object.prototype.hasOwnProperty.call(flags, 'business') ? ` --business ${business.slug}` : ''}`;
126
+
127
+ if (ack !== null) {
128
+ const res = await request(`${base}/ack`, { method: 'POST', token, body: { agent_id: member.id, seq: ack } });
129
+ if (!res || !res.ok) { err(`could not mark ${member.name}'s inbox handled: ${inboxWhy(res)}`); return FAILED; }
130
+ if (flags.json) { log(JSON.stringify(res.data)); return 0; }
131
+ const upTo = Number(res.data && res.data.read_up_to);
132
+ if (Number.isFinite(upTo) && upTo > ack) log(`${member.name}'s inbox was already handled up to ${upTo}; nothing changed.`);
133
+ else log(`marked handled for ${member.name}, up to ${Number.isFinite(upTo) ? upTo : ack}.`);
134
+ return 0;
135
+ }
136
+
137
+ const fetchInbox = async () => {
138
+ // --watch asks for the most the server gives, since it never marks
139
+ // anything handled and would otherwise keep seeing the same oldest few.
140
+ const n = flags.watch ? SERVER_MAX : limit;
141
+ const res = await request(`${base}?agent_id=${encodeURIComponent(member.id)}&limit=${n}`, { method: 'GET', token });
142
+ if (!res || !res.ok) return { failed: inboxWhy(res), res };
143
+ if (!res.data || !Array.isArray(res.data.items)) return { failed: 'the server answered without an items list' };
144
+ return { data: res.data, items: bySeq(res.data.items) };
145
+ };
146
+
147
+ if (!flags.watch) {
148
+ const got = await fetchInbox();
149
+ if (got.failed) { err(`could not read ${member.name}'s inbox: ${got.failed}`); return FAILED; }
150
+ if (flags.json) { log(JSON.stringify(got.data)); return got.items.length ? NEW : NOTHING; }
151
+ if (!got.items.length) { log(`nothing new for ${member.name}.`); return NOTHING; }
152
+ for (const line of itemLines(got.items, clock())) log(line);
153
+ if (got.items.length >= limit) log(`showing the oldest ${got.items.length}; more are waiting after these are handled.`);
154
+ log(`mark handled: ${again} --ack ${got.items[got.items.length - 1].seq}`);
155
+ return NEW;
156
+ }
157
+
158
+ // --watch: for a person at a terminal. Prints only items it has not shown
159
+ // yet and never marks anything handled. Errors that retrying cannot fix
160
+ // (sign-in, access, no such business) stop it; the rest wait for the next round.
161
+ const sleep = deps.sleep || ((ms) => new Promise((r) => setTimeout(r, ms)));
162
+ const keepGoing = deps.keepGoing || (() => true);
163
+ // stderr, so --watch --json leaves nothing but items on stdout.
164
+ if (!deps.sleep) process.once('SIGINT', () => { (deps.err || console.error)('stopped listening.'); process.exit(0); });
165
+ if (!flags.json) log(`listening for ${member.name} in ${business.slug}. checks every 30 seconds; Ctrl-C to stop.`);
166
+ let shown = -Infinity;
167
+ let first = true;
168
+ let full = false;
169
+ for (;;) {
170
+ const got = await fetchInbox();
171
+ if (got.failed) {
172
+ err(`could not read ${member.name}'s inbox: ${got.failed}`);
173
+ if (got.res && [401, 403, 404].includes(got.res.status)) return FAILED;
174
+ } else {
175
+ const fresh = got.items.filter((it) => Number(it.seq) > shown);
176
+ if (fresh.length) {
177
+ if (flags.json) for (const it of fresh) log(JSON.stringify(it));
178
+ else for (const line of itemLines(fresh, clock())) log(line);
179
+ shown = Math.max(shown, ...fresh.map((it) => Number(it.seq)));
180
+ if (!flags.json) log(`mark handled: ${again} --ack ${shown}`);
181
+ } else if (first && !flags.json) {
182
+ log(`nothing new for ${member.name} yet.`);
183
+ }
184
+ // The server gives the oldest 50 unhandled; newer ones stay hidden
185
+ // until some are marked handled. Say so once, not every round.
186
+ const nowFull = got.items.length >= SERVER_MAX;
187
+ if (nowFull && !full) (flags.json ? err : log)(`${SERVER_MAX}+ waiting for ${member.name}; newer ones show after these are marked handled: ${again} --ack ${shown}`);
188
+ full = nowFull;
189
+ }
190
+ first = false;
191
+ if (!keepGoing()) return 0;
192
+ await sleep(deps.intervalMs || WATCH_MS);
193
+ }
194
+ }
195
+
196
+ module.exports = { listenCommand };
@@ -4,7 +4,7 @@ const knownCommands = ['init', 'guide', 'doctor', 'doc-health', 'install', 'work
4
4
  'activate', '_activate', 'agent', 'team', 'chat', 'fast', 'ax', 'console', 'serve', 'login', 'logout', 'whoami', 'switch', 'use', 'accounts', '_resolve', '_profile-email', '_switch-session', 'shell-init', 'update', 'upgrade', 'version', 'help', 'next', 'atris',
5
5
  'clean', 'close', 'harvest', 'verify', 'recover', 'search', 'scout', 'skill', 'member', 'codex-goal', 'app', 'apps', 'learn', 'lesson', 'taste', 'teach', 'plugin', 'experiments', 'bench', 'router', 'tree', 'receipt', 'proof', 'openclaw', 'pull', 'push', 'watch', 'cloud', 'live', 'align', 'terminal', 'computer', 'diff', 'business', 'sync', 'youtube', 'x-search',
6
6
  'ingest', 'query', 'lint', 'loop', 'pulse', 'task', 'mission', 'decide', 'campaign', 'agents', 'probe', 'worktree', 'land', 'caretaker', 'autoland', 'drive', 'aeo', 'slop', 'voice', 'strings', 'write', 'security-review', 'secure', 'sentinel', 'deck', 'site', 'theme', 'card', 'reel', 'improve', 'study', 'rainmaker', 'xp', 'play', 'gm', 'game', 'x', 'recap', 'report', 'signup', 'clarity', 'interview', 'meet', 'moves', 'unknowns', 'avail', 'scoreboard', 'growth', 'scorecard', 'rating', 'sync-checkout', 'zen',
7
- 'ci', 'github', 'vercel', 'supabase', 'linear', 'stripe', 'memory', 'balance', 'film', 'usage', 'spend', 'api-key', 'agent-client', 'topup', 'buy', 'transactions', 'hire', 'hires', 'mail', 'design', 'mcp', 'gmail', 'google', 'calendar', 'twitter', 'slack', 'imessage', 'integrations', 'setup', 'clean-workspace', 'cw',
7
+ 'ci', 'github', 'vercel', 'supabase', 'linear', 'stripe', 'memory', 'balance', 'film', 'usage', 'spend', 'api-key', 'agent-client', 'topup', 'buy', 'transactions', 'hire', 'hires', 'mail', 'channel', 'listen', 'design', 'mcp', 'gmail', 'google', 'calendar', 'twitter', 'slack', 'imessage', 'integrations', 'setup', 'clean-workspace', 'cw',
8
8
  'fork', 'browse', 'publish', 'pack', 'sleep', 'wake', 'feedback', 'errors', 'wiki', 'code-review', 'cr', 'soul', 'fleet', 'fleet-report', 'loops', 'self-improve', 'compile', 'spaceship', 'truth', 'sign', 'engine', 'engines', 'link', 'playbook', 'feed', 'brief', 'social', 'host', 'people', 'follow', 'unfollow', 'friends', 'msg', 'inbox', 'invite', 'join', 'rsi', 'pay', 'trust'];
9
9
 
10
10
  // Damerau-Levenshtein edit distance between two short strings. Counts an
@@ -174,11 +174,15 @@ function jobScope(state, key) {
174
174
  return state.machine && state.machine.path ? 'machine' : 'project';
175
175
  }
176
176
 
177
+ // A member belongs to one project, so a member no file names yet goes in that
178
+ // project's own atris/ROSTER.md when it has one; only a project without one
179
+ // falls back to the all-projects roster.
177
180
  function teamScope(state, member) {
178
181
  for (const scope of reg.ROSTER_LAYERS) {
179
182
  const team = state[scope] && Array.isArray(state[scope].team) ? state[scope].team : [];
180
183
  if (team.some((line) => line.member === member)) return scope;
181
184
  }
185
+ if (state.project && state.project.format === 'markdown') return 'project';
182
186
  return state.machine && state.machine.path ? 'machine' : 'project';
183
187
  }
184
188
 
@@ -0,0 +1,97 @@
1
+ 'use strict';
2
+
3
+ // Shared plumbing for the team board on the hosted server (`atris channel`,
4
+ // `atris listen`): which business, the plain reason a request failed, and the
5
+ // member name -> agent id lookup. HTTP client only; routes live in the backend.
6
+
7
+ const fs = require('fs');
8
+ const path = require('path');
9
+ const { oneLine } = require('./developer-api');
10
+ const { stateHome } = require('./state-home');
11
+
12
+ function readJson(file) {
13
+ try { return JSON.parse(fs.readFileSync(file, 'utf8')); } catch { return null; }
14
+ }
15
+
16
+ function registeredBusinesses(homeDir) {
17
+ const raw = readJson(path.join(homeDir, '.atris', 'businesses.json'));
18
+ if (!raw || typeof raw !== 'object') return [];
19
+ return Object.entries(raw)
20
+ .filter(([, b]) => b && typeof b === 'object' && b.business_id)
21
+ .map(([key, b]) => ({ id: String(b.business_id), slug: String(b.slug || key), name: String(b.name || b.slug || key) }));
22
+ }
23
+
24
+ function boundBusiness(startDir) {
25
+ let dir = path.resolve(startDir);
26
+ for (;;) {
27
+ const parsed = readJson(path.join(dir, '.atris', 'business.json'));
28
+ if (parsed && parsed.business_id) {
29
+ const slug = String(parsed.slug || parsed.name || parsed.business_id);
30
+ return { id: String(parsed.business_id), slug, name: String(parsed.name || slug) };
31
+ }
32
+ const up = path.dirname(dir);
33
+ if (up === dir) return null;
34
+ dir = up;
35
+ }
36
+ }
37
+
38
+ const PICK = 'add --business <slug>, set ATRIS_BUSINESS=<slug>, or run inside a business folder';
39
+
40
+ // Which business, in order: --business, ATRIS_BUSINESS, the nearest
41
+ // .atris/business.json, then the only one in ~/.atris/businesses.json.
42
+ // A slug is looked up locally; one this computer does not know goes to the
43
+ // server as is. Returns { id, slug, name } or { error }.
44
+ function resolveChannelBusiness(flags = {}, deps = {}) {
45
+ const env = deps.env || process.env;
46
+ const known = registeredBusinesses(deps.homeDir || stateHome());
47
+ const lookup = (value) => known.find((b) => b.slug === value || b.id === value)
48
+ || { id: value, slug: value, name: value };
49
+
50
+ if (Object.prototype.hasOwnProperty.call(flags, 'business')) {
51
+ const given = typeof flags.business === 'string' ? flags.business.trim() : '';
52
+ if (!given) return { error: '--business needs a business slug' };
53
+ return lookup(given);
54
+ }
55
+ const fromEnv = String(env.ATRIS_BUSINESS || '').trim();
56
+ if (fromEnv) return lookup(fromEnv);
57
+ const bound = boundBusiness(deps.cwd || process.cwd());
58
+ if (bound) return bound;
59
+ if (known.length === 1) return known[0];
60
+ if (!known.length) return { error: `no business picked: ${PICK}.` };
61
+ const names = known.map((b) => b.slug);
62
+ const shown = names.length > 8 ? `${names.slice(0, 8).join(', ')}, and ${names.length - 8} more` : names.join(', ');
63
+ return { error: `more than one business on this computer (${shown}): ${PICK}.` };
64
+ }
65
+
66
+ // One plain line on why a board request failed.
67
+ function boardWhy(res, business) {
68
+ if (!res) return 'no answer from the server';
69
+ const detail = res.data && (res.data.detail || res.data.error);
70
+ if (res.status === 401) return 'your sign-in was refused. run: atris login';
71
+ if (res.status === 403) return `you are not on ${business.slug}'s team`;
72
+ if (res.status === 404 && detail === 'Business not found') return `no business called "${business.slug}"`;
73
+ return oneLine(detail || res.error || `request failed (${res.status})`);
74
+ }
75
+
76
+ // The member named by --as, else ATRIS_AGENT: lowercased, no leading @.
77
+ function memberName(flags = {}, env = process.env) {
78
+ return String((typeof flags.as === 'string' ? flags.as : env.ATRIS_AGENT) || '').trim().replace(/^@/, '').toLowerCase();
79
+ }
80
+
81
+ // The server's words for "no such member" ("Agent not found: walsh"). Any
82
+ // other 404, like "Business not found", is a different problem.
83
+ const MEMBER_MISSING = /\b(agent|member) not found\b/i;
84
+
85
+ // Member name -> { member: { id, name } }, or { missing: true }, { failed: res },
86
+ // or { noId: true }. The caller words the error for its own command.
87
+ // `options` adds request settings, like a shorter timeout.
88
+ async function lookupMember(request, business, name, token, options = {}) {
89
+ const res = await request(`/business/${encodeURIComponent(business.id)}/team/${encodeURIComponent(name)}`, { method: 'GET', ...options, token });
90
+ const detail = res && res.data && (res.data.detail || res.data.error);
91
+ if (res && res.status === 404 && MEMBER_MISSING.test(String(detail || ''))) return { missing: true };
92
+ if (!res || !res.ok) return { failed: res };
93
+ if (!res.data || !res.data.agent_id) return { noId: true };
94
+ return { member: { id: String(res.data.agent_id), name: String(res.data.name || name) } };
95
+ }
96
+
97
+ module.exports = { resolveChannelBusiness, boardWhy, memberName, lookupMember };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "atris",
3
- "version": "3.82.22",
3
+ "version": "3.82.24",
4
4
  "description": "you say what you want in plain words. atris builds it, checks it, and shows you proof. project memory your coding agents share across sessions.",
5
5
  "mcpName": "io.github.atrislabs/atris",
6
6
  "main": "bin/atris.js",