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/CHANGELOG.md +13 -0
- package/dist/assets/index-BcqWv4NW.css +1 -0
- package/dist/assets/{index-Y1a-xw5C.js → index-qdaAZdZT.js} +370 -345
- package/dist/assets/{pdf-a7kwa8Fi.js → pdf-CKZBNrtF.js} +1 -1
- package/dist/index.html +17 -10
- package/docs/features.md +96 -0
- package/package.json +3 -2
- package/server/apps.js +146 -0
- package/server/chief.js +3 -1
- package/server/files.js +11 -0
- package/server/index.js +512 -26
- package/server/office-mcp.js +16 -1
- package/server/telegram.js +320 -0
- package/dist/assets/index-DxWppRAd.css +0 -1
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
|
-
|
|
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
|
|
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;
|
|
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
|
-
|
|
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
|
-
|
|
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;
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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();
|