atris 3.82.21 → 3.82.23

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 |
@@ -113,7 +113,7 @@ Smoke: `reply with the word OK`, run from `$HOME`, 120s cap, one at a time on a
113
113
  | Devin (search) | `devin -p --permission-mode dangerous --model swe-1.7-lightning -- "<prompt>"` | `swe-1.7-lightning` (paid, no Free tag on 2026-09-23) | 2026-09-23 | 13.9 | |
114
114
  | Grok | `grok --always-approve --model grok-4.7 -p "<prompt>"` (run from the target repo) | `grok-4.7` | 2026-09-23 | 11.7 | Unpinned default is now `grok-4.7-build-fast`; `grok models` lists the menu. Uses grok.com login. `--best-of-n <N>` for tricky bounded builds. |
115
115
  | Muse | `muse exec --sandbox-network enabled --trust-workspace --model muse-wonder-1.0 "<prompt>"` (run from the target repo) | `muse-wonder-1.0` | 2026-11-11 | - | Meta's Muse Code. Runs only Muse models (`muse wonder`, `muse spark`). Its sandbox proxies the network by default, which blocks atris calls; `enabled` keeps the sandbox and approval judge on. Read-only asks add `--disable-write`. |
116
- | Antigravity (agy) | `agy --mode accept-edits --add-dir "$PWD" --model gemini-3.8-flash-high --output-format json -p "<prompt>"` (run from the target repo) | `gemini-3.8-flash-high` | 2026-09-23 | 15.9 | **`--add-dir` is mandatory for writes**: without it agy edits `~/.gemini/antigravity-cli/scratch/` and the project never changes (2026-08-28). `--mode plan --sandbox` for read-only review. Also answers to "gemini". |
116
+ | Antigravity (agy) | `agy --mode accept-edits --add-dir "$PWD" --model gemini-3.8-flash-high --output-format json -p "<prompt>"` (run from the target repo) | `gemini-3.8-flash-high` | 2026-09-23 | 15.9 | **`--add-dir` is mandatory for writes**: without it agy edits `~/.gemini/antigravity-cli/scratch/` and the project never changes (2026-08-28). For questions use `atris engine ask --engine agy`: it runs agy in edit mode, sandboxed, from an empty throwaway folder (plan mode hangs with no output), and picks `gemini-4-carbon-medium` when no model is named, since High is too slow for questions. Put any repo context in the question itself. Also answers to "gemini". |
117
117
  | opencode | `opencode run -m opencode/muse-spark-1.3-contributor-free "<prompt>"` (read-only: add `--agent plan`) | `opencode/muse-spark-1.3-contributor-free` | 2026-09-23 | 41.1 | Build work needs `--auto` (isolated worktree only). `opencode models` lists the live menu. |
118
118
 
119
119
  Headless dispatch permissions (verified 2026-08-11): `codex exec`, `grok`, and `cursor-agent` are allowlisted in `~/.claude/settings.json` so fresh and one-shot sessions can dispatch without a human approval click. A cold session that gets "requires approval" on an engine command means that allowlist regressed.
