@openagents-org/agent-launcher 0.2.192 → 0.2.194

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.
@@ -0,0 +1,69 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * The placeholder an npm package ships where its native binary goes.
5
+ *
6
+ * opencode-ai and @anthropic-ai/claude-code are both published this way: the
7
+ * package's `bin` (bin/opencode.exe, bin/claude.exe) is a few hundred bytes of
8
+ * shell that prints "postinstall … not run" and exits 1, and the package's
9
+ * postinstall script swaps in the real binary from a per-platform optional
10
+ * dependency. Skip that script — `ignore-scripts=true` in an npmrc,
11
+ * `--ignore-scripts`, pnpm and bun by default — and the install still exits 0
12
+ * with the placeholder sitting where the CLI should be. It resolves on PATH
13
+ * and it has the right name, so nothing about it looks like a missing install:
14
+ * the launcher reports the agent installed, and every run fails on whatever
15
+ * check happens to come next.
16
+ */
17
+
18
+ const fs = require('fs');
19
+ const path = require('path');
20
+
21
+ // A native CLI is tens of megabytes; the placeholders are about 500 bytes.
22
+ const STUB_MAX_BYTES = 4096;
23
+ const STUB_SIGNATURE = /postinstall (script )?(was|did) not run|native binary not installed/i;
24
+
25
+ /** Whether text a CLI printed is its package's placeholder talking. */
26
+ function isPostinstallStubOutput(text) {
27
+ return STUB_SIGNATURE.test(String(text || ''));
28
+ }
29
+
30
+ function isStubFile(file) {
31
+ try {
32
+ const st = fs.statSync(file);
33
+ if (!st.isFile() || st.size > STUB_MAX_BYTES) return false;
34
+ return STUB_SIGNATURE.test(fs.readFileSync(file, 'utf-8'));
35
+ } catch {
36
+ return false;
37
+ }
38
+ }
39
+
40
+ /**
41
+ * The files an npm `.cmd` shim launches. The shim is a wrapper npm wrote
42
+ * beside the package, never the placeholder itself — the placeholder is what
43
+ * it points at (`"%dp0%\node_modules\opencode-ai\bin\opencode.exe" %*`).
44
+ */
45
+ function cmdShimTargets(file) {
46
+ if (!/\.cmd$/i.test(file)) return [];
47
+ try {
48
+ if (fs.statSync(file).size > STUB_MAX_BYTES) return [];
49
+ const dir = path.dirname(file);
50
+ const text = fs.readFileSync(file, 'utf-8');
51
+ return [...text.matchAll(/"%(?:~dp0|dp0%)\\?([^"\r\n]+)"/g)]
52
+ .map((m) => path.join(dir, ...m[1].split(/[\\/]+/)));
53
+ } catch {
54
+ return [];
55
+ }
56
+ }
57
+
58
+ /**
59
+ * Whether `file` is such a placeholder — directly, through the symlink npm
60
+ * puts in `.bin`, or through a Windows `.cmd` shim. Read off disk rather than
61
+ * by running it: on Windows the placeholder is shell text named `.exe`, and
62
+ * starting it raises a "this app can't run on your PC" dialog.
63
+ */
64
+ function isPostinstallStub(file) {
65
+ if (!file) return false;
66
+ return [file, ...cmdShimTargets(file)].some(isStubFile);
67
+ }
68
+
69
+ module.exports = { isPostinstallStub, isPostinstallStubOutput };
@@ -0,0 +1,97 @@
1
+ 'use strict';
2
+ /**
3
+ * v1.1 M5 — the shared work brief.
4
+ *
5
+ * Every thread has one brief (objective, owner, latest result, open
6
+ * questions, next step) that people read instead of scrolling the
7
+ * transcript. Agents keep it current after each meaningful step.
8
+ *
9
+ * workspace_get_brief { channel? }
10
+ * workspace_update_brief { objective?, latest_result?, open_questions?, next_step?, owner? }
11
+ *
12
+ * Only the fields passed to update are changed; the backend records the
13
+ * writer as `openagents:<agent>`.
14
+ */
15
+
16
+ const defs = [
17
+ {
18
+ name: 'workspace_get_brief',
19
+ description:
20
+ 'Read the shared work brief of a thread: objective, owner of the next step, latest result, ' +
21
+ 'open questions and next step, plus who directs the thread (director_email). Defaults to the current thread.',
22
+ inputSchema: {
23
+ type: 'object',
24
+ properties: {
25
+ channel: { type: 'string', description: 'Thread id (default: the current thread)' },
26
+ },
27
+ },
28
+ },
29
+ {
30
+ name: 'workspace_update_brief',
31
+ description:
32
+ 'Update the current thread\'s shared work brief. Pass only the fields that changed. ' +
33
+ 'Call it after each meaningful step so people can see where the work stands without reading the transcript.',
34
+ inputSchema: {
35
+ type: 'object',
36
+ properties: {
37
+ objective: { type: 'string', description: 'What this thread is trying to achieve (one or two sentences)' },
38
+ latest_result: { type: 'string', description: 'The most recent concrete outcome (what was produced / verified)' },
39
+ open_questions: { type: 'array', items: { type: 'string' }, description: 'Questions blocking or shaping the work. Replaces the list.' },
40
+ next_step: { type: 'string', description: 'The very next action' },
41
+ owner: { type: 'string', description: 'Who owns the next step: "openagents:<agent>" or "human:<email>"' },
42
+ },
43
+ },
44
+ },
45
+ ];
46
+
47
+ const FIELDS = ['objective', 'latest_result', 'open_questions', 'next_step', 'owner'];
48
+
49
+ function renderBrief(b) {
50
+ if (!b) return 'No brief yet.';
51
+ const lines = [`Brief for ${b.channel || 'this thread'}:`];
52
+ if (b.director_email) lines.push(`Directed by: ${b.director_email}`);
53
+ lines.push(`Objective: ${b.objective || '—'}`);
54
+ lines.push(`Owner of next step: ${b.owner || '—'}`);
55
+ lines.push(`Latest result: ${b.latest_result || '—'}`);
56
+ const qs = Array.isArray(b.open_questions) ? b.open_questions : [];
57
+ lines.push(qs.length ? `Open questions:\n${qs.map((q) => ` - ${q}`).join('\n')}` : 'Open questions: —');
58
+ lines.push(`Next step: ${b.next_step || '—'}`);
59
+ if (b.updated_by) lines.push(`Updated by ${b.updated_by}${b.updated_at ? ` at ${b.updated_at}` : ''}`);
60
+ return lines.join('\n');
61
+ }
62
+
63
+ async function handle(server, name, args, helpers) {
64
+ const { text } = helpers;
65
+ args = args || {};
66
+
67
+ if (name === 'workspace_get_brief') {
68
+ const channel = (args.channel && String(args.channel).trim()) || server.channelName;
69
+ if (!channel) return text('No thread in scope — pass `channel`.');
70
+ const brief = await server.ws.getBrief(server.workspaceId, server.token, channel);
71
+ return text(renderBrief(brief));
72
+ }
73
+
74
+ if (name === 'workspace_update_brief') {
75
+ if (!server.channelName) return text('No thread in scope — the brief belongs to a thread.');
76
+ const fields = {};
77
+ for (const k of FIELDS) {
78
+ if (args[k] === undefined) continue;
79
+ if (k === 'open_questions') {
80
+ fields[k] = Array.isArray(args[k]) ? args[k].map((q) => String(q)) : [];
81
+ } else {
82
+ fields[k] = args[k] === null ? null : String(args[k]);
83
+ }
84
+ }
85
+ if (Object.keys(fields).length === 0) {
86
+ return text('Nothing to update — pass at least one of: ' + FIELDS.join(', ') + '.');
87
+ }
88
+ const brief = await server.ws.putBrief(server.workspaceId, server.token, server.channelName, fields, {
89
+ source: `openagents:${server.agentName}`,
90
+ });
91
+ return text(`Brief updated (${Object.keys(fields).join(', ')}).\n${renderBrief(brief)}`);
92
+ }
93
+
94
+ return undefined;
95
+ }
96
+
97
+ module.exports = { defs, handle };
@@ -0,0 +1,28 @@
1
+ 'use strict';
2
+ /**
3
+ * Roadmap v1.1 workspace tools — one module per milestone so parallel work
4
+ * never edits the same file. Each module exports:
5
+ * defs : MCP tool definitions (array)
6
+ * handle : async (server, name, args, helpers) => result | undefined
7
+ * `server` is the McpServer instance (ws, workspaceId, channelName,
8
+ * agentName, token, _log); `helpers` is { text, image }.
9
+ */
10
+ const modules = [
11
+ require('./presence-handoff'), // M3/M6 — presence detail + structured hand-off
12
+ require('./brief'), // M5 — shared work brief
13
+ ];
14
+
15
+ function toolDefs() {
16
+ return modules.flatMap((m) => m.defs || []);
17
+ }
18
+
19
+ async function dispatch(server, name, args, helpers) {
20
+ for (const m of modules) {
21
+ if (!m.handle) continue;
22
+ const r = await m.handle(server, name, args, helpers);
23
+ if (r !== undefined) return r;
24
+ }
25
+ return undefined;
26
+ }
27
+
28
+ module.exports = { toolDefs, dispatch };
@@ -0,0 +1,199 @@
1
+ 'use strict';
2
+ /**
3
+ * Roadmap v1.1 M3/M6 — presence detail + structured agent-to-agent hand-off.
4
+ *
5
+ * workspace_agent_availability is <agent> free / busy / offline / runtime down?
6
+ * workspace_handoff hand work to another agent with request, context,
7
+ * output so far and next owner travelling together
8
+ * workspace_get_handoffs the recent hand-offs in a thread
9
+ *
10
+ * A hand-off is an ordinary `workspace.message.posted` chat message that
11
+ * OPENS with `@<to_agent>` (the backend's deterministic explicit-target path:
12
+ * it routes to exactly that agent and adds it to the thread) and carries the
13
+ * structured record twice — `payload.handoff` for readers of the thread and
14
+ * `metadata.handoff` for routing/analytics — so nothing depends on parsing
15
+ * the markdown back out.
16
+ */
17
+
18
+ const MAX_HANDOFFS = 10;
19
+
20
+ const defs = [
21
+ {
22
+ name: 'workspace_agent_availability',
23
+ description:
24
+ 'Check whether another agent can take work right now. Returns its status, whether it is busy ' +
25
+ '(and in which threads / how many messages are queued), whether the device it runs on is ' +
26
+ 'online, and one reason: available | busy | agent_offline | runtime_offline.',
27
+ inputSchema: {
28
+ type: 'object',
29
+ properties: {
30
+ agent: { type: 'string', description: 'Agent name (without the openagents: prefix)' },
31
+ },
32
+ required: ['agent'],
33
+ },
34
+ },
35
+ {
36
+ name: 'workspace_handoff',
37
+ description:
38
+ 'Hand a piece of work to another agent in the current thread. Posts "@<to_agent> <request>" with ' +
39
+ 'optional Context / Output so far / Next owner sections so the request, what it needs, what you ' +
40
+ 'already produced and who owns the next step travel together. Only the addressed agent is woken. ' +
41
+ 'Use workspace_agent_availability first if timing matters.',
42
+ inputSchema: {
43
+ type: 'object',
44
+ properties: {
45
+ to_agent: { type: 'string', description: 'Agent to hand the work to (name only)' },
46
+ request: { type: 'string', description: 'What you are asking the agent to do — one or two sentences' },
47
+ context: { type: 'string', description: 'Background the agent needs (inputs, constraints, links, file ids)' },
48
+ output: { type: 'string', description: 'What you have already produced (results, paths, partial work)' },
49
+ next_owner: { type: 'string', description: 'Who owns the next step after this (defaults to to_agent)' },
50
+ },
51
+ required: ['to_agent', 'request'],
52
+ },
53
+ },
54
+ {
55
+ name: 'workspace_get_handoffs',
56
+ description:
57
+ 'List the most recent structured hand-offs in a thread (newest first, max 10): who handed what to ' +
58
+ 'whom, with the context, output and next owner they carried. Defaults to the current thread.',
59
+ inputSchema: {
60
+ type: 'object',
61
+ properties: {
62
+ channel: { type: 'string', description: 'Thread name; omit for the current one' },
63
+ limit: { type: 'integer', description: 'How many to return (1-10, default 10)' },
64
+ },
65
+ },
66
+ },
67
+ ];
68
+
69
+ function cleanName(name) {
70
+ return String(name || '').trim().replace(/^@/, '').replace(/^openagents:/, '');
71
+ }
72
+
73
+ function cleanText(v, max) {
74
+ if (v === undefined || v === null) return null;
75
+ const s = String(v).trim();
76
+ if (!s) return null;
77
+ return max && s.length > max ? s.slice(0, max) + '…' : s;
78
+ }
79
+
80
+ /**
81
+ * Build the hand-off record + message content. Pure; exported for tests.
82
+ * Returns { handoff, content } where handoff =
83
+ * { from, to, request, context, output, next_owner }.
84
+ */
85
+ function buildHandoff(from, { to_agent, request, context, output, next_owner } = {}) {
86
+ const to = cleanName(to_agent);
87
+ const req = cleanText(request, 4000);
88
+ if (!to) throw new Error('to_agent is required');
89
+ if (!req) throw new Error('request is required');
90
+ const handoff = {
91
+ from: cleanName(from),
92
+ to,
93
+ request: req,
94
+ context: cleanText(context, 8000),
95
+ output: cleanText(output, 8000),
96
+ next_owner: cleanName(next_owner) || to,
97
+ };
98
+ let content = `@${to} ${req}`;
99
+ if (handoff.context) content += `\n\nContext:\n${handoff.context}`;
100
+ if (handoff.output) content += `\n\nOutput so far:\n${handoff.output}`;
101
+ content += `\n\nNext owner: ${handoff.next_owner}`;
102
+ return { handoff, content };
103
+ }
104
+
105
+ /** The hand-off records found in a list of raw events (newest first). */
106
+ function extractHandoffs(events, limit = MAX_HANDOFFS) {
107
+ const out = [];
108
+ for (const e of events || []) {
109
+ const payload = e.payload || {};
110
+ const meta = e.metadata || {};
111
+ const h = (payload.handoff && typeof payload.handoff === 'object') ? payload.handoff
112
+ : (meta.handoff && typeof meta.handoff === 'object') ? meta.handoff : null;
113
+ if (!h) continue;
114
+ out.push({
115
+ id: e.id || null,
116
+ timestamp: e.timestamp || null,
117
+ from: h.from || String(e.source || '').replace(/^openagents:/, ''),
118
+ to: h.to || null,
119
+ request: h.request || null,
120
+ context: h.context || null,
121
+ output: h.output || null,
122
+ next_owner: h.next_owner || h.to || null,
123
+ });
124
+ if (out.length >= limit) break;
125
+ }
126
+ return out;
127
+ }
128
+
129
+ function describeAvailability(agent, a) {
130
+ const bits = [`${agent}: ${a.reason || 'unknown'}`];
131
+ bits.push(`status=${a.status || '?'}`);
132
+ if (a.presence_state) bits.push(`presence=${a.presence_state}`);
133
+ if (Array.isArray(a.busy_channels) && a.busy_channels.length) bits.push(`busy in: ${a.busy_channels.join(', ')}`);
134
+ if (a.queue_depth) bits.push(`queued: ${a.queue_depth}`);
135
+ if (a.runtime_status) bits.push(`runtime ${a.runtime_status}${a.runtime_name ? ` (${a.runtime_name})` : ''}`);
136
+ const hint = {
137
+ available: 'It can take work now.',
138
+ busy: 'It will answer after the current run — a hand-off is queued, not lost.',
139
+ agent_offline: 'Its connector is not running; a message will wait until it reconnects.',
140
+ runtime_offline: 'The device it runs on is offline; nobody will pick this up until it is back.',
141
+ }[a.reason];
142
+ return bits.join(' · ') + (hint ? `\n${hint}` : '');
143
+ }
144
+
145
+ async function handle(server, name, args, helpers) {
146
+ const { text } = helpers;
147
+ args = args || {};
148
+ switch (name) {
149
+ case 'workspace_agent_availability': {
150
+ const agent = cleanName(args.agent);
151
+ if (!agent) throw new Error('agent is required');
152
+ const a = await server.ws.getAgentAvailability(server.workspaceId, server.token, agent);
153
+ return text(describeAvailability(agent, a || {}));
154
+ }
155
+
156
+ case 'workspace_handoff': {
157
+ const { handoff, content } = buildHandoff(server.agentName, args);
158
+ if (handoff.to === handoff.from) throw new Error('You cannot hand work to yourself');
159
+ const event = {
160
+ type: 'workspace.message.posted',
161
+ source: `openagents:${server.agentName}`,
162
+ target: `channel/${server.channelName}`,
163
+ payload: { content, message_type: 'chat', handoff },
164
+ metadata: { handoff, explicit_targets: [handoff.to] },
165
+ };
166
+ if (server.requesterEmail) event.metadata.requester_email = server.requesterEmail;
167
+ const result = await server.ws.sendEvent(server.workspaceId, event, server.token);
168
+ const targets = (result && result.metadata && result.metadata.target_agents) || [];
169
+ const delivered = targets.includes(handoff.to);
170
+ return text(
171
+ `Handed off to @${handoff.to}${delivered ? '' : ' (warning: the workspace did not route it — is that agent in this workspace?)'}. ` +
172
+ `Next owner: ${handoff.next_owner}. End your turn now unless you have other work; ` +
173
+ `@${handoff.to} will reply in this thread.`,
174
+ );
175
+ }
176
+
177
+ case 'workspace_get_handoffs': {
178
+ const channel = args.channel || server.channelName;
179
+ const limit = Math.min(Math.max(1, parseInt(args.limit, 10) || MAX_HANDOFFS), MAX_HANDOFFS);
180
+ const events = await server.ws.getChannelEvents(server.workspaceId, channel, server.token, { limit: 200, sort: 'desc' });
181
+ const handoffs = extractHandoffs(events, limit);
182
+ if (!handoffs.length) return text(`No hand-offs in ${channel} yet.`);
183
+ const lines = handoffs.map((h, i) => {
184
+ const parts = [`${i + 1}. ${h.from || '?'} → ${h.to || '?'}: ${h.request || ''}`];
185
+ if (h.context) parts.push(` Context: ${h.context}`);
186
+ if (h.output) parts.push(` Output so far: ${h.output}`);
187
+ if (h.next_owner) parts.push(` Next owner: ${h.next_owner}`);
188
+ if (h.timestamp) parts.push(` at ${new Date(h.timestamp).toISOString()}`);
189
+ return parts.join('\n');
190
+ });
191
+ return text(`Hand-offs in ${channel} (newest first):\n${lines.join('\n')}`);
192
+ }
193
+
194
+ default:
195
+ return undefined;
196
+ }
197
+ }
198
+
199
+ module.exports = { defs, handle, buildHandoff, extractHandoffs };