agent-office-studio 0.9.1 → 0.9.2

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/server/index.js CHANGED
@@ -3,7 +3,7 @@ import fs from 'node:fs';
3
3
  import path from 'node:path';
4
4
  import crypto from 'node:crypto';
5
5
  import os from 'node:os';
6
- import { spawn, spawnSync } from 'node:child_process';
6
+ import { spawn, spawnSync, execFile } from 'node:child_process';
7
7
  import { fileURLToPath, pathToFileURL } from 'node:url';
8
8
  import { WebSocketServer } from 'ws';
9
9
  import { summarizeTool, stationFor } from './tools.js';
@@ -26,10 +26,12 @@ import { Guests } from './guests.js';
26
26
  import { Visits, visitJob } from './visits.js';
27
27
  import { findIcon } from './icons.js';
28
28
  import { Journeys } from './journeys.js';
29
+ import { Apps } from './apps.js';
29
30
  import * as files from './files.js';
30
31
  import { lanAddresses, phoneLinks, qrSvg, qrText } from './phone.js';
31
32
  import { Tunnel } from './tunnel.js';
32
33
  import { findCloudflared } from './cloudflared.js';
34
+ import { Telegram } from './telegram.js';
33
35
 
34
36
  const HERE = path.dirname(fileURLToPath(import.meta.url));
35
37
  const APP = path.resolve(HERE, '..');
@@ -63,6 +65,32 @@ try { fs.chmodSync(STATE_FILE, 0o600); } catch {}
63
65
  if (DATA_IN_ROOT) console.warn(`[office] The office's data (${DATA}) is inside the projects folder (${ROOT}). Job tokens are kept elsewhere and file tools are denied it, but a job there can still read it through the shell. Set OFFICE_DATA to a folder outside ${ROOT}.`);
64
66
  const auth = new Auth(DATA);
65
67
  const journeys = new Journeys(DATA);