package/bin/atris.js CHANGED
@@ -787,6 +787,7 @@ function showHelpAll() {
787
787
  console.log(' hire - Hire an agent in one step: name, job, inbox, allowance, nightly work loop');
788
788
  console.log(' hires - Morning check-in: one line per hire (mail, spend, last run)');
789
789
  console.log(' mail - Your agents\' own inboxes: list, read, send (not Gmail)');
790
+ console.log(' channel - The team\'s shared channels: read, post, claim, done (atris channel <name>)');
790
791
  console.log(' design - Extract a site design system, check brand adherence, search brands');
791
792
  console.log(' mcp - Run the atris MCP server (stdio) for Claude Desktop and Cursor');
792
793
  console.log('');
@@ -2580,6 +2581,11 @@ if (command === 'guide') {
2580
2581
  Promise.resolve().then(() => require('../commands/mail').mailCommand(process.argv.slice(3)))
2581
2582
  .then((code) => exitAfterStdoutDrain(typeof code === 'number' ? code : 0))
2582
2583
  .catch((err) => { console.error(String(err.message || err).replace(/\s+/g, ' ')); process.exit(1); });
2584
+ } else if (command === 'channel') {
2585
+ // The team's shared channels on the hosted server: read, post, claim, report by name.
2586
+ Promise.resolve().then(() => require('../commands/channel').channelCommand(process.argv.slice(3)))
2587
+ .then((code) => exitAfterStdoutDrain(typeof code === 'number' ? code : 0))
2588
+ .catch((err) => { console.error(String(err.message || err).replace(/\s+/g, ' ')); process.exit(1); });
2583
2589
  } else if (command === 'transactions') {
2584
2590
  Promise.resolve().then(() => require('../commands/transactions').transactionsCommand(process.argv.slice(3)))
2585
2591
  .then((code) => exitAfterStdoutDrain(typeof code === 'number' ? code : 0))
@@ -0,0 +1,286 @@
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 fs = require('fs');
9
+ const path = require('path');
10
+ const { apiRequestJson } = require('../utils/api');
11
+ const { NOT_LOGGED_IN, oneLine } = require('../lib/developer-api');
12
+ const { parseArgs, loginToken, relativeTime, clip } = require('../lib/business-target');
13
+ const { stateHome } = require('../lib/state-home');
14
+
15
+ // Words that are verbs here, so no channel can be called one of them.
16
+ const REPORTS = ['done', 'failed', 'blocked'];
17
+ const VERBS = new Set(['list', 'claim', ...REPORTS]);
18
+ const FLAGS = { switches: ['new'], values: ['channel', 'limit', 'task', 'as'] };
19
+ const OVERVIEW_PER_CHANNEL = 3;
20
+ // The server reads the newest limit x 3 posts of the whole business and then
21
+ // keeps one channel's, so a small limit hides a quiet channel's older posts.
22
+ // Always ask for its most (50) and trim here.
23
+ const SERVER_MAX = 50;
24
+
25
+ function channelHelp(log = console.log) {
26
+ log(`usage: atris channel what's new in every channel
27
+ atris channel <name> read a channel, newest last
28
+ atris channel <name> "message" post to it
29
+ atris channel <name> "what to do" --task <key> post a task others can claim
30
+ atris channel claim <task-key> ["note"] claim a task
31
+ atris channel done <task-key> "summary" report it done (also: failed, blocked)
32
+ atris channel list channel names only
33
+
34
+ options:
35
+ --business <slug> which business (else ATRIS_BUSINESS, this folder's business, or your only one)
36
+ --as <member> post, claim, and report as that team member (else ATRIS_AGENT, else you)
37
+ --channel <name> for claim and done: skip the search for the task's channel
38
+ --limit <n> posts to read, 1 to 50 (default 20)
39
+ --new post to a channel that does not exist yet
40
+ --json raw replies from the server
41
+
42
+ the team's channels on the hosted Atris server, shared by every agent on every
43
+ computer. uses your existing sign-in (atris login).`);
44
+ }
45
+
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
+ function postLine(post, now, width = 400) {
101
+ const who = clip(post.member_name || 'someone', 18);
102
+ const when = relativeTime(post.created_at, now) || '';
103
+ const key = post.task_key ? String(post.task_key) : '';
104
+ let tag = key ? `[${key}] ` : '';
105
+ if (post.kind === 'claim') tag = `claimed ${key}: `;
106
+ else if (post.kind === 'result') tag = `done ${key}: `;
107
+ else if (post.kind === 'failed') tag = `${post.status || 'failed'} ${key}: `;
108
+ return `${who.padEnd(12)} ${when.padEnd(11)} ${clip(`${tag}${post.content || ''}`, width)}`;
109
+ }
110
+
111
+ function closest(name, channels) {
112
+ const n = name.toLowerCase();
113
+ return channels.find((c) => c.toLowerCase().startsWith(n.slice(0, 3))) || null;
114
+ }
115
+
116
+ async function channelCommand(args = [], deps = {}) {
117
+ let secret = '';
118
+ const hide = (line) => (secret ? String(line).split(secret).join('[hidden]') : String(line));
119
+ const log = (line) => (deps.log || console.log)(hide(line));
120
+ const err = (line) => (deps.err || console.error)(hide(line));
121
+ const now = deps.now ? deps.now() : Date.now();
122
+
123
+ const { flags, pos, error } = parseArgs(args, FLAGS);
124
+ if (flags.help || pos[0] === 'help') { channelHelp(log); return 0; }
125
+ if (error) { err(`${error}. see: atris channel --help`); return 1; }
126
+
127
+ const verb = VERBS.has(pos[0]) ? pos[0] : null;
128
+ const name = verb ? null : (pos[0] ? pos[0].replace(/^#/, '') : null);
129
+ const taskKey = verb && verb !== 'list' ? pos[1] : null;
130
+ const text = verb ? pos.slice(2).join(' ').trim() : pos.slice(1).join(' ').trim();
131
+ if (verb && verb !== 'list' && !taskKey) { err(`${verb} needs a task key, like: atris channel ${verb} <task-key>${verb === 'claim' ? '' : ' "summary"'}`); return 1; }
132
+ if (REPORTS.includes(verb) && !text) { err(`${verb} needs a summary, like: atris channel ${verb} ${taskKey} "what happened"`); return 1; }
133
+ if (verb === 'list' && pos.length > 1) { err('atris channel list takes no other words'); return 1; }
134
+ if (name === '') { err('give a channel name, like: atris channel general'); return 1; }
135
+ const limit = flags.limit == null ? 20 : Number(flags.limit);
136
+ if (!Number.isInteger(limit) || limit < 1 || limit > 50) { err('--limit takes a whole number from 1 to 50'); return 1; }
137
+
138
+ const business = resolveChannelBusiness(flags, deps);
139
+ if (business.error) { err(business.error); return 1; }
140
+ const token = await loginToken(deps);
141
+ if (!token) { err(NOT_LOGGED_IN); return 1; }
142
+ secret = token;
143
+
144
+ const request = deps.apiRequestJson || apiRequestJson;
145
+ const base = `/swarlo/${encodeURIComponent(business.id)}`;
146
+ 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
+ };
155
+ const say = (res) => log(JSON.stringify(res.data));
156
+ const channelList = async () => {
157
+ const res = await call('/channels');
158
+ if (!res || !res.ok) { err(`could not list ${business.slug}'s channels: ${why(res)}`); return null; }
159
+ return Array.isArray(res.data && res.data.channels) ? res.data.channels.map(String) : [];
160
+ };
161
+ const readPosts = async (channel, n) => {
162
+ const res = await call(`/channels/${encodeURIComponent(channel)}/posts?limit=${SERVER_MAX}`);
163
+ if (res && res.ok && res.data && Array.isArray(res.data.posts)) {
164
+ const posts = res.data.posts.slice(0, n);
165
+ return { ...res, data: { ...res.data, count: posts.length, posts } };
166
+ }
167
+ return res;
168
+ };
169
+ const unknownChannel = (channel, channels) => {
170
+ const near = closest(channel, channels);
171
+ err(`no channel named "${channel}" in ${business.slug}.${near ? ` did you mean ${near}?` : ''} channels: ${channels.join(', ') || 'none yet'}`);
172
+ };
173
+
174
+ // Find where a task lives: --channel, then an open claim, then any post carrying the key.
175
+ const findTaskChannel = async (key, useClaims) => {
176
+ if (typeof flags.channel === 'string') return { channel: flags.channel.replace(/^#/, '') };
177
+ if (useClaims) {
178
+ const res = await call('/claims');
179
+ if (!res || !res.ok) return { failed: `could not check open claims: ${why(res)}` };
180
+ const hit = ((res.data && res.data.claims) || []).find((c) => c.task_key === key && c.channel);
181
+ if (hit) return { channel: hit.channel };
182
+ }
183
+ const channels = await channelList();
184
+ if (!channels) return { failed: 'shown' };
185
+ const reads = await Promise.all(channels.map((c) => readPosts(c, SERVER_MAX)));
186
+ let best = null;
187
+ reads.forEach((res, i) => {
188
+ if (!res || !res.ok) return;
189
+ for (const p of (res.data && res.data.posts) || []) {
190
+ if (p.task_key !== key) continue;
191
+ if (!best || String(p.created_at || '') > String(best.created_at || '')) best = { ...p, channel: p.channel || channels[i] };
192
+ }
193
+ });
194
+ if (best) return { channel: best.channel };
195
+ 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>` };
196
+ };
197
+
198
+ // Writes go out as a team member when one is named (--as, else ATRIS_AGENT),
199
+ // so a member's post reads as that member, not as the signed-in person. A
200
+ // member that cannot be found stops the write; it never falls back to you.
201
+ const writes = (verb && verb !== 'list') || (name && text);
202
+ const env = deps.env || process.env;
203
+ const asWho = String((typeof flags.as === 'string' ? flags.as : env.ATRIS_AGENT) || '').trim().replace(/^@/, '').toLowerCase();
204
+ let member = null;
205
+ if (writes && asWho) {
206
+ const res = await request(`/business/${encodeURIComponent(business.id)}/team/${encodeURIComponent(asWho)}`, { method: 'GET', token });
207
+ if (res && res.status === 404) { 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; }
208
+ if (!res || !res.ok) { err(`could not look up member "${asWho}", so nothing was sent: ${why(res)}`); return 1; }
209
+ if (!res.data || !res.data.agent_id) { err(`member "${asWho}" in ${business.slug} has no agent id, so nothing was sent`); return 1; }
210
+ member = { id: String(res.data.agent_id), name: String(res.data.name || asWho) };
211
+ }
212
+ const signed = (body) => (member ? { ...body, agent_id: member.id } : body);
213
+ const byline = member ? ` as ${member.name}` : '';
214
+
215
+ if (verb === 'claim' || REPORTS.includes(verb)) {
216
+ const found = await findTaskChannel(taskKey, verb !== 'claim');
217
+ if (found.failed) { if (found.failed !== 'shown') err(found.failed); return 1; }
218
+ const channel = found.channel;
219
+ const res = verb === 'claim'
220
+ ? await call(`/channels/${encodeURIComponent(channel)}/claim`, { method: 'POST', body: signed({ task_key: taskKey, content: text || `claimed ${taskKey}` }) })
221
+ : await call(`/channels/${encodeURIComponent(channel)}/report`, { method: 'POST', body: signed({ task_key: taskKey, status: verb, content: text }) });
222
+ if (!res || !res.ok) { err(`could not ${verb === 'claim' ? 'claim' : 'report'} ${taskKey} in #${channel}: ${why(res)}`); return 1; }
223
+ if (flags.json) say(res);
224
+ else log(verb === 'claim' ? `claimed ${taskKey} in #${channel}${byline}` : `${taskKey} is ${verb} in #${channel}${byline}`);
225
+ return 0;
226
+ }
227
+
228
+ if (verb === 'list') {
229
+ const channels = await channelList();
230
+ if (!channels) return 1;
231
+ log(flags.json ? JSON.stringify({ channels }) : channels.map((c) => `#${c}`).join('\n'));
232
+ return 0;
233
+ }
234
+
235
+ if (name && text) {
236
+ const channels = await channelList();
237
+ if (!channels) return 1;
238
+ if (!channels.includes(name) && !flags.new) { unknownChannel(name, channels); err(`to start #${name}, add --new`); return 1; }
239
+ const body = signed(typeof flags.task === 'string' ? { content: text, task_key: flags.task } : { content: text });
240
+ const res = await call(`/channels/${encodeURIComponent(name)}/posts`, { method: 'POST', body });
241
+ if (!res || !res.ok) { err(`could not post to #${name}: ${why(res)}`); return 1; }
242
+ if (flags.json) say(res);
243
+ 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}`);
244
+ return 0;
245
+ }
246
+
247
+ if (name) {
248
+ const res = await readPosts(name, limit);
249
+ if (!res || !res.ok) { err(`could not read #${name}: ${why(res)}`); return 1; }
250
+ const posts = (res.data && res.data.posts) || [];
251
+ if (!posts.length) {
252
+ const channels = await channelList();
253
+ if (channels && !channels.includes(name)) { unknownChannel(name, channels); return 1; }
254
+ }
255
+ if (flags.json) { say(res); return 0; }
256
+ if (!posts.length) { log(`#${name} has no posts yet. post one: atris channel ${name} "hello"`); return 0; }
257
+ log(`#${name}`);
258
+ for (const p of posts.slice().reverse()) log(` ${postLine(p, now)}`);
259
+ return 0;
260
+ }
261
+
262
+ // No name: the latest few posts in every channel, busiest-most-recent first.
263
+ const channels = await channelList();
264
+ if (!channels) return 1;
265
+ const reads = await Promise.all(channels.map((c) => readPosts(c, OVERVIEW_PER_CHANNEL)));
266
+ const failed = reads.map((r, i) => (r && r.ok ? null : `#${channels[i]}: ${why(r)}`)).filter(Boolean);
267
+ const rows = channels.map((c, i) => ({ channel: c, posts: (reads[i] && reads[i].ok && reads[i].data && reads[i].data.posts) || [] }));
268
+ if (flags.json) {
269
+ log(JSON.stringify({ business: business.slug, channels: rows }));
270
+ } else {
271
+ const active = rows.filter((r) => r.posts.length)
272
+ .sort((a, b) => String(b.posts[0].created_at || '').localeCompare(String(a.posts[0].created_at || '')));
273
+ const quiet = rows.filter((r) => !r.posts.length).map((r) => `#${r.channel}`);
274
+ if (!active.length) log(`no posts yet in ${business.slug}'s channels.`);
275
+ active.forEach((r, i) => {
276
+ if (i) log('');
277
+ log(`#${r.channel}`);
278
+ for (const p of r.posts.slice().reverse()) log(` ${postLine(p, now, 140)}`);
279
+ });
280
+ if (quiet.length) { log(''); log(`quiet: ${quiet.join(', ')}`); }
281
+ }
282
+ for (const f of failed) err(`could not read ${f}`);
283
+ return failed.length === channels.length && channels.length ? 1 : 0;
284
+ }
285
+
286
+ module.exports = { channelCommand, resolveChannelBusiness };
@@ -0,0 +1,250 @@
1
+ 'use strict';
2
+
3
+ // The orb, in the terminal: the same chat as atris.ai/orb, with its numbered choices.
4
+ // atris orb chat talk, type a number to pick a choice or write your own
5
+ // atris orb say "text" one message, prints the reply and numbered choices
6
+ // atris orb pick 2 picks choice 2 from the last reply
7
+ // atris orb new starts fresh with the orb's own opener
8
+ // The thread lives in ~/.atris/orb-chat.json so say and pick continue the same chat.
9
+
10
+ const fs = require('node:fs');
11
+ const path = require('node:path');
12
+ const readline = require('node:readline');
13
+ const { stateHome } = require('../lib/state-home');
14
+ const { getApiBaseUrl } = require('../utils/api');
15
+
16
+ const MAX_MESSAGES = 16;
17
+ const MAX_CHOICE_SETS = 6;
18
+ const OPENER_MESSAGE = 'Open my day.';
19
+
20
+ const TOOL_WORDS = {
21
+ computer_list: 'looking at your computers',
22
+ computer_team: 'looking at your helpers',
23
+ computer_execute: 'sending the work to your computer',
24
+ computer_delegate: 'handing the work to a helper',
25
+ computer_inflight: 'checking what your computers are doing',
26
+ computer_execution_result: 'reading what a job found',
27
+ web_search: 'searching the web',
28
+ memory_search: 'searching your notes',
29
+ memory_read: 'reading your notes',
30
+ memory_ask: 'reading your notes',
31
+ memory_write: 'writing a page',
32
+ remember: 'saving that',
33
+ };
34
+
35
+ function stateFile() {
36
+ return path.join(stateHome(), '.atris', 'orb-chat.json');
37
+ }
38
+
39
+ function readState() {
40
+ try {
41
+ const parsed = JSON.parse(fs.readFileSync(stateFile(), 'utf8'));
42
+ if (parsed && Array.isArray(parsed.messages)) return parsed;
43
+ } catch {}
44
+ return { messages: [], choiceHistory: [], options: [] };
45
+ }
46
+
47
+ function writeState(state) {
48
+ const file = stateFile();
49
+ fs.mkdirSync(path.dirname(file), { recursive: true });
50
+ const trimmed = {
51
+ messages: state.messages.slice(-MAX_MESSAGES),
52
+ choiceHistory: state.choiceHistory.slice(-MAX_CHOICE_SETS),
53
+ options: state.options || [],
54
+ };
55
+ fs.writeFileSync(file, JSON.stringify(trimmed, null, 2), { mode: 0o600 });
56
+ }
57
+
58
+ function token(env = process.env) {
59
+ if (env.ATRIS_TOKEN) return env.ATRIS_TOKEN;
60
+ try {
61
+ const creds = require('../utils/auth').loadCredentials();
62
+ return creds && creds.token ? String(creds.token) : '';
63
+ } catch {
64
+ return '';
65
+ }
66
+ }
67
+
68
+ function turnBody(state, message, { opener = false } = {}) {
69
+ const body = {
70
+ message,
71
+ surface: 'dashboard_orb',
72
+ model: 'atris:fast',
73
+ memory_enabled: true,
74
+ max_turns: 4,
75
+ previous_messages: opener ? [] : state.messages.slice(-MAX_MESSAGES),
76
+ choice_history: state.choiceHistory.slice(-MAX_CHOICE_SETS),
77
+ };
78
+ if (opener) {
79
+ Object.assign(body, {
80
+ orb_opener: true,
81
+ turn_kind: 'orb_open',
82
+ disable_connector_tools: true,
83
+ max_turns: 1,
84
+ });
85
+ }
86
+ return body;
87
+ }
88
+
89
+ function parseSseLines(buffer, onEvent) {
90
+ const lines = buffer.split('\n');
91
+ const rest = lines.pop();
92
+ for (const raw of lines) {
93
+ const line = raw.trim();
94
+ if (!line.startsWith('data:')) continue;
95
+ const data = line.slice(5).trim();
96
+ if (!data || data === '[DONE]') continue;
97
+ try { onEvent(JSON.parse(data)); } catch {}
98
+ }
99
+ return rest;
100
+ }
101
+
102
+ async function sendTurn(state, message, { opener = false, out = process.stdout, fetchImpl = globalThis.fetch } = {}) {
103
+ const auth = token();
104
+ if (!auth) throw new Error('Not signed in. Run: atris login');
105
+ const response = await fetchImpl(`${getApiBaseUrl()}/atris2/turn`, {
106
+ method: 'POST',
107
+ headers: {
108
+ Authorization: `Bearer ${auth}`,
109
+ 'Content-Type': 'application/json',
110
+ Accept: 'text/event-stream',
111
+ },
112
+ body: JSON.stringify(turnBody(state, message, { opener })),
113
+ });
114
+ if (!response.ok) {
115
+ const detail = await response.text().catch(() => '');
116
+ throw new Error(`The orb did not answer (${response.status}). ${detail.slice(0, 200)}`.trim());
117
+ }
118
+
119
+ let text = '';
120
+ let options = [];
121
+ let lastToolWords = '';
122
+ const onEvent = (event) => {
123
+ if (event.type === 'text_delta' && event.content) {
124
+ text += event.content;
125
+ out.write(event.content);
126
+ } else if (event.type === 'options' && Array.isArray(event.options)) {
127
+ options = event.options
128
+ .filter((option) => option && option.label)
129
+ .map((option) => ({ label: String(option.label), message: String(option.message || option.label) }));
130
+ } else if (event.type === 'tool_call') {
131
+ const words = TOOL_WORDS[event.tool];
132
+ if (words && words !== lastToolWords) {
133
+ lastToolWords = words;
134
+ out.write(`${text && !text.endsWith('\n') ? '\n' : ''}\x1b[2m· ${words}\x1b[0m\n`);
135
+ }
136
+ } else if (event.type === 'error' && event.error) {
137
+ out.write(`\n\x1b[2m(${String(event.error).slice(0, 160)})\x1b[0m\n`);
138
+ }
139
+ };
140
+
141
+ const decoder = new TextDecoder();
142
+ let buffer = '';
143
+ for await (const chunk of response.body) {
144
+ buffer += decoder.decode(chunk, { stream: true });
145
+ buffer = parseSseLines(buffer, onEvent);
146
+ }
147
+ parseSseLines(`${buffer}\n`, onEvent);
148
+ if (!text.endsWith('\n')) out.write('\n');
149
+
150
+ if (!opener) state.messages.push({ role: 'user', content: message });
151
+ if (text.trim()) state.messages.push({ role: 'assistant', content: text.trim() });
152
+ state.options = options;
153
+ if (options.length) state.choiceHistory.push({ shown: options.map((option) => option.label), picked: null });
154
+ writeState(state);
155
+ return { text, options };
156
+ }
157
+
158
+ function renderOptions(options, out = process.stdout) {
159
+ if (!options.length) return;
160
+ out.write('\n');
161
+ options.forEach((option, index) => {
162
+ out.write(` \x1b[1m${index + 1}\x1b[0m ${option.label}\n`);
163
+ });
164
+ }
165
+
166
+ // A number picks a choice from the last reply; anything else is their own words.
167
+ function resolveInput(state, input) {
168
+ const trimmed = String(input || '').trim();
169
+ const number = /^\d+$/.test(trimmed) ? Number(trimmed) : NaN;
170
+ const options = state.options || [];
171
+ if (Number.isInteger(number) && number >= 1 && number <= options.length) {
172
+ const option = options[number - 1];
173
+ const lastSet = state.choiceHistory[state.choiceHistory.length - 1];
174
+ if (lastSet) lastSet.picked = option.label;
175
+ return { message: option.message, echo: option.label };
176
+ }
177
+ return { message: trimmed, echo: '' };
178
+ }
179
+
180
+ async function say(state, input, io) {
181
+ const { message, echo } = resolveInput(state, input);
182
+ if (!message) return;
183
+ if (echo) io.out.write(`\x1b[2m> ${echo}\x1b[0m\n`);
184
+ io.out.write('\n');
185
+ const { options } = await sendTurn(state, message, io);
186
+ renderOptions(options, io.out);
187
+ }
188
+
189
+ async function openFresh(io) {
190
+ const state = { messages: [], choiceHistory: readState().choiceHistory, options: [] };
191
+ const { options } = await sendTurn(state, OPENER_MESSAGE, { ...io, opener: true });
192
+ renderOptions(options, io.out);
193
+ return state;
194
+ }
195
+
196
+ async function interactive(io) {
197
+ let state = await openFresh(io);
198
+ const rl = readline.createInterface({ input: process.stdin, output: io.out });
199
+ const ask = () => new Promise((resolve) => rl.question('\n\x1b[2mpick a number or say anything (q to leave)\x1b[0m\n> ', resolve));
200
+ try {
201
+ for (;;) {
202
+ const input = (await ask()).trim();
203
+ if (!input) continue;
204
+ if (/^(q|quit|exit|bye)$/i.test(input)) break;
205
+ if (/^new$/i.test(input)) { state = await openFresh(io); continue; }
206
+ try {
207
+ await say(state, input, io);
208
+ } catch (error) {
209
+ io.out.write(`\n${error.message || error}\n`);
210
+ }
211
+ }
212
+ } finally {
213
+ rl.close();
214
+ }
215
+ return 0;
216
+ }
217
+
218
+ async function runOrbChat(args = [], io = { out: process.stdout }) {
219
+ const [verb, ...rest] = args;
220
+ if (verb === 'new') {
221
+ await openFresh(io);
222
+ return 0;
223
+ }
224
+ if (verb === 'say') {
225
+ const text = rest.join(' ').trim();
226
+ if (!text) {
227
+ io.out.write('Usage: atris orb say "what is on your mind"\n');
228
+ return 2;
229
+ }
230
+ await say(readState(), text, io);
231
+ return 0;
232
+ }
233
+ if (verb === 'pick') {
234
+ const state = readState();
235
+ if (!state.options.length) {
236
+ io.out.write('No choices yet. Start with: atris orb new\n');
237
+ return 2;
238
+ }
239
+ const number = Number(rest[0]);
240
+ if (!Number.isInteger(number) || number < 1 || number > state.options.length) {
241
+ io.out.write(`Pick a number from 1 to ${state.options.length}.\n`);
242
+ return 2;
243
+ }
244
+ await say(state, String(number), io);
245
+ return 0;
246
+ }
247
+ return interactive(io);
248
+ }
249
+
250
+ module.exports = { runOrbChat, resolveInput, turnBody, parseSseLines, readState, writeState, stateFile };
package/commands/orb.js CHANGED
@@ -89,6 +89,7 @@ function showHelp() {
89
89
  console.log('Usage: atris orb [--once] [--json] [--engine claude|codex|fast]');
90
90
  console.log(' atris orb <n> | atris orb --pick <n>');
91
91
  console.log(' atris orb scorecard [--days N]');
92
+ console.log(' atris orb chat | atris orb new | atris orb say "text" | atris orb pick <n>');
92
93
  console.log('');
93
94
  console.log('Choose a next move while background engine jobs keep working.');
94
95
  console.log('--json emits {moves:[{n,label,command}]} and exits.');
@@ -162,6 +163,11 @@ async function runOrb(args = []) {
162
163
  return 0;
163
164
  }
164
165
 
166
+ if (['chat', 'say', 'pick', 'new'].includes(args[0])) {
167
+ const { runOrbChat } = require('./orb-chat');
168
+ return runOrbChat(args[0] === 'chat' ? args.slice(1) : args);
169
+ }
170
+
165
171
  if (args[0] === 'scorecard') {
166
172
  const parsed = parseOrbScorecardDays(args.slice(1));
167
173
  if (!parsed.ok) {
package/lib/engine-ask.js CHANGED
@@ -14,6 +14,7 @@ const {
14
14
  canonicalEngineName,
15
15
  claudeModelId,
16
16
  currentClaudeModel,
17
+ DEFAULT_AGY_ASK_MODEL,
17
18
  engineFailureHealthStatus,
18
19
  setEngineHealth,
19
20
  } = require('./engine-registry');
@@ -252,6 +253,8 @@ function resolveAskModel(engine, modelName = '') {
252
253
  ? claudeModelId(requested)
253
254
  : currentClaudeModel(requested);
254
255
  }
256
+ // agy asks name their default so the receipt says which model answered.
257
+ if (engine === 'agy') return DEFAULT_AGY_ASK_MODEL;
255
258
  if (engine !== 'claude' && engine !== 'fable' && engine !== 'haiku') return '';
256
259
  // No model named: this is the unattended default, so it goes through the
257
260
  // spend gate. A model a person typed is their own yes.
@@ -334,19 +337,21 @@ function buildReadOnlyEngineInvocation(engineName, prompt, modelName = '', {
334
337
  };
335
338
  }
336
339
  if (engine === 'agy') {
337
- // agy prompts for tool permission even in plan mode and a headless ask
338
- // has no one to answer, so it denies itself `ls` and fails every ask
339
- // (verified live 2026-08-19). The sandbox keeps the run read-only while
340
- // skip-permissions lets sandboxed reads proceed unattended.
340
+ // Plan mode hangs headless: a one-line ask timed out at 60s and at 300s,
341
+ // while edit mode answered in about 10s (measured 2026-11-11). So the ask
342
+ // runs in edit mode from an empty folder made for it (scratchCwd), where
343
+ // there is nothing real to edit; the prompt carries any repo context.
344
+ // No skip-permissions: edits land only in that folder, and anything else
345
+ // that would need a yes is refused instead of auto-approved.
341
346
  return {
342
347
  engine,
343
348
  bin: profile.bin,
349
+ scratchCwd: true,
344
350
  args: [
345
- '--mode', 'plan',
351
+ '--mode', 'accept-edits',
346
352
  '--sandbox',
347
- '--dangerously-skip-permissions',
348
353
  '--print-timeout', `${Math.max(1, Math.ceil(timeoutMs / 1000))}s`,
349
- ...(model ? ['--model', model] : []),
354
+ '--model', model || DEFAULT_AGY_ASK_MODEL,
350
355
  ...effortArgs,
351
356
  '-p', request,
352
357
  ],
@@ -422,6 +427,20 @@ function buildAskSpawnEnv(sourceEnv = process.env) {
422
427
  return env;
423
428
  }
424
429
 
430
+ // An empty folder made for one ask, for engines whose ask runs in edit mode
431
+ // (scratchCwd). The engine starts there instead of the repo, and the folder
432
+ // is removed when the ask ends. Returns null for every other engine.
433
+ function openAskScratch(invocation) {
434
+ if (!invocation || !invocation.scratchCwd) return null;
435
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), `atris-ask-${invocation.engine || 'engine'}-`));
436
+ return {
437
+ cwd: dir,
438
+ close() {
439
+ try { fs.rmSync(dir, { recursive: true, force: true }); } catch {}
440
+ },
441
+ };
442
+ }
443
+
425
444
  function runAskProcess(invocation, {
426
445
  cwd = process.cwd(),
427
446
  timeoutMs = DEFAULT_ASK_TIMEOUT_MS,
@@ -433,6 +452,7 @@ function runAskProcess(invocation, {
433
452
  } = {}) {
434
453
  return new Promise((resolve) => {
435
454
  const startedAt = Date.now();
455
+ let scratch = null;
436
456
  const stdoutChunks = [];
437
457
  const stderrChunks = [];
438
458
  const stdoutState = { bytes: 0, truncated: false };
@@ -449,6 +469,7 @@ function runAskProcess(invocation, {
449
469
  const finish = (code, closeSignalValue, spawnError) => {
450
470
  if (finished) return;
451
471
  finished = true;
472
+ if (scratch) scratch.close();
452
473
  clearTimeout(timeoutTimer);
453
474
  if (hardStopTimer) clearTimeout(hardStopTimer);
454
475
  if (signal && typeof signal.removeEventListener === 'function') signal.removeEventListener('abort', onAbort);
@@ -515,8 +536,9 @@ function runAskProcess(invocation, {
515
536
  }
516
537
 
517
538
  try {
539
+ scratch = openAskScratch(invocation);
518
540
  child = spawnProcess(invocation.bin, invocation.args, {
519
- cwd,
541
+ cwd: scratch ? scratch.cwd : cwd,
520
542
  env: buildAskSpawnEnv(env || invocation.env || process.env),
521
543
  detached: process.platform !== 'win32',
522
544
  stdio: ['ignore', 'pipe', 'pipe'],
@@ -882,6 +904,7 @@ module.exports = {
882
904
  MAX_ASK_PROMPT_BYTES,
883
905
  parseEngineAskArgs,
884
906
  buildReadOnlyEngineInvocation,
907
+ openAskScratch,
885
908
  buildAskSpawnEnv,
886
909
  runAskProcess,
887
910
  runEngineAskJobs,
@@ -158,6 +158,9 @@ function engineFailureHealthStatus(result) {
158
158
  // (not build, see hostedFallbacks), and only for someone logged in to atris.
159
159
  // role_order: the engine's place for one job when it differs from its
160
160
  // fallback_order. Haiku searches first wherever the claude CLI is installed.
161
+ // An agy ask with no model named runs here: fast enough for a question,
162
+ // where High is too slow (see the agy models list below).
163
+ const DEFAULT_AGY_ASK_MODEL = 'gemini-4-carbon-medium';
161
164
  const ENGINE_SEED_META = Object.freeze({
162
165
  'atris-fast': Object.freeze({ tier: 'fast', roles: Object.freeze(['navigator']), models: Object.freeze(['atris fast']), duty: 'learning', fallback_order: 10, hosted: true }),
163
166
  // Test model on a capped free tier: a roster pick may use it, the router never hands it work.
@@ -191,6 +194,11 @@ const ENGINE_SEED_META = Object.freeze({
191
194
  // Matches `agy models` on agy 1.3.4, verified 2026-10-13.
192
195
  'gemini-4-argon-high',
193
196
  'gemini-4-argon-medium',
197
+ // Carbon on `agy models`, 2026-11-11. Medium is the ask default:
198
+ // High timed out at 150s on a 150-word question Medium answered in 25s.
199
+ 'gemini-4-carbon-low',
200
+ DEFAULT_AGY_ASK_MODEL,
201
+ 'gemini-4-carbon-high',
194
202
  'gemini-3.8-flash-high',
195
203
  'claude-opus-5-5-high',
196
204
  'claude-sonnet-5-5-high',
@@ -2491,6 +2499,7 @@ function engineDoctorReport(root = process.cwd()) {
2491
2499
  }
2492
2500
 
2493
2501
  module.exports = {
2502
+ DEFAULT_AGY_ASK_MODEL,
2494
2503
  ENGINE_ROLES,
2495
2504
  ENGINE_JOBS,
2496
2505
  SMALL_BUILD_JOB,
@@ -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', '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
@@ -17,7 +17,7 @@ const {
17
17
  resolveJobTeam,
18
18
  } = require('./engine-registry');
19
19
  const { spawnSync } = require('child_process');
20
- const { buildReadOnlyEngineInvocation, buildAskSpawnEnv, runAskProcess } = require('./engine-ask');
20
+ const { buildReadOnlyEngineInvocation, buildAskSpawnEnv, openAskScratch, runAskProcess } = require('./engine-ask');
21
21
  const { appendRosterRun, parseRunUsage } = require('./roster-runs');
22
22
  const { startEngineRun } = require('./engine-resource');
23
23
 
@@ -136,15 +136,22 @@ async function defaultPrepAsk({ engine, model, effort, prompt, cwd, timeoutMs })
136
136
  // run synchronously). Same command, same answer shape as runAskProcess.
137
137
  function defaultPrepAskSync({ engine, model, effort, prompt, cwd, timeoutMs }) {
138
138
  const invocation = buildReadOnlyEngineInvocation(engine, prompt, model, { timeoutMs, effort });
139
- const run = spawnSync(invocation.bin, invocation.args, {
140
- cwd,
141
- env: buildAskSpawnEnv(),
142
- encoding: 'utf8',
143
- timeout: timeoutMs,
144
- killSignal: 'SIGKILL',
145
- maxBuffer: 4 * 1024 * 1024,
146
- stdio: ['ignore', 'pipe', 'pipe'],
147
- });
139
+ // An edit-mode ask (agy) never starts in the checkout, same as runAskProcess.
140
+ const scratch = openAskScratch(invocation);
141
+ let run;
142
+ try {
143
+ run = spawnSync(invocation.bin, invocation.args, {
144
+ cwd: scratch ? scratch.cwd : cwd,
145
+ env: buildAskSpawnEnv(),
146
+ encoding: 'utf8',
147
+ timeout: timeoutMs,
148
+ killSignal: 'SIGKILL',
149
+ maxBuffer: 4 * 1024 * 1024,
150
+ stdio: ['ignore', 'pipe', 'pipe'],
151
+ });
152
+ } finally {
153
+ if (scratch) scratch.close();
154
+ }
148
155
  const stdout = String(run.stdout || '');
149
156
  const stderr = String(run.stderr || '');
150
157
  const timedOut = Boolean(run.error && run.error.code === 'ETIMEDOUT');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "atris",
3
- "version": "3.82.21",
3
+ "version": "3.82.23",
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",