atris 3.82.23 → 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/bin/atris.js +8 -1
- package/commands/channel.js +9 -72
- package/commands/listen.js +196 -0
- package/lib/known-commands.js +1 -1
- package/lib/roster-swap.js +4 -0
- package/lib/team-board.js +97 -0
- package/package.json +1 -1
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
|
-
|
|
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) {
|
|
@@ -788,6 +789,7 @@ function showHelpAll() {
|
|
|
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)');
|
|
790
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>)');
|
|
791
793
|
console.log(' design - Extract a site design system, check brand adherence, search brands');
|
|
792
794
|
console.log(' mcp - Run the atris MCP server (stdio) for Claude Desktop and Cursor');
|
|
793
795
|
console.log('');
|
|
@@ -2586,6 +2588,11 @@ if (command === 'guide') {
|
|
|
2586
2588
|
Promise.resolve().then(() => require('../commands/channel').channelCommand(process.argv.slice(3)))
|
|
2587
2589
|
.then((code) => exitAfterStdoutDrain(typeof code === 'number' ? code : 0))
|
|
2588
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); });
|
|
2589
2596
|
} else if (command === 'transactions') {
|
|
2590
2597
|
Promise.resolve().then(() => require('../commands/transactions').transactionsCommand(process.argv.slice(3)))
|
|
2591
2598
|
.then((code) => exitAfterStdoutDrain(typeof code === 'number' ? code : 0))
|
package/commands/channel.js
CHANGED
|
@@ -5,12 +5,10 @@
|
|
|
5
5
|
// the sign-in they already have. Backend: /api/swarlo/{business}/... (the
|
|
6
6
|
// business may be its slug or its id; the server takes both).
|
|
7
7
|
|
|
8
|
-
const fs = require('fs');
|
|
9
|
-
const path = require('path');
|
|
10
8
|
const { apiRequestJson } = require('../utils/api');
|
|
11
|
-
const { NOT_LOGGED_IN
|
|
9
|
+
const { NOT_LOGGED_IN } = require('../lib/developer-api');
|
|
12
10
|
const { parseArgs, loginToken, relativeTime, clip } = require('../lib/business-target');
|
|
13
|
-
const {
|
|
11
|
+
const { resolveChannelBusiness, boardWhy, memberName, lookupMember } = require('../lib/team-board');
|
|
14
12
|
|
|
15
13
|
// Words that are verbs here, so no channel can be called one of them.
|
|
16
14
|
const REPORTS = ['done', 'failed', 'blocked'];
|
|
@@ -43,60 +41,6 @@ the team's channels on the hosted Atris server, shared by every agent on every
|
|
|
43
41
|
computer. uses your existing sign-in (atris login).`);
|
|
44
42
|
}
|
|
45
43
|
|
|
46
|
-
function readJson(file) {
|
|
47
|
-
try { return JSON.parse(fs.readFileSync(file, 'utf8')); } catch { return null; }
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
function registeredBusinesses(homeDir) {
|
|
51
|
-
const raw = readJson(path.join(homeDir, '.atris', 'businesses.json'));
|
|
52
|
-
if (!raw || typeof raw !== 'object') return [];
|
|
53
|
-
return Object.entries(raw)
|
|
54
|
-
.filter(([, b]) => b && typeof b === 'object' && b.business_id)
|
|
55
|
-
.map(([key, b]) => ({ id: String(b.business_id), slug: String(b.slug || key), name: String(b.name || b.slug || key) }));
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
function boundBusiness(startDir) {
|
|
59
|
-
let dir = path.resolve(startDir);
|
|
60
|
-
for (;;) {
|
|
61
|
-
const parsed = readJson(path.join(dir, '.atris', 'business.json'));
|
|
62
|
-
if (parsed && parsed.business_id) {
|
|
63
|
-
const slug = String(parsed.slug || parsed.name || parsed.business_id);
|
|
64
|
-
return { id: String(parsed.business_id), slug, name: String(parsed.name || slug) };
|
|
65
|
-
}
|
|
66
|
-
const up = path.dirname(dir);
|
|
67
|
-
if (up === dir) return null;
|
|
68
|
-
dir = up;
|
|
69
|
-
}
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
const PICK = 'add --business <slug>, set ATRIS_BUSINESS=<slug>, or run inside a business folder';
|
|
73
|
-
|
|
74
|
-
// Which business, in order: --business, ATRIS_BUSINESS, the nearest
|
|
75
|
-
// .atris/business.json, then the only one in ~/.atris/businesses.json.
|
|
76
|
-
// A slug is looked up locally; one this computer does not know goes to the
|
|
77
|
-
// server as is. Returns { id, slug, name } or { error }.
|
|
78
|
-
function resolveChannelBusiness(flags = {}, deps = {}) {
|
|
79
|
-
const env = deps.env || process.env;
|
|
80
|
-
const known = registeredBusinesses(deps.homeDir || stateHome());
|
|
81
|
-
const lookup = (value) => known.find((b) => b.slug === value || b.id === value)
|
|
82
|
-
|| { id: value, slug: value, name: value };
|
|
83
|
-
|
|
84
|
-
if (Object.prototype.hasOwnProperty.call(flags, 'business')) {
|
|
85
|
-
const given = typeof flags.business === 'string' ? flags.business.trim() : '';
|
|
86
|
-
if (!given) return { error: '--business needs a business slug' };
|
|
87
|
-
return lookup(given);
|
|
88
|
-
}
|
|
89
|
-
const fromEnv = String(env.ATRIS_BUSINESS || '').trim();
|
|
90
|
-
if (fromEnv) return lookup(fromEnv);
|
|
91
|
-
const bound = boundBusiness(deps.cwd || process.cwd());
|
|
92
|
-
if (bound) return bound;
|
|
93
|
-
if (known.length === 1) return known[0];
|
|
94
|
-
if (!known.length) return { error: `no business picked: ${PICK}.` };
|
|
95
|
-
const names = known.map((b) => b.slug);
|
|
96
|
-
const shown = names.length > 8 ? `${names.slice(0, 8).join(', ')}, and ${names.length - 8} more` : names.join(', ');
|
|
97
|
-
return { error: `more than one business on this computer (${shown}): ${PICK}.` };
|
|
98
|
-
}
|
|
99
|
-
|
|
100
44
|
function postLine(post, now, width = 400) {
|
|
101
45
|
const who = clip(post.member_name || 'someone', 18);
|
|
102
46
|
const when = relativeTime(post.created_at, now) || '';
|
|
@@ -144,14 +88,7 @@ async function channelCommand(args = [], deps = {}) {
|
|
|
144
88
|
const request = deps.apiRequestJson || apiRequestJson;
|
|
145
89
|
const base = `/swarlo/${encodeURIComponent(business.id)}`;
|
|
146
90
|
const call = (route, options = {}) => request(`${base}${route}`, { method: 'GET', ...options, token });
|
|
147
|
-
const why = (res) =>
|
|
148
|
-
if (!res) return 'no answer from the server';
|
|
149
|
-
const detail = res.data && (res.data.detail || res.data.error);
|
|
150
|
-
if (res.status === 401) return 'your sign-in was refused. run: atris login';
|
|
151
|
-
if (res.status === 403) return `you are not on ${business.slug}'s team`;
|
|
152
|
-
if (res.status === 404 && detail === 'Business not found') return `no business called "${business.slug}"`;
|
|
153
|
-
return oneLine(detail || res.error || `request failed (${res.status})`);
|
|
154
|
-
};
|
|
91
|
+
const why = (res) => boardWhy(res, business);
|
|
155
92
|
const say = (res) => log(JSON.stringify(res.data));
|
|
156
93
|
const channelList = async () => {
|
|
157
94
|
const res = await call('/channels');
|
|
@@ -200,14 +137,14 @@ async function channelCommand(args = [], deps = {}) {
|
|
|
200
137
|
// member that cannot be found stops the write; it never falls back to you.
|
|
201
138
|
const writes = (verb && verb !== 'list') || (name && text);
|
|
202
139
|
const env = deps.env || process.env;
|
|
203
|
-
const asWho =
|
|
140
|
+
const asWho = memberName(flags, env);
|
|
204
141
|
let member = null;
|
|
205
142
|
if (writes && asWho) {
|
|
206
|
-
const
|
|
207
|
-
if (
|
|
208
|
-
if (
|
|
209
|
-
if (
|
|
210
|
-
member =
|
|
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;
|
|
211
148
|
}
|
|
212
149
|
const signed = (body) => (member ? { ...body, agent_id: member.id } : body);
|
|
213
150
|
const byline = member ? ` as ${member.name}` : '';
|
|
@@ -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 };
|
package/lib/known-commands.js
CHANGED
|
@@ -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', 'channel', '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
|
package/lib/roster-swap.js
CHANGED
|
@@ -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.
|
|
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",
|