68
+ // The boss's Telegram bot (see the Telegram section below).
69
+ const phoneThreads = new Set(); // office threads the boss has answered from Telegram
70
+ let lastPhoneSpeaker = null; // who the last message to the phone was from
71
+ const telegram = new Telegram({
72
+ dataDir: DATA,
73
+ // The tests' stand-in for api.telegram.org.
74
+ ...(process.env.OFFICE_TELEGRAM_API && { api: process.env.OFFICE_TELEGRAM_API }),
75
+ onMessage: ({ text, files, replyTo }) => {
76
+ const root = replyTo && state.chat.find((m) => m.id === replyTo && !m.thread);
77
+ // Answering from the phone (say, a flag) brings that thread, and the
78
+ // job's own, to the phone from then on.
79
+ if (root) for (const id of [root.id, threadJob(root)?.thread]) if (id) phoneThreads.add(id);
80
+ try { chat({ text, files, thread: root?.id, channel: GENERAL, via: 'telegram' }); } catch (err) { telegram.send(err.message); }
81
+ },
82
+ saveUpload: (name, buf) => {
83
+ if (buf.length > MAX_UPLOAD) return null;
84
+ name = path.basename(name).replace(/[\x00-\x1f\/\\:*?"<>|]/g, '_').replace(/^\.+/, '').slice(0, 120) || 'file';
85
+ const id = crypto.randomBytes(6).toString('hex');
86
+ fs.mkdirSync(uploadDir(id), { recursive: true, mode: 0o700 });
87
+ fs.writeFileSync(path.join(uploadDir(id), name), buf, { mode: 0o600 });
88
+ return id;
89
+ },
90
+ onNotice: (text) => post('chief', text, { channel: GENERAL, kind: 'telegram' }),
91
+ });
92
+ // The home screen's apps (apps.js). Every open office hears about a change.
93
+ const apps = new Apps(DATA, { repoPath, onChange: (list) => send({ t: 'apps', apps: list }) });
66
94
  const board = new Leaderboard(DATA, { onChange: () => send({ t: 'leaderboard', leaderboard: board.status() }) });
67
95
  const MAX_UPLOAD = 25 * 1024 * 1024;
68
96
  const MAX_LOG = 600;
@@ -70,6 +98,17 @@ const MAX_CHAT = 300;
70
98
  // After a turn ends, keep the session open briefly: a message that landed
71
99
  // at the last moment starts another turn instead of being lost.
72
100
  const GRACE_MS = 2500;
101
+ // How long a session whose input was closed gets to exit by itself.
102
+ const CLOSE_MS = 10000;
103
+ // A session mid-turn that has said nothing for this long is stuck. Waiting on
104
+ // the model, it streams every token, so silence means the request hung (the
105
+ // usual case: Amara's turn after two image reads never got an answer). A tool
106
+ // gets longer, since builds are quiet. STALL_MODEL_MIN / STALL_TOOL_MIN to tune.
107
+ const minutesEnv = (k, d) => (parseFloat(process.env[k]) > 0 ? parseFloat(process.env[k]) : d) * 60000;
108
+ const STALL_MODEL_MS = minutesEnv('STALL_MODEL_MIN', 5);
109
+ const STALL_TOOL_MS = minutesEnv('STALL_TOOL_MIN', 15);
110
+ // A hung request is restarted by the office this many times per job; after that the boss decides.
111
+ const AUTO_RESTARTS = 2;
73
112
 
74
113
  // The roster. Capacity grows with level, so later names are "hires".
75
114
  // Role, personality and background are defaults; the Team tab edits them.
@@ -440,6 +479,7 @@ const snapshot = () => ({
440
479
  usage,
441
480
  auth: auth.status(),
442
481
  leaderboard: board.status(),
482
+ apps: apps.list(),
443
483
  economy: economy.status(),
444
484
  links: links.status(),
445
485
  visitors: links.visiting(),
@@ -553,6 +593,7 @@ function post(from, text, extra = {}) {
553
593
  send({ t: 'chat', msg });
554
594
  save();
555
595
  voiceNote(msg);
596
+ relayToTelegram(msg);
556
597
  return msg;
557
598
  }
558
599
 
@@ -774,7 +815,7 @@ const OFFICE_TOOLS = [
774
815
  'mcp__office__office_roster', 'mcp__office__message_colleague', 'mcp__office__hand_off', 'mcp__office__flag_for_boss',
775
816
  'mcp__office__list_channels', 'mcp__office__create_channel', 'mcp__office__invite_to_channel', 'mcp__office__post_to_channel',
776
817
  'mcp__office__restart_office', 'mcp__office__clear_context', 'mcp__office__attach_files',
777
- 'mcp__office__heavy_work', 'mcp__office__keep_running',
818
+ 'mcp__office__heavy_work', 'mcp__office__keep_running', 'mcp__office__add_app',
778
819
  'mcp__office__office_shop', 'mcp__office__buy_item',
779
820
  'mcp__office__bets', 'mcp__office__place_bet', 'mcp__office__take_bet', 'mcp__office__cancel_bet',
780
821
  ];
@@ -802,6 +843,45 @@ function listRepos() {
802
843
  .sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' }));
803
844
  }
804
845
 
846
+ // Folders that finish a typed path: `/abs/pa`, `~/pa`, or `#project/sub/pa`
847
+ // (a project's own folders). Names only, never files; hidden folders only
848
+ // when asked for with a leading dot.
849
+ function completePaths(q) {
850
+ if (typeof q !== 'string' || q.length > 400 || q.includes('\0')) return [];
851
+ let base, head = '', rest = q;
852
+ const proj = q.match(/^#([\w.-]+)\/(.*)$/s);
853
+ if (proj) {
854
+ const name = listRepos().find((r) => r.replace(/\s+/g, '-') === proj[1]);
855
+ if (!name) return [];
856
+ base = path.join(ROOT, name); head = `#${proj[1]}/`; rest = proj[2];
857
+ } else if (q === '~' || q.startsWith('~/')) {
858
+ base = os.homedir(); head = '~/'; rest = q.slice(2);
859
+ } else if (q.startsWith('/')) {
860
+ base = path.sep; head = '/'; rest = q.slice(1);
861
+ } else return [];
862
+ const cut = rest.lastIndexOf('/');
863
+ const dirPart = rest.slice(0, cut + 1), prefix = rest.slice(cut + 1).toLowerCase();
864
+ const parts = dirPart.split('/').filter(Boolean);
865
+ if (proj && parts.includes('..')) return [];
866
+ const dir = path.resolve(base, ...parts);
867
+ if (proj) {
868
+ try { const real = fs.realpathSync(dir), top = fs.realpathSync(base); if (real !== top && !real.startsWith(top + path.sep)) return []; } catch { return []; }
869
+ }
870
+ let entries;
871
+ try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return []; }
872
+ const out = [];
873
+ for (const e of entries) {
874
+ if (e.name === 'node_modules' || e.name === '.git') continue;
875
+ if (e.name.startsWith('.') && !prefix.startsWith('.')) continue;
876
+ if (!e.name.toLowerCase().startsWith(prefix)) continue;
877
+ let isDir = e.isDirectory();
878
+ if (e.isSymbolicLink()) { try { isDir = fs.statSync(path.join(dir, e.name)).isDirectory(); } catch { continue; } }
879
+ if (isDir) out.push(e.name);
880
+ }
881
+ out.sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' }));
882
+ return out.slice(0, 30).map((name) => ({ name, path: `${head}${dirPart}${name}/` }));
883
+ }
884
+
805
885
  // `extra` tags a job, as a guest's (`from`, `guest`); it must be set before the job can start.
806
886
  function createJob({ repo, prompt, mode = 'edit', model = '', auto = false, agentId = '' }, extra = {}) {
807
887
  // A project at a host (`away`) isn't on this machine; its name is `web@host`.
@@ -906,7 +986,8 @@ function start(job, agentId) {
906
986
  const cwd = job.away ? awayDir(job) : repoPath(job.repo);
907
987
  // An away job reads files it doesn't own, so nothing that runs code here:
908
988
  // no built-in tools, no settings, hooks or connectors, only the office's.
909
- const args = ['-p', '--input-format', 'stream-json', '--output-format', 'stream-json', '--verbose',
989
+ // Partial messages are the heartbeat: without them a long answer is as quiet as a hung one.
990
+ const args = ['-p', '--input-format', 'stream-json', '--output-format', 'stream-json', '--verbose', '--include-partial-messages',
910
991
  ...(job.away ? ['--permission-mode', 'default', '--tools', '', '--setting-sources', '', '--strict-mcp-config', '--settings', JSON.stringify({ autoMemoryEnabled: false })] : MODES[job.mode].args)];
911
992
  if (job.model) args.push('--model', job.model);
912
993
  if (job.sessionId) args.push('--resume', job.sessionId);
@@ -961,6 +1042,7 @@ function start(job, agentId) {
961
1042
  }
962
1043
  const child = spawn(CLAUDE, args, { cwd, env: { CLAUDE_AUTOCOMPACT_PCT_OVERRIDE: String(settings.get('compactPct')), ...(job.guest ? { CLAUDE_CODE_DISABLE_AUTO_MEMORY: '1' } : {}), ...auth.env() }, stdio: ['pipe', 'pipe', 'pipe'] });
963
1044
  running.set(job.id, child);
1045
+ pulse.set(job.id, { heard: Date.now(), turn: true, tools: new Map() });
964
1046
  startProcs();
965
1047
  child.stdin.on('error', () => {});
966
1048
  write(child, first);
@@ -968,11 +1050,14 @@ function start(job, agentId) {
968
1050
  let buf = '';
969
1051
  child.stdout.setEncoding('utf8');
970
1052
  child.stdout.on('data', (chunk) => {
1053
+ heard(job);
971
1054
  buf += chunk;
972
1055
  let i;
973
1056
  while ((i = buf.indexOf('\n')) >= 0) {
974
1057
  const line = buf.slice(0, i);
975
1058
  buf = buf.slice(i + 1);
1059
+ // Token deltas only say the model is answering; the whole message follows.
1060
+ if (line.startsWith('{"type":"stream_event"')) continue;
976
1061
  if (line.trim()) onLine(job, line);
977
1062
  }
978
1063
  });
@@ -984,12 +1069,14 @@ function start(job, agentId) {
984
1069
  if (job.status !== 'working') return;
985
1070
  if (job.clearing && !job.stopRequested) return recycle(job);
986
1071
  if (job.switching && !job.stopRequested) return resume(job);
1072
+ if (job.restarting && !job.stopRequested) return revive(job);
987
1073
  if (job.stopRequested) finish(job, 'stopped', job.stopWhy || 'Stopped by you.');
988
1074
  else if (job.lastTurn) finish(job, job.lastTurn.ok ? 'done' : 'failed', job.lastTurn.ok ? null : job.lastTurn.error);
989
1075
  else finish(job, 'failed', errTail.trim().split('\n').pop() || why);
990
1076
  };
991
1077
  const ended = (why) => {
992
1078
  running.delete(job.id);
1079
+ pulse.delete(job.id);
993
1080
  fs.rm(mcpFile, { force: true }, () => {});
994
1081
  if (procs) { delete procs[job.id]; }
995
1082
  endGrace(job);
@@ -1030,6 +1117,7 @@ function cleanUpAfter(job) {
1030
1117
  // arrived meanwhile are all the new session carries.
1031
1118
  function recycle(job) {
1032
1119
  const note = job.clearNote;
1120
+ const by = job.clearedBy || 'self';
1033
1121
  const carried = job.context;
1034
1122
  const wake = [
1035
1123
  `Your original brief:\n${job.prompt}`,
@@ -1041,7 +1129,7 @@ function recycle(job) {
1041
1129
  Object.assign(job, { sessionId: null, clearNote: null, clearedBy: null, clearing: false, hinted: false, context: null, lastTurn: null, pending: wake,
1042
1130
  status: 'queued', queuedAt: Date.now(), waiting: false, carryOn: true });
1043
1131
  job.clears = (job.clears || 0) + 1;
1044
- job.lastClear = { note, by: job.clearedBy || 'self', at: Date.now() };
1132
+ job.lastClear = { note, by, at: Date.now() };
1045
1133
  tokens.delete(job.id);
1046
1134
  addLog(job, { kind: 'system', summary: `Cleared context${carried ? ` (${Math.round(carried / 1000)}k tokens)` : ''} and carried on fresh` });
1047
1135
  pushJob(job);
@@ -1062,6 +1150,142 @@ function resume(job) {
1062
1150
  setImmediate(pump);
1063
1151
  }
1064
1152
 
1153
+ // ---------- stuck sessions ----------
1154
+
1155
+ // Each live session's heartbeat: when it last wrote anything, whether a turn
1156
+ // is under way, and the tools it has called that haven't answered yet.
1157
+ // jobId -> { heard, turn, tools: Map(toolUseId -> { name, at }) }
1158
+ const pulse = new Map();
1159
+ const minutes = (ms) => { const m = Math.round(ms / 60000); return m < 1 ? 'under a minute' : `${m} min`; };
1160
+
1161
+ function heard(job) {
1162
+ const p = pulse.get(job.id);
1163
+ if (p) p.heard = Date.now();
1164
+ if (job.stuck) {
1165
+ addLog(job, { kind: 'system', summary: `Moving again after ${minutes(Date.now() - job.stuck.since)}.` });
1166
+ job.stuck = null;
1167
+ pushJob(job);
1168
+ }
1169
+ }
1170
+
1171
+ // A session mid-turn, not waiting on anyone, that has been silent too long.
1172
+ // The office's own tools are left out: a wait for the build lock or a
1173
+ // colleague is the office's to know about, not a hang.
1174
+ function checkStalls() {
1175
+ const now = Date.now();
1176
+ for (const [id, p] of pulse) {
1177
+ const job = state.jobs.find((j) => j.id === id);
1178
+ if (!job || job.status !== 'working' || job.stuck || !p.turn || job.waiting || graces.has(id)) continue;
1179
+ if (job.stopRequested || job.clearing || job.switching || job.restarting) continue;
1180
+ const open = [...p.tools.values()];
1181
+ if (open.some((t) => t.name.startsWith('mcp__office__'))) continue;
1182
+ const tool = open.sort((a, b) => a.at - b.at)[0];
1183
+ const quiet = now - p.heard;
1184
+ if (quiet < (tool ? STALL_TOOL_MS : STALL_MODEL_MS)) continue;
1185
+ flagStuck(job, tool, quiet);
1186
+ }
1187
+ }
1188
+ setInterval(checkStalls, Math.min(30000, STALL_MODEL_MS / 3)).unref();
1189
+
1190
+ function flagStuck(job, tool, quiet) {
1191
+ const last = [...job.log].reverse().find((e) => e.kind === 'tool')?.summary;
1192
+ const what = tool ? `${tool.name} has been running for ${minutes(Date.now() - tool.at)} with no output`
1193
+ : `no answer from the model for ${minutes(quiet)}${last ? ` (last step: ${clip(last, 60)})` : ''}`;
1194
+ job.stuck = { since: Date.now() - quiet, kind: tool ? 'tool' : 'model', tool: tool?.name || null, what };
1195
+ addLog(job, { kind: 'system', summary: `Looks stuck: ${what}.` });
1196
+ pushJob(job);
1197
+ // A hung request is the usual case, and resuming the session fixes it: its
1198
+ // transcript has everything up to the last tool result. A hung tool may be
1199
+ // waiting on something real (a prompt, a lock), so that is the boss's call.
1200
+ if (!tool && (job.autoRestarts || 0) < AUTO_RESTARTS) return restartJob(job, 'office');
1201
+ const who = nameOf(job.agentId);
1202
+ post('chief', `**Stuck?** · ${who} on "${clip(job.title, 80)}": ${what}.${job.autoRestarts ? ` The office already restarted it ${job.autoRestarts === 1 ? 'once' : `${job.autoRestarts} times`}.` : ''} Open the job to restart it or hand it to a free colleague, or tell me "get someone to take over from ${who}".`,
1203
+ { kind: 'stuck', jobId: job.id, thread: null, channel: GENERAL });
1204
+ }
1205
+
1206
+ // End a live session and start it again on the same transcript. The boss's
1207
+ // own restart, or the office's when a request hangs.
1208
+ function restartJob(job, by = 'boss') {
1209
+ const child = running.get(job.id);
1210
+ if (job.status !== 'working' || !child) throw new Error('That job isn’t running.');
1211
+ if (job.restarting || job.stopRequested) return;
1212
+ job.restarting = by;
1213
+ if (by === 'office') job.autoRestarts = (job.autoRestarts || 0) + 1;
1214
+ addLog(job, { kind: 'system', summary: by === 'office' ? 'Restarting the session to unstick it; it carries on from its last step.' : 'Boss restarted the session; it carries on from its last step.' });
1215
+ pushJob(job);
1216
+ child.kill('SIGTERM');
1217
+ setTimeout(() => running.get(job.id) === child && child.kill('SIGKILL'), 5000);
1218
+ }
1219
+
1220
+ // What the boss said after the agent's last sign of life: written to a
1221
+ // session that was stuck, so never read, and lost with it unless sent again.
1222
+ function unread(job) {
1223
+ const said = [];
1224
+ for (const e of [...job.log].reverse()) {
1225
+ if (e.kind === 'user') said.unshift(e.summary);
1226
+ else if (['text', 'tool', 'result', 'thinking'].includes(e.kind) || /^Clocked in/.test(e.summary)) break;
1227
+ }
1228
+ return said;
1229
+ }
1230
+
1231
+ function revive(job) {
1232
+ const by = job.restarting;
1233
+ const why = job.stuck?.what || (by === 'office' ? 'it had stopped responding' : 'the boss asked');
1234
+ const missed = unread(job).filter((m) => m !== job.prompt);
1235
+ const note = `Your session was restarted by ${by === 'office' ? 'the office' : 'the boss'}: ${why}. Everything up to your last tool result is still in your history and the project. ` +
1236
+ 'Carry on from where you were. If the same step hangs again (a large image, a command that never returns), do it a different way.';
1237
+ Object.assign(job, { restarting: null, stuck: null, lastTurn: null, status: 'queued', queuedAt: Date.now(), waiting: false, carryOn: true,
1238
+ pending: [
1239
+ job.sessionId ? null : `Your brief:\n${job.prompt}`,
1240
+ note,
1241
+ missed.length ? `Sent to you while you were stuck:\n${missed.join('\n\n')}` : null,
1242
+ job.pending,
1243
+ ].filter(Boolean).join('\n\n') });
1244
+ tokens.delete(job.id);
1245
+ pushJob(job);
1246
+ save();
1247
+ setImmediate(pump);
1248
+ }
1249
+
1250
+ // Hand a job to a free colleague: the stuck session ends, and they get the
1251
+ // brief, where it stood and the last steps taken, in the same thread. A
1252
+ // stuck session can't read messages, so taking over is the help that lands.
1253
+ function rescueJob(job, { agentId = '' } = {}) {
1254
+ if (job.guest || job.away || job.visitor || job.remote) throw new Error("That job belongs to another office.");
1255
+ if (!['working', 'queued', 'failed', 'stopped'].includes(job.status)) throw new Error('That job has finished.');
1256
+ const from = job.agentId;
1257
+ const busy = new Set(state.jobs.filter((j) => j.status === 'working' && j.id !== job.id).map((j) => j.agentId));
1258
+ const free = onFloor().filter((s) => s.id !== from && !busy.has(s.id));
1259
+ let to = agentId;
1260
+ if (to) {
1261
+ if (!hired().some((s) => s.id === to)) throw new Error('Pick someone from the office.');
1262
+ if (to === from) throw new Error(`That's ${nameOf(from)} already. Restart their session instead.`);
1263
+ } else {
1264
+ if (!free.length) throw new Error(`Everyone else is busy. Restart ${nameOf(from)}'s session instead, or pick someone to queue it for.`);
1265
+ to = free[Math.floor(Math.random() * free.length)].id;
1266
+ }
1267
+ const fromName = from ? nameOf(from) : 'Nobody';
1268
+ const steps = job.log.filter((e) => ['tool', 'text', 'error', 'user'].includes(e.kind)).slice(-15)
1269
+ .map((e) => `- ${e.kind === 'user' ? 'Boss: ' : e.kind === 'error' ? 'Error: ' : ''}${clip(String(e.summary).replace(/\s+/g, ' '), 200)}`);
1270
+ const brief = [
1271
+ `${from ? `${fromName} was on this job and got stuck` : 'This job got stuck'}${job.stuck ? ` (${job.stuck.what})` : ''}. The boss has handed it to you to finish.`,
1272
+ `Original brief:\n${job.prompt}`,
1273
+ `Where it stood: ${whereItStood(job)}`,
1274
+ steps.length ? `${fromName}'s last steps, oldest first:\n${steps.join('\n')}` : '',
1275
+ `Their work so far is in the project: check it before redoing anything. If they hung on a step (a large image, a command that never returned), do it a different way.`,
1276
+ ].filter(Boolean).join('\n\n');
1277
+ if (job.status === 'working' || job.status === 'queued') stopJob(job, `Handed to ${nameOf(to)}: it was stuck.`);
1278
+ addLog(job, { kind: 'system', summary: `Handed to ${nameOf(to)} to finish.` });
1279
+ const next = createJob({ repo: job.repo, prompt: brief, mode: job.mode, model: job.model, agentId: to },
1280
+ { title: job.title, thread: job.thread, channel: job.channel, ...(job.via && { via: job.via }), takeoverOf: job.id });
1281
+ job.takenOverBy = next.id;
1282
+ pushJob(job);
1283
+ const line = next.status === 'queued' ? `${nameOf(to)} is busy, so taking over "${clip(job.title, 80)}" from ${fromName} is next on their list.`
1284
+ : `${nameOf(to)} is taking over "${clip(job.title, 80)}" from ${fromName}.`;
1285
+ post('chief', line, { jobId: next.id, channel: job.channel, thread: job.thread || null });
1286
+ return next;
1287
+ }
1288
+
1065
1289
  // Set by the boss from the job card. Any job can be switched; one the chief
1066
1290
  // sized stops climbing on its own, since the boss has now chosen.
1067
1291
  function setModel(job, model) {
@@ -1073,13 +1297,36 @@ function setModel(job, model) {
1073
1297
  Object.assign(job, { model, autoModel: false });
1074
1298
  const live = job.status === 'working' && running.get(job.id);
1075
1299
  addLog(job, { kind: 'system', summary: `Boss moved this from ${from} to ${modelName(model)}${live ? '; switching after the current turn' : ''}` });
1076
- if (live) { job.switching = true; live.stdin.end(); }
1300
+ if (live) { job.switching = true; closeSession(job); }
1077
1301
  pushJob(job);
1078
1302
  }
1079
1303
 
1304
+ // End a live session's turn-taking: close its input so it exits, and ended()
1305
+ // takes it from there. A session with a background task still running (a
1306
+ // build, a watcher) does not exit on that: it stays up to report on the task,
1307
+ // and nothing written to it lands, so a clear or a reply sat unread until the
1308
+ // task ended. If it is still there after CLOSE_MS, it is ended instead.
1309
+ function closeSession(job) {
1310
+ const child = running.get(job.id);
1311
+ if (!child) return;
1312
+ child.stdin.end();
1313
+ setTimeout(() => {
1314
+ if (running.get(job.id) !== child) return;
1315
+ addLog(job, { kind: 'system', summary: 'The session did not close by itself (a background task was likely still running), so the office ended it.' });
1316
+ child.kill('SIGTERM');
1317
+ setTimeout(() => running.get(job.id) === child && child.kill('SIGKILL'), 5000);
1318
+ }, CLOSE_MS);
1319
+ }
1320
+
1080
1321
  function write(child, text) {
1081
1322
  if (!child.stdin.writable) return false;
1082
1323
  child.stdin.write(JSON.stringify({ type: 'user', message: { role: 'user', content: text } }) + '\n');
1324
+ // An idle session now has a turn to take, timed from here. One mid-turn
1325
+ // keeps its clock: a nudge to a hung session must not reset it.
1326
+ for (const [id, c] of running) {
1327
+ const p = c === child && pulse.get(id);
1328
+ if (p && !p.turn) Object.assign(p, { turn: true, heard: Date.now() });
1329
+ }
1083
1330
  return true;
1084
1331
  }
1085
1332
 
@@ -1140,6 +1387,8 @@ function onLine(job, line) {
1140
1387
  let e;
1141
1388
  try { e = JSON.parse(line); } catch { return; }
1142
1389
  if (e.type === 'assistant' && graces.has(job.id)) { endGrace(job); job.lastTurn = null; }
1390
+ // Anything but the end of a turn means one is under way (a background task's report starts one by itself).
1391
+ if (e.type !== 'result' && e.type !== 'system' && pulse.has(job.id)) pulse.get(job.id).turn = true;
1143
1392
 
1144
1393
  if (e.type === 'system' && e.subtype === 'init') {
1145
1394
  job.sessionId = e.session_id;
@@ -1171,6 +1420,7 @@ function onLine(job, line) {
1171
1420
  if (c.name === 'Bash') sampleSoon();
1172
1421
  job.tools++;
1173
1422
  job.turnTools++;
1423
+ pulse.get(job.id)?.tools.set(c.id, { name: c.name, at: Date.now() });
1174
1424
  const entry = { kind: 'tool', tool: c.name, summary: summarizeTool(c.name, c.input), station: stationFor(c.name) };
1175
1425
  if (c.name === 'TodoWrite' && Array.isArray(c.input?.todos)) {
1176
1426
  entry.todos = c.input.todos.slice(0, 12).map((t) => ({ text: String(t.content || '').slice(0, 80), done: t.status === 'completed' }));
@@ -1181,12 +1431,15 @@ function onLine(job, line) {
1181
1431
  }
1182
1432
  } else if (e.type === 'user') {
1183
1433
  for (const c of e.message?.content || []) {
1434
+ if (c.type === 'tool_result') pulse.get(job.id)?.tools.delete(c.tool_use_id);
1184
1435
  if (c.type === 'tool_result' && c.is_error) {
1185
1436
  const text = textOf(c.content).trim();
1186
1437
  addLog(job, { kind: 'error', summary: text.slice(0, 400), blocked: /permission|haven't granted/i.test(text) });
1187
1438
  }
1188
1439
  }
1189
1440
  } else if (e.type === 'result') {
1441
+ const p = pulse.get(job.id);
1442
+ if (p) { p.turn = false; p.tools.clear(); }
1190
1443
  job.cost += e.total_cost_usd || 0;
1191
1444
  job.turns++;
1192
1445
  // The result's usage is summed over every API call in the turn, so its cache
@@ -1216,7 +1469,7 @@ function onLine(job, line) {
1216
1469
  // starts the job again without its history.
1217
1470
  if (ok && job.clearNote && running.has(job.id)) {
1218
1471
  job.clearing = true;
1219
- running.get(job.id).stdin.end();
1472
+ closeSession(job);
1220
1473
  return;
1221
1474
  }
1222
1475
  const pending = waits.get(job.id) || [];
@@ -1230,8 +1483,7 @@ function onLine(job, line) {
1230
1483
  }
1231
1484
  graces.set(job.id, setTimeout(() => {
1232
1485
  graces.delete(job.id);
1233
- const child = running.get(job.id);
1234
- if (child) child.stdin.end();
1486
+ if (running.has(job.id)) closeSession(job);
1235
1487
  else finish(job, ok ? 'done' : 'failed', job.lastTurn?.error);
1236
1488
  }, GRACE_MS));
1237
1489
  }
@@ -1307,6 +1559,8 @@ function finish(job, status, error) {
1307
1559
  tokens.delete(job.id);
1308
1560
  guestTurn.delete(job.id);
1309
1561
  job.waiting = false;
1562
+ job.stuck = null;
1563
+ job.restarting = null;
1310
1564
  const before = progress();
1311
1565
  // An agent that ended its turn still needing the boss's input has not
1312
1566
  // finished: it is waiting. Nothing is marked, scored or announced as done.
@@ -1573,7 +1827,7 @@ function clearContext(ids) {
1573
1827
  job.clearedBy = 'boss';
1574
1828
  job.lastClear = { note: job.clearNote, by: 'boss', at: Date.now() };
1575
1829
  // Idle with the session held open: nothing will end the turn, so close it now.
1576
- if (job.waiting && running.has(job.id)) { job.clearing = true; running.get(job.id).stdin.end(); }
1830
+ if (job.waiting && running.has(job.id)) { job.clearing = true; closeSession(job); }
1577
1831
  cleared.push(s.name);
1578
1832
  continue;
1579
1833
  }
@@ -1632,7 +1886,13 @@ function parseTags(text) {
1632
1886
  return { agent: agents[0], agents: [...new Set(agents)], repo: projects[0], clean: clean || text };
1633
1887
  }
1634
1888
 
1635
- function chiefContext(text, agents, repo, channel = GENERAL) {
1889
+ // Where the boss wrote from, in words an agent can act on.
1890
+ const SOURCES = { telegram: 'Telegram, on their phone', web: 'the web chat, at the laptop' };
1891
+ const sourceNote = (via) => (SOURCES[via] ? `The boss wrote this from ${SOURCES[via]}.` : '');
1892
+ // A boss message handed to a job says where it came from, so "send it here" has an answer.
1893
+ const withSource = (text, via) => (SOURCES[via] ? `[${sourceNote(via)}]\n${text}` : text);
1894
+
1895
+ function chiefContext(text, agents, repo, channel = GENERAL, via = '') {
1636
1896
  const roster = STAFF.slice(0, capacity());
1637
1897
  const lines = ['Staff:'];
1638
1898
  for (const s of roster) {
@@ -1641,7 +1901,7 @@ function chiefContext(text, agents, repo, channel = GENERAL) {
1641
1901
  if (!j && state.dismissed.includes(s.id)) { lines.push(`- ${s.name} (${id}): dismissed, gone home`); continue; }
1642
1902
  if (!j) { lines.push(`- ${s.name} (${id}): free`); continue; }
1643
1903
  const last = [...j.log].reverse().find((e) => e.kind === 'tool' || e.kind === 'text');
1644
- lines.push(`- ${s.name} (${id}): working on job ${j.id} in ${j.repo}, asked for in #${channelOf(j.channel)}: "${j.title}". Latest: ${last ? last.summary.slice(0, 100).replace(/\n/g, ' ') : 'just starting'}`);
1904
+ lines.push(`- ${s.name} (${id}): working on job ${j.id} in ${j.repo}, asked for in #${channelOf(j.channel)}: "${j.title}". Latest: ${last ? last.summary.slice(0, 100).replace(/\n/g, ' ') : 'just starting'}${j.stuck ? `. LOOKS STUCK: ${j.stuck.what}` : ''}`);
1645
1905
  }
1646
1906
  const queued = state.jobs.filter((j) => j.status === 'queued');
1647
1907
  if (queued.length) {
@@ -1661,6 +1921,7 @@ function chiefContext(text, agents, repo, channel = GENERAL) {
1661
1921
  const reacts = Object.keys(m.reactions || {}).join('');
1662
1922
  lines.push(`${who}: ${m.text.slice(0, 200).replace(/\n/g, ' ')}${reacts ? ` [reactions: ${reacts}]` : ''}`);
1663
1923
  }
1924
+ if (SOURCES[via]) lines.push('', `${sourceNote(via)} "Here", "send it to me" and replies mean that place unless they say otherwise.`);
1664
1925
  lines.push('', `New message from the boss: ${text}`);
1665
1926
  if (agents.length === 1) lines.push(`Tagged agent: ${agents[0].name} (id ${agents[0].id})`);
1666
1927
  if (agents.length > 1) lines.push(`Tagged agents: ${agents.map((a) => `${a.name} (id ${a.id})`).join(', ')}`);
@@ -1668,8 +1929,9 @@ function chiefContext(text, agents, repo, channel = GENERAL) {
1668
1929
  return lines.join('\n');
1669
1930
  }
1670
1931
 
1671
- function hireWork({ repo, brief, agentId, mode, model, auto, say, files, thread, inThread, channel }) {
1932
+ function hireWork({ repo, brief, agentId, mode, model, auto, say, files, thread, inThread, channel, via }) {
1672
1933
  const job = createJob({ repo, prompt: withFiles(brief, files), mode, model, auto, agentId: agentId || '' });
1934
+ if (SOURCES[via]) job.via = via;
1673
1935
  job.thread = thread;
1674
1936
  job.channel = channel;
1675
1937
  const who = nameOf(job.agentId);
@@ -1688,7 +1950,7 @@ function hireWork({ repo, brief, agentId, mode, model, auto, say, files, thread,
1688
1950
 
1689
1951
  // `root` is the thread this conversation lives in: the boss's message, or the
1690
1952
  // thread they replied in (`inThread`), where the chief answers too.
1691
- async function dispatch(text, mode, model, files, root, inThread, channel) {
1953
+ async function dispatch(text, mode, model, files, root, inThread, channel, via) {
1692
1954
  const { agent, agents, repo, clean } = parseTags(text);
1693
1955
  const say = (line, extra = {}) => post('chief', line, { channel, ...(inThread ? { thread: root } : {}), ...extra });
1694
1956
  const open = state.jobs.filter((j) => j.status === 'working' || j.status === 'queued');
@@ -1696,7 +1958,7 @@ async function dispatch(text, mode, model, files, root, inThread, channel) {
1696
1958
  // A named project with nothing open in it can only be new work. Several
1697
1959
  // tagged agents still go past the chief, who decides how to split it.
1698
1960
  if (repo && agents.length < 2 && !open.some((j) => j.repo === repo)) {
1699
- return hireWork({ repo, brief: clean, agentId: agent?.id, mode, ...sized(model), files, thread: root, inThread, channel });
1961
+ return hireWork({ repo, brief: clean, agentId: agent?.id, mode, ...sized(model), files, thread: root, inThread, channel, via });
1700
1962
  }
1701
1963
 
1702
1964
  chiefBusy = true;
@@ -1709,7 +1971,7 @@ async function dispatch(text, mode, model, files, root, inThread, channel) {
1709
1971
  const said = files.length
1710
1972
  ? `${text}\n(Attached: ${files.map((f) => f.name).join(', ')}${shown.length ? `. ${shown.length === 1 ? 'The image is' : 'The images are'} attached for you to look at.` : ''})`
1711
1973
  : text;
1712
- d = await route(chiefContext(said, agents, repo, channel), { bin: CLAUDE, model: settings.get('chiefModel'), images: shown, env: auth.env() });
1974
+ d = await route(chiefContext(said, agents, repo, channel, via), { bin: CLAUDE, model: settings.get('chiefModel'), images: shown, env: auth.env() });
1713
1975
  state.chiefCost += d.cost || 0;
1714
1976
  nudgeUsage();
1715
1977
  } finally {
@@ -1730,7 +1992,8 @@ async function dispatch(text, mode, model, files, root, inThread, channel) {
1730
1992
  const up = d.escalate && job.status !== 'working' ? bump(job) : null;
1731
1993
  if (up) addLog(job, { kind: 'system', summary: `Sent back by the boss; moving up to ${TIER_LABEL[up]}` });
1732
1994
  answered(job);
1733
- deliver(job, withFiles(clean, files));
1995
+ if (SOURCES[via]) job.via = via;
1996
+ deliver(job, withSource(withFiles(clean, files), via));
1734
1997
  return post('chief', (d.say || `Passed to ${nameOf(job.agentId)}.`) + (up ? ` Running it on ${TIER_LABEL[up]} this time.` : ''), {
1735
1998
  jobId: job.id,
1736
1999
  channel,
@@ -1752,7 +2015,7 @@ async function dispatch(text, mode, model, files, root, inThread, channel) {
1752
2015
  // has. Otherwise each job goes to the chief's pick.
1753
2016
  for (const w of work) w.agentId = (work.length === 1 && agents.length === 1 && agent.id) || (onRoster(w.agentId) ? w.agentId : work.length === 1 ? agent?.id || '' : '');
1754
2017
  for (const w of work) {
1755
- hireWork({ repo: w.project, brief: (w.brief || '').trim() || clean, agentId: w.agentId, mode, ...sized(model, w.model), say: w.say || (work.length === 1 ? d.say : ''), files, thread: root, inThread, channel });
2018
+ hireWork({ repo: w.project, brief: (w.brief || '').trim() || clean, agentId: w.agentId, mode, ...sized(model, w.model), say: w.say || (work.length === 1 ? d.say : ''), files, thread: root, inThread, channel, via });
1756
2019
  }
1757
2020
  return;
1758
2021
  }
@@ -1765,6 +2028,17 @@ async function dispatch(text, mode, model, files, root, inThread, channel) {
1765
2028
  return say(clearContext(ids.length ? ids : hired().map((x) => x.id)));
1766
2029
  }
1767
2030
  if (d.action === 'standup') return void runStandup(true);
2031
+ if (d.action === 'restart' || d.action === 'takeover') {
2032
+ const job = state.jobs.find((j) => j.id === d.jobId) || (agent && workingJobOf(agent.id));
2033
+ if (!job) return say('Which job? Tag the agent who is stuck.');
2034
+ try {
2035
+ if (d.action === 'takeover') return void rescueJob(job, { agentId: d.agentId && d.agentId !== job.agentId ? d.agentId : '' });
2036
+ restartJob(job);
2037
+ return say(d.say || `Restarted ${nameOf(job.agentId)}'s session; they carry on from their last step.`, { jobId: job.id });
2038
+ } catch (err) {
2039
+ return say(err.message, { jobId: job.id });
2040
+ }
2041
+ }
1768
2042
  // An answer that is merely about a job gets the same link a follow-up does.
1769
2043
  // Asking "how did that go?" from another channel shouldn't dead-end just
1770
2044
  // because the job has already finished and nothing was passed on.
@@ -1790,8 +2064,59 @@ function remoteEnded(job) {
1790
2064
  post('chief', `${name} at @${job.remote.host} ${how} “${job.title}”.${said}`, { jobId: job.id, channel: job.channel, thread: job.thread || null });
1791
2065
  }
1792
2066
 
1793
- function chat({ text, mode = 'edit', model = '', files, thread, channel }) {
2067
+ // Full autonomy from Telegram. `/grant`, or the bare words, on their own;
2068
+ // `/grant all` for every job a permission stopped.
2069
+ const GRANT_WORDS = /^(\/grant|(grant\s+)?full\s+autonomy)(\s+(to\s+|for\s+)?(all|everyone))?[.!]?$/i;
2070
+ const GRANTED = 'You now have permission to run the commands that were blocked. Carry on from where you stopped.';
2071
+ // Stopped by a permission it lacked, and ours to raise.
2072
+ const blockedJobs = () => state.jobs
2073
+ .filter((j) => j.denials?.length && j.status !== 'working' && j.status !== 'queued' && !j.guest && !j.away && !j.remote)
2074
+ .sort((x, y) => (y.endedAt || 0) - (x.endedAt || 0));
2075
+ // In reply to a job's message (or in its thread), that job; otherwise the one
2076
+ // most recently stopped by a missing permission, or with `all`, every one.
2077
+ // `message` is the boss's own words from `/auto <words>` in a job's thread,
2078
+ // said in place of the stock line.
2079
+ function grantFromPhone(root, thread, message = '', all = false) {
2080
+ const say = (line) => post('chief', line, { channel: GENERAL, thread });
2081
+ if (root) {
2082
+ const job = threadJob(root);
2083
+ return void say(job ? grantJob(job, message) : 'That thread has no job of ours to grant full autonomy to.');
2084
+ }
2085
+ const blocked = blockedJobs();
2086
+ if (!blocked.length) return void say('Nothing is blocked on a permission. To give a job full autonomy, reply /grant to one of its messages.');
2087
+ const picked = all ? blocked : blocked.slice(0, 1);
2088
+ const rest = blocked.slice(picked.length);
2089
+ say([...picked.map((j) => grantJob(j)),
2090
+ rest.length ? `Still blocked: ${rest.map((j) => `${nameOf(j.agentId)} on “${clip(j.title, 60)}”`).join('; ')}. Send /grant again for the next, or /grant all for every one.` : '',
2091
+ ].filter(Boolean).join('\n'));
2092
+ }
2093
+
2094
+ // Raises one job to Full autonomy, as the app's grant button does, and says how it went.
2095
+ function grantJob(job, message = '') {
2096
+ const name = nameOf(job.agentId);
2097
+ // A guest's clearance is capped by their link, and an away job's by its
2098
+ // host; neither is the boss's to raise from here, nor from the app.
2099
+ if (job.guest || job.away || job.remote) return `“${clip(job.title, 80)}” runs under someone else’s office, so its clearance isn’t yours to raise.`;
2100
+ const was = job.mode;
2101
+ job.mode = 'auto';
2102
+ job.via = 'telegram';
2103
+ addLog(job, { kind: 'system', summary: `Full autonomy granted from Telegram${was === 'auto' ? ' (it already had it)' : `, up from ${MODES[was]?.label || was}`}.` });
2104
+ // A session already running keeps the permissions it started with: the
2105
+ // grant counts from its next turn, which is how the app's picker works too.
2106
+ if (job.status === 'working' || job.status === 'queued') {
2107
+ pushJob(job);
2108
+ if (message.trim()) { answered(job); deliver(job, withSource(message, 'telegram')); }
2109
+ return `${name} has full autonomy on “${clip(job.title, 80)}” from their next turn; the one under way keeps the permissions it started with.`;
2110
+ }
2111
+ answered(job);
2112
+ replyJob(job, { message: withSource(message.trim() || GRANTED, 'telegram'), mode: 'auto' });
2113
+ return `${name} has full autonomy and is carrying on with “${clip(job.title, 80)}”.`;
2114
+ }
2115
+
2116
+ function chat({ text, mode = 'edit', model = '', files, thread, channel, via }) {
1794
2117
  text = String(text || '').trim();
2118
+ // Before post(): a bot token must never land in the chat.
2119
+ if (via !== 'telegram' && /^\/telegram\b/i.test(text)) return void telegramCommand(text, channel);
1795
2120
  files = attachments(files);
1796
2121
  if (!text && !files.length) throw new Error('Say something first.');
1797
2122
  if (!MODES[mode]) throw new Error('Unknown mode.');
@@ -1803,6 +2128,7 @@ function chat({ text, mode = 'edit', model = '', files, thread, channel }) {
1803
2128
  const where = channelOf(root ? root.channel : channel);
1804
2129
  const msg = post('you', text, {
1805
2130
  channel: where,
2131
+ ...(SOURCES[via] && { via }),
1806
2132
  ...(files.length && { files: files.map(({ path: _, ...f }) => f) }),
1807
2133
  ...(root && { thread: root.id }),
1808
2134
  });
@@ -1812,10 +2138,25 @@ function chat({ text, mode = 'edit', model = '', files, thread, channel }) {
1812
2138
  try { requestRestart({ now: !!restart[1] }); } catch (err) { post('chief', err.message, { channel: where }); }
1813
2139
  return;
1814
2140
  }
2141
+ // The phone has no clearance picker, so it has words for it: `/grant` is the
2142
+ // app's "Grant full autonomy and continue", and `/auto` in front of a request
2143
+ // is the composer's picker set to Full autonomy.
2144
+ if (via === 'telegram') {
2145
+ const grant = !files.length && GRANT_WORDS.exec(text);
2146
+ if (grant) return void grantFromPhone(root, root?.id || msg.id, '', !!grant[3]);
2147
+ const auto = /^\/auto\b\s*/i.exec(text);
2148
+ if (auto) {
2149
+ mode = 'auto';
2150
+ text = text.slice(auto[0].length);
2151
+ const job = root && threadJob(root);
2152
+ if (job) return void grantFromPhone(root, root.id, withFiles(text, files));
2153
+ if (!text && !files.length) return void post('chief', 'Say what the job is after /auto, and it runs at full autonomy.', { channel: where, thread: root?.id || msg.id });
2154
+ }
2155
+ }
1815
2156
  if (!text) text = files.length === 1 ? 'Take a look at the attached file.' : 'Take a look at the attached files.';
1816
2157
  // A reply in a job's thread goes straight to that job; no routing needed.
1817
2158
  const job = root && threadJob(root);
1818
- if (job) { answered(job); return void deliver(job, withFiles(text, files)); }
2159
+ if (job) { answered(job); if (SOURCES[via]) job.via = via; return void deliver(job, withSource(withFiles(text, files), via)); }
1819
2160
  const remoteJob = root && guests.threadJob(root.id);
1820
2161
  const say = (line, extra = {}) => post('chief', line, { channel: where, thread: root?.id || msg.id, ...extra });
1821
2162
  if (remoteJob) {
@@ -1853,7 +2194,7 @@ function chat({ text, mode = 'edit', model = '', files, thread, channel }) {
1853
2194
  return;
1854
2195
  }
1855
2196
  chiefQueue = chiefQueue
1856
- .then(() => dispatch(text, mode, model, files, root?.id || msg.id, !!root, where))
2197
+ .then(() => dispatch(text, mode, model, files, root?.id || msg.id, !!root, where, via))
1857
2198
  .catch((err) => post('chief', `I couldn't route that one: ${err.message}`, { channel: where }));
1858
2199
  }
1859
2200
 
@@ -2133,8 +2474,10 @@ function officePrompt(agentId, job) {
2133
2474
  `Office tools: see colleagues, message them, hand off work, flag the boss, use channels. To show the boss an image or file (a screenshot, say), attach_files with absolute paths, then say it in your reply: it goes on your next message to them. Messages from colleagues arrive as user messages starting "Message from"; answer them with message_colleague. ` +
2134
2475
  `When your context has grown heavy and you are between steps, clear_context with notes to yourself lets you resume fresh and cheaper. ` +
2135
2476
  `Everyone shares one laptop. Before a heavy build or full test suite (Gradle, Xcode, an emulator, jest or vitest across a whole app, a docker build), call heavy_work start and wait your turn if told to; call heavy_work done the moment it ends. Run jest with --maxWorkers=2. Anything you leave running (dev servers, watchers, test copies of a server) is stopped when your job ends; if the boss needs one to stay up, keep_running it with its pid and say so in your reply. ` +
2477
+ `When the boss asks for an app for the office, build it as one self-contained HTML file in a project (no network: inline scripts, styles and data) and register it with add_app so it gets an icon on the home screen. ` +
2136
2478
  `Your updates collect in your job's thread, which the boss may not open: use flag_for_boss when you need them (question, decision, blocker, review), not for routine progress. If you flag a question, decision or blocker, you are not done: say plainly in your reply that you are waiting on the boss, and do not present the work as finished. ` +
2137
- `This job is discussed in #${channelOf(job?.channel)}; everyday talk stays in #general.` + payNote(agentId) + standupBrief(agentId) + recentWork(agentId, job);
2479
+ `This job is discussed in #${channelOf(job?.channel)}; everyday talk stays in #general. ` +
2480
+ (SOURCES[job?.via] ? `${sourceNote(job.via)} Each message from the boss says where it came from; when they say "here" or ask for something to be sent, answer in that place: your replies and attach_files in this job's thread reach them there. If a later message comes from the other place, follow that one. ` : '') + payNote(agentId) + standupBrief(agentId) + recentWork(agentId, job);
2138
2481
  }
2139
2482
 
2140
2483
  // Only said when there is something to spend it on, so it costs nothing to
@@ -2259,11 +2602,11 @@ const agentApi = {
2259
2602
  : `Delivered to ${target.name}.`;
2260
2603
  },
2261
2604
 
2262
- attach_files(me, { paths }) {
2605
+ async attach_files(me, { paths }) {
2263
2606
  if (!Array.isArray(paths) || !paths.length) throw new Error('Give the absolute paths of the files to attach.');
2264
2607
  const staged = (me.staged ||= []);
2265
2608
  if (staged.length + paths.length > MAX_ATTACH) throw new Error(`At most ${MAX_ATTACH} files go on one message.`);
2266
- for (const p of paths) staged.push(stageFile(p, me));
2609
+ for (const p of paths) staged.push(await stageFile(p, me));
2267
2610
  return `${staged.length} file${staged.length === 1 ? '' : 's'} will go with your next message to the boss (your final reply, a flag, or a channel post). Nothing is sent yet.`;
2268
2611
  },
2269
2612
 
@@ -2353,6 +2696,12 @@ const agentApi = {
2353
2696
  return `${holder} is running (for ${minutesSince(heavy.since)}), and only one heavy run goes at a time. Do not start yours. Finish any light work, then end your turn: you will get a message when it is your turn.`;
2354
2697
  },
2355
2698
 
2699
+ add_app(me, input) {
2700
+ const { project, ...rest } = input || {};
2701
+ const app = apps.add({ ...rest, repo: project }, nameOf(me.agentId));
2702
+ return `${app.name} is on the home screen (id ${app.id}); the boss opens it from Apps. Registering the same id again updates it.`;
2703
+ },
2704
+
2356
2705
  async keep_running(me, { pid, why }) {
2357
2706
  pid = Number(pid);
2358
2707
  if (!Number.isInteger(pid) || pid < 2) throw new Error('Give the pid of the process to keep.');
@@ -2507,6 +2856,33 @@ const IMAGE_TYPES = { '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image
2507
2856
  // Audio plays in the chat bubble, so these are served inline like images.
2508
2857
  const AUDIO_TYPES = { '.webm': 'audio/webm', '.m4a': 'audio/mp4', '.mp4': 'audio/mp4', '.mp3': 'audio/mpeg', '.wav': 'audio/wav', '.ogg': 'audio/ogg', '.oga': 'audio/ogg', '.opus': 'audio/ogg', '.aac': 'audio/aac', '.flac': 'audio/flac' };
2509
2858
  const ext = (name) => path.extname(name).toLowerCase();
2859
+
2860
+ // iPhone photos arrive as HEIC, which neither the Read tool nor most browsers
2861
+ // can open. They are converted to JPEG on the way in (sips ships with macOS;
2862
+ // ImageMagick or libheif's heif-convert stand in elsewhere). If nothing can
2863
+ // convert it, the original stays and goes by as a plain file.
2864
+ const HEIC = new Set(['.heic', '.heif']);
2865
+ function convertCommands(src, dst) {
2866
+ return [
2867
+ ['sips', ['-s', 'format', 'jpeg', '-s', 'formatOptions', '85', src, '--out', dst]],
2868
+ ['magick', [src, '-auto-orient', '-quality', '85', dst]],
2869
+ ['heif-convert', ['-q', '85', src, dst]],
2870
+ ];
2871
+ }
2872
+ async function heicToJpeg(file) {
2873
+ if (!HEIC.has(ext(file))) return file;
2874
+ const dst = file.slice(0, -ext(file).length) + '.jpg';
2875
+ for (const [cmd, args] of convertCommands(file, dst)) {
2876
+ const ok = await new Promise((done) => execFile(cmd, args, { timeout: 60000 }, (err) => done(!err)));
2877
+ if (ok && fs.existsSync(dst) && fs.statSync(dst).size > 0) {
2878
+ fs.rmSync(file, { force: true });
2879
+ fs.chmodSync(dst, 0o600);
2880
+ return dst;
2881
+ }
2882
+ fs.rmSync(dst, { force: true });
2883
+ }
2884
+ return file;
2885
+ }
2510
2886
  const uploadDir = (id) => (typeof id === 'string' && /^[a-f0-9]{12}$/.test(id) ? path.join(UPLOADS, id) : null);
2511
2887
 
2512
2888
  function upload(req) {
@@ -2529,7 +2905,7 @@ function upload(req) {
2529
2905
  });
2530
2906
  req.on('error', fail);
2531
2907
  out.on('error', fail);
2532
- out.on('finish', () => resolve(attachments([id])[0]));
2908
+ out.on('finish', () => heicToJpeg(file).then(() => resolve(attachments([id])[0]), fail));
2533
2909
  req.pipe(out);
2534
2910
  });
2535
2911
  }
@@ -2554,7 +2930,7 @@ function attachments(ids) {
2554
2930
  // Full autonomy that is its project's files, links resolved, never the office's
2555
2931
  // data, so attaching can't copy ~/.ssh/id_rsa where every agent can read it.
2556
2932
  const MAX_ATTACH = 6;
2557
- function stageFile(file, job) {
2933
+ async function stageFile(file, job) {
2558
2934
  if (!path.isAbsolute(String(file || ''))) throw new Error(`${file}: use an absolute path.`);
2559
2935
  let src;
2560
2936
  try { src = fs.realpathSync(String(file)); } catch { throw new Error(`${file}: no such file.`); }
@@ -2572,6 +2948,7 @@ function stageFile(file, job) {
2572
2948
  const name = path.basename(src).replace(/[\x00-\x1f\/\\:*?"<>|]/g, '_').replace(/^\.+/, '').slice(0, 120) || 'file';
2573
2949
  fs.copyFileSync(src, path.join(dir, name));
2574
2950
  fs.chmodSync(path.join(dir, name), 0o600);
2951
+ await heicToJpeg(path.join(dir, name));
2575
2952
  const { path: _, ...f } = attachments([id])[0];
2576
2953
  return f;
2577
2954
  }
@@ -2856,6 +3233,49 @@ const server = http.createServer(async (req, res) => {
2856
3233
  return json(res, err.code >= 400 && err.code < 600 ? err.code : 500, { error: err.message });
2857
3234
  }
2858
3235
  }
3236
+ // A self-contained HTML page from a project, for the preview pane. It runs
3237
+ // its own inline scripts and styles but sits in an opaque origin (sandbox,
3238
+ // no allow-same-origin) with no network, so it can't touch the office.
3239
+ if (req.method === 'GET' && url.pathname === '/api/files/page') {
3240
+ const dir = repoPath(url.searchParams.get('repo'));
3241
+ if (!dir) return json(res, 404, { error: 'Unknown project folder.' });
3242
+ try {
3243
+ const f = files.page(dir, url.searchParams.get('path') || '');
3244
+ res.writeHead(200, {
3245
+ 'content-type': 'text/html; charset=utf-8', 'content-length': f.size, 'cache-control': 'no-store', 'x-content-type-options': 'nosniff',
3246
+ 'content-security-policy': "default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; img-src data: blob:; font-src data:; media-src data: blob:; sandbox allow-scripts",
3247
+ });
3248
+ return fs.createReadStream(f.abs).pipe(res);
3249
+ } catch (err) {
3250
+ return json(res, err.code >= 400 && err.code < 600 ? err.code : 500, { error: err.message });
3251
+ }
3252
+ }
3253
+ // The home screen's apps: list them, add or update one, remove one, or
3254
+ // put them in order (body: { order: [ids] }).
3255
+ if (url.pathname === '/api/apps' && ['GET', 'POST', 'DELETE'].includes(req.method)) {
3256
+ try {
3257
+ if (req.method === 'POST') {
3258
+ const body = await readBody(req);
3259
+ if (Array.isArray(body?.order)) apps.reorder(body.order);
3260
+ else return json(res, 200, { app: apps.add(body, 'boss'), apps: apps.list() });
3261
+ }
3262
+ if (req.method === 'DELETE') apps.remove(url.searchParams.get('id') || '');
3263
+ return json(res, 200, { apps: apps.list() });
3264
+ } catch (err) {
3265
+ return json(res, err.code >= 400 && err.code < 600 ? err.code : 500, { error: err.message });
3266
+ }
3267
+ }
3268
+ // An app's own saved data (web/src/apps.js passes the page's requests on).
3269
+ if (url.pathname === '/api/apps/data' && ['GET', 'PUT'].includes(req.method)) {
3270
+ try {
3271
+ if (req.method === 'GET') return json(res, 200, { value: apps.readData(url.searchParams.get('id') || '') });
3272
+ const body = await readBody(req, 1_100_000);
3273
+ apps.writeData(body?.id || '', body?.value);
3274
+ return json(res, 200, { ok: true });
3275
+ } catch (err) {
3276
+ return json(res, err.code >= 400 && err.code < 600 ? err.code : 500, { error: err.message });
3277
+ }
3278
+ }
2859
3279
  // The boss's own file editor: a project's folders and text files.
2860
3280
  if (url.pathname === '/api/files' && ['GET', 'PUT'].includes(req.method)) {
2861
3281
  const body = req.method === 'PUT' ? await readBody(req, 2 * files.MAX_BYTES) : null;
@@ -2869,6 +3289,10 @@ const server = http.createServer(async (req, res) => {
2869
3289
  return json(res, err.code >= 400 && err.code < 600 ? err.code : 500, { error: err.message });
2870
3290
  }
2871
3291
  }
3292
+ // Folder completion for the chat box: what a half-typed path could become.
3293
+ if (req.method === 'GET' && url.pathname === '/api/paths') {
3294
+ return json(res, 200, { items: completePaths(url.searchParams.get('q') || '') });
3295
+ }
2872
3296
  if (req.method === 'GET' && url.pathname === '/api/repos') return json(res, 200, { root: ROOT, repos: listRepos() });
2873
3297
  // A project's user journey map, and the job drawing it if one is under way.
2874
3298
  if (url.pathname === '/api/journeys' && ['GET', 'POST'].includes(req.method)) {
@@ -3086,7 +3510,7 @@ const server = http.createServer(async (req, res) => {
3086
3510
  react(parts[2], (await readBody(req)).emoji);
3087
3511
  return json(res, 200, { ok: true });
3088
3512
  }
3089
- if (req.method === 'POST' && url.pathname === '/api/chat') { chat(await readBody(req)); return json(res, 202, { ok: true }); }
3513
+ if (req.method === 'POST' && url.pathname === '/api/chat') { chat({ ...(await readBody(req)), via: 'web' }); return json(res, 202, { ok: true }); }
3090
3514
  if (req.method === 'GET' && url.pathname === '/api/channels') return json(res, 200, { channels: state.channels });
3091
3515
  if (req.method === 'POST' && url.pathname === '/api/channels') {
3092
3516
  const { name, purpose } = await readBody(req);
@@ -3177,6 +3601,8 @@ const server = http.createServer(async (req, res) => {
3177
3601
  return json(res, 200, { diff: await fileDiff(cwd, job.changes, url.searchParams.get('path') || '') });
3178
3602
  }
3179
3603
  if (req.method === 'POST' && parts[3] === 'stop') { stopJob(job); return json(res, 200, { ok: true }); }
3604
+ if (req.method === 'POST' && parts[3] === 'restart') { restartJob(job); return json(res, 200, { ok: true }); }
3605
+ if (req.method === 'POST' && parts[3] === 'rescue') return json(res, 200, publicJob(rescueJob(job, await readBody(req)), 0));
3180
3606
  if (req.method === 'POST' && parts[3] === 'model') { setModel(job, (await readBody(req)).model); return json(res, 200, { ok: true }); }
3181
3607
  if (req.method === 'POST' && parts[3] === 'reply') { replyJob(job, await readBody(req)); return json(res, 200, { ok: true }); }
3182
3608
  }
@@ -3216,6 +3642,64 @@ server.on('error', (err) => {
3216
3642
  console.error(`Port ${PORT} is already in use. Start the office with another: agentstudio --port <n>, or PORT=<n>.`);
3217
3643
  process.exit(78);
3218
3644
  });
3645
+ // ---------- Telegram ----------
3646
+ //
3647
+ // The boss's own bot, for when they are away from the laptop: what they send
3648
+ // it goes into #general as if typed here, and what is said to them comes back.
3649
+ // See server/telegram.js for pairing and who may use it.
3650
+
3651
+ // What reaches the phone: the chief's top-level notes in #general (finished
3652
+ // jobs, flags, the standup), and everything said in a thread the boss
3653
+ // started from Telegram. Not the break room, not agent-to-agent asides, and
3654
+ // not what the boss typed themselves.
3655
+ function relayToTelegram(msg) {
3656
+ if (!telegram?.paired || msg.from === 'you' || msg.to || msg.kind === 'chill' || msg.kind === 'telegram' || msg.channel === CHILL) return;
3657
+ const root = msg.thread && state.chat.find((m) => m.id === msg.thread);
3658
+ const fromPhone = root?.via === 'telegram' || phoneThreads.has(msg.thread);
3659
+ if (!fromPhone && !(msg.from === 'chief' && !msg.thread && msg.channel === GENERAL)) return;
3660
+ // Read like a chat, not a log: the chief, who carries most of it and names
3661
+ // whoever it is about, speaks unlabelled; an agent's name heads their
3662
+ // message only when the speaker changes.
3663
+ const named = msg.from !== 'chief' && msg.from !== lastPhoneSpeaker;
3664
+ lastPhoneSpeaker = msg.from;
3665
+ const head = named ? `${nameOf(msg.from)}:\n` : '';
3666
+ const thread = msg.thread || msg.id;
3667
+ const files = attachments((msg.files || []).map((x) => x.id));
3668
+ // The app puts a grant button on a blocked job's message; the phone gets the word for it.
3669
+ const blocked = (msg.denials?.length || msg.kind === 'finished') && state.jobs.find((j) => j.id === msg.jobId && j.denials?.length && !j.guest && !j.away);
3670
+ const hint = blocked ? `\n\nBlocked from using ${blocked.denials.join(', ')}. Reply /grant to give ${nameOf(blocked.agentId)} full autonomy and let them carry on.` : '';
3671
+ if (String(msg.text || '').trim()) telegram.send(`${head}${msg.text}${hint}`, thread);
3672
+ // Attachments follow as photos, videos or files. Voice replies are added
3673
+ // later and separately, so they never come through here.
3674
+ files.forEach((f, i) => telegram.sendFile(f.path, { name: f.name, caption: !i && named && !String(msg.text || '').trim() ? nameOf(msg.from) : '', thread }));
3675
+ }
3676
+
3677
+ // `/telegram <token>` connects a bot, `/telegram pair` gives a new pairing
3678
+ // code, `/telegram off` forgets the bot, `/telegram` says how it stands.
3679
+ // Answered in the office only, and never posted, so the token is kept out of
3680
+ // the chat.
3681
+ function telegramCommand(text, channel) {
3682
+ const where = channelOf(channel);
3683
+ const say = (line) => post('chief', line, { channel: where, kind: 'telegram' });
3684
+ const arg = text.replace(/^\/telegram\b/i, '').trim();
3685
+ const pairLine = (bot, code) => `Now open Telegram, find @${bot}, and send it:\n\n/pair ${code}\n\nThe code works once, for 15 minutes, and only in a private chat with the bot. Whoever sends it becomes the only account the bot will listen to.`;
3686
+ if (/^off$/i.test(arg)) { telegram.disconnect(); return say('Telegram is off. The bot token and the paired account are forgotten; nothing reaches the bot any more.'); }
3687
+ if (/^pair$/i.test(arg)) {
3688
+ if (!telegram.status().on) return say('No bot yet. Send /telegram followed by the token @BotFather gave you.');
3689
+ if (telegram.paired) return say('Already paired. To pair a different account, send /telegram off, then connect the bot again.');
3690
+ return say(pairLine(telegram.bot || 'your bot', telegram.newPairing()));
3691
+ }
3692
+ if (!arg) {
3693
+ const st = telegram.status();
3694
+ return say(!st.on ? 'Telegram is off. Make a bot with @BotFather in Telegram, then send /telegram followed by its token.'
3695
+ : st.paired ? `Telegram is on and paired${st.bot ? ` (@${st.bot})` : ''}.${st.error ? ` But: ${st.error}` : ''} /telegram off turns it off.`
3696
+ : 'Telegram has a bot but is not paired yet. /telegram pair gives a code.');
3697
+ }
3698
+ telegram.connect(arg)
3699
+ .then(({ bot, code }) => say(`Connected to @${bot}. ${pairLine(bot, code)}`))
3700
+ .catch((err) => say(`Telegram didn't take that: ${err.message}`));
3701
+ }
3702
+
3219
3703
  server.listen(PORT, LAN ? '0.0.0.0' : '127.0.0.1', () => {
3220
3704
  // The key is in this line on purpose: it's how the boss gets in. It only
3221
3705
  // goes to this terminal (or the supervisor's), never to a log file. The
@@ -3240,10 +3724,12 @@ server.listen(PORT, LAN ? '0.0.0.0' : '127.0.0.1', () => {
3240
3724
  refreshUsage();
3241
3725
  setInterval(standupDue, 60 * 1000);
3242
3726
  setTimeout(standupDue, 15 * 1000);
3727
+ telegram.start();
3243
3728
  });
3244
3729
 
3245
3730
  function shutdown(code = 0) {
3246
3731
  tunnel.drop();
3732
+ telegram.stop();
3247
3733
  for (const child of running.values()) child.kill('SIGTERM');
3248
3734
  clearTimeout(saveTimer);
3249
3735
  board.flush();