@orbit-intelligence/orbit-agent 0.3.12

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.
Files changed (80) hide show
  1. package/LICENSE +16 -0
  2. package/README.md +23 -0
  3. package/bin/orbit +26 -0
  4. package/dist/prompts/system.js +80 -0
  5. package/dist/src/cli/args.js +145 -0
  6. package/dist/src/cli/orchestrate.js +100 -0
  7. package/dist/src/cli/run.js +393 -0
  8. package/dist/src/config/config-schema.js +151 -0
  9. package/dist/src/config/index.js +57 -0
  10. package/dist/src/core/agent/agent-loop.js +402 -0
  11. package/dist/src/core/agents/delegate.js +120 -0
  12. package/dist/src/core/agents/orchestrator.js +58 -0
  13. package/dist/src/core/agents/prompts.js +82 -0
  14. package/dist/src/core/agents/types.js +1 -0
  15. package/dist/src/core/context/context-manager.js +167 -0
  16. package/dist/src/core/events.js +23 -0
  17. package/dist/src/core/llm/http.js +207 -0
  18. package/dist/src/core/llm/index.js +93 -0
  19. package/dist/src/core/llm/models.js +228 -0
  20. package/dist/src/core/llm/providers/gemini.js +211 -0
  21. package/dist/src/core/llm/providers/openai-compat.js +31 -0
  22. package/dist/src/core/llm/router.js +125 -0
  23. package/dist/src/core/llm/secrets.js +121 -0
  24. package/dist/src/core/llm/types.js +10 -0
  25. package/dist/src/core/orchestration/dispatcher.js +74 -0
  26. package/dist/src/core/orchestration/messenger.js +139 -0
  27. package/dist/src/core/orchestration/roles.js +129 -0
  28. package/dist/src/core/orchestration/runtime.js +122 -0
  29. package/dist/src/core/orchestration/session.js +204 -0
  30. package/dist/src/core/orchestration/shared-context.js +88 -0
  31. package/dist/src/core/orchestration/tools.js +187 -0
  32. package/dist/src/core/orchestration/types.js +3 -0
  33. package/dist/src/core/permissions/index.js +58 -0
  34. package/dist/src/core/project-context.js +115 -0
  35. package/dist/src/core/skill-loader.js +31 -0
  36. package/dist/src/core/tools/edit.js +142 -0
  37. package/dist/src/core/tools/filesystem.js +203 -0
  38. package/dist/src/core/tools/git.js +138 -0
  39. package/dist/src/core/tools/registry.js +73 -0
  40. package/dist/src/core/tools/search.js +90 -0
  41. package/dist/src/core/tools/shell.js +65 -0
  42. package/dist/src/core/tools/types.js +6 -0
  43. package/dist/src/core/types.js +3 -0
  44. package/dist/src/index.js +11 -0
  45. package/dist/src/session/event-log.js +55 -0
  46. package/dist/src/session/store.js +76 -0
  47. package/dist/src/setup/wizard.js +401 -0
  48. package/dist/src/tui/InkApp.js +67 -0
  49. package/dist/src/tui/ansi.js +142 -0
  50. package/dist/src/tui/app.js +768 -0
  51. package/dist/src/tui/colors.js +13 -0
  52. package/dist/src/tui/components/AgentDock.js +46 -0
  53. package/dist/src/tui/components/Composer.js +35 -0
  54. package/dist/src/tui/components/Header.js +23 -0
  55. package/dist/src/tui/components/ModelPicker.js +23 -0
  56. package/dist/src/tui/components/PermissionModal.js +29 -0
  57. package/dist/src/tui/components/SlashMenu.js +15 -0
  58. package/dist/src/tui/components/StatusLine.js +27 -0
  59. package/dist/src/tui/components/Transcript.js +31 -0
  60. package/dist/src/tui/components/WorkingStatus.js +29 -0
  61. package/dist/src/tui/components/input.js +246 -0
  62. package/dist/src/tui/components/markdown.js +384 -0
  63. package/dist/src/tui/components/message.js +105 -0
  64. package/dist/src/tui/context.js +8 -0
  65. package/dist/src/tui/geometry.js +40 -0
  66. package/dist/src/tui/renderer.js +116 -0
  67. package/dist/src/tui/rows.js +247 -0
  68. package/dist/src/tui/scheduler.js +32 -0
  69. package/dist/src/tui/store.js +127 -0
  70. package/dist/src/tui/style.js +151 -0
  71. package/dist/src/tui/term.js +309 -0
  72. package/dist/src/tui/text.js +104 -0
  73. package/dist/src/tui/themes/index.js +15 -0
  74. package/dist/src/tui/themes/palettes.js +137 -0
  75. package/dist/src/tui/themes/types.js +1 -0
  76. package/dist/src/utils/diff.js +161 -0
  77. package/dist/src/utils/platform.js +71 -0
  78. package/dist/src/utils/signals.js +26 -0
  79. package/dist/src/version.js +4 -0
  80. package/package.json +71 -0
@@ -0,0 +1,204 @@
1
+ import { AgentRuntime } from './runtime.js';
2
+ import { Messenger } from './messenger.js';
3
+ import { SharedContext } from './shared-context.js';
4
+ import { createDispatcher, truncate } from './dispatcher.js';
5
+ const MAX_BUDGET_COUNT = 6;
6
+ const QUIESCENCE_STEPS = 400;
7
+ const LEAD_PROMPT_BASE = `You are a Lead agent in a multi-agent session. Other lead agents (your peers) are full agents with their own models and goals. Coordinate with them instead of working entirely alone: use agent_message to propose/ask/agree, agent_interrupt to halt a peer heading the wrong way, and read_shared/note_shared to stay in sync. You may delegate self-contained work to specialized role agents via delegate_task (single or parallel). When the work is done, stop — do not invent follow-up tasks.`;
8
+ /**
9
+ * OrchestrationSession — serialized driver for one user turn across a team of
10
+ * lead agents. Leads' loops run concurrently; the messenger coordinates
11
+ * hand-offs (parked waiters resume when their reply lands). The driver drains
12
+ * mailboxes between runs and ends the turn once no agent has work left.
13
+ */
14
+ export class OrchestrationSession {
15
+ opts;
16
+ messenger;
17
+ shared;
18
+ runtimes = new Map();
19
+ leadIds = [];
20
+ roleIds = new Set();
21
+ jobs = new Map();
22
+ statuses = new Map();
23
+ busy = false;
24
+ roleDispatcher;
25
+ get status() {
26
+ return this.statuses;
27
+ }
28
+ get agentIds() {
29
+ return this.leadIds;
30
+ }
31
+ get sharedContext() {
32
+ return this.shared;
33
+ }
34
+ constructor(opts) {
35
+ this.opts = opts;
36
+ this.shared = new SharedContext();
37
+ this.messenger = new Messenger({
38
+ getAgentIds: () => [...this.leadIds, ...this.roleIds],
39
+ onMessage: (msg) => this.onTraffic(msg),
40
+ onParked: (id) => this.setStatus(id, 'waiting'),
41
+ onUnparked: (id) => this.setStatus(id, 'thinking'),
42
+ });
43
+ const leads = opts.config.leads.slice(0, Math.max(1, opts.config.maxLeadAgents ?? 2));
44
+ for (let i = 0; i < leads.length; i++) {
45
+ const lead = leads[i];
46
+ const id = leadIdFor(lead.name, i);
47
+ const rt = new AgentRuntime({
48
+ identity: { id, name: lead.name },
49
+ label: `Lead ${i + 1} · ${lead.name}`,
50
+ systemPrompt: [opts.baseSystemPrompt, LEAD_PROMPT_BASE, lead.systemPrompt].filter(Boolean).join('\n\n'),
51
+ model: lead.model,
52
+ providers: opts.providers,
53
+ strategy: opts.strategy,
54
+ tools: opts.registry,
55
+ permissions: opts.permissions,
56
+ messenger: this.messenger,
57
+ shared: this.shared,
58
+ cwd: opts.cwd,
59
+ delegate: (req) => this.dispatchRole(req),
60
+ maxIterations: 16,
61
+ maxDelegations: MAX_BUDGET_COUNT,
62
+ contextBudgetTokens: opts.contextBudgetTokens ?? 48_000,
63
+ toolTimeoutMs: opts.config.toolTimeoutMs ?? 30_000,
64
+ streamTimeoutMs: opts.config.streamTimeoutMs ?? 120_000,
65
+ maxTokensPerSecond: opts.maxTokensPerSecond,
66
+ reasoning: opts.reasoning,
67
+ bus: opts.bus,
68
+ });
69
+ this.runtimes.set(id, rt);
70
+ this.leadIds.push(id);
71
+ this.setStatus(id, 'idle');
72
+ }
73
+ this.roleDispatcher = createDispatcher({
74
+ roles: opts.roles,
75
+ providers: opts.providers,
76
+ strategy: opts.strategy,
77
+ permissions: opts.permissions,
78
+ registry: opts.registry,
79
+ cwd: opts.cwd,
80
+ leakWindowSize: 200,
81
+ defaultModel: opts.defaultModel,
82
+ maxIterations: 12,
83
+ contextBudgetTokens: opts.contextBudgetTokens ?? 48_000,
84
+ toolTimeoutMs: opts.config.toolTimeoutMs ?? 30_000,
85
+ streamTimeoutMs: opts.config.streamTimeoutMs ?? 120_000,
86
+ reasoning: opts.reasoning,
87
+ bus: opts.bus,
88
+ registerRole: (id) => this.roleIds.add(id),
89
+ unregisterRole: (id) => this.roleIds.delete(id),
90
+ });
91
+ }
92
+ runtime(id) {
93
+ return this.runtimes.get(id);
94
+ }
95
+ /** Run one user turn across the team; resolves when the team is quiescent. */
96
+ async handleUser(text) {
97
+ if (this.busy)
98
+ return;
99
+ this.busy = true;
100
+ try {
101
+ this.shared.recordActivity({ kind: 'activity', text: truncate(text, 300), to: 'all', from: 'user' });
102
+ this.opts.bus.emit('onSessionStatus', `user → all agents: ${truncate(text, 80)}`);
103
+ for (const id of this.leadIds)
104
+ this.startRun(id, text);
105
+ await this.quiesce();
106
+ }
107
+ finally {
108
+ this.busy = false;
109
+ }
110
+ }
111
+ /** Abort one agent's in-flight run (used by cancellation UI). */
112
+ abortAgent(id) {
113
+ this.runtimes.get(id)?.abort();
114
+ }
115
+ startRun(id, input) {
116
+ if (this.jobs.has(id))
117
+ return;
118
+ const rt = this.runtimes.get(id);
119
+ if (!rt)
120
+ return;
121
+ this.setStatus(id, 'thinking');
122
+ const p = (async () => {
123
+ let interrupted = false;
124
+ try {
125
+ const res = await rt.run(input);
126
+ interrupted = res.interrupted ?? false;
127
+ this.setStatus(id, interrupted ? 'interrupted' : 'done');
128
+ }
129
+ catch (err) {
130
+ this.setStatus(id, 'error');
131
+ this.opts.bus.emit('onError', err);
132
+ }
133
+ finally {
134
+ this.jobs.delete(id);
135
+ // Lost-wakeup guard: mail may have arrived while this run was busy.
136
+ // Resume an agent that was NOT aborted/interrupted, on actionable mail.
137
+ if (!interrupted && this.hasActionableMail(id))
138
+ this.startRun(id, this.drainMailbox(id));
139
+ }
140
+ })();
141
+ this.jobs.set(id, p);
142
+ }
143
+ drainMailbox(id) {
144
+ const msgs = this.messenger.takePending(id);
145
+ if (msgs.length === 0)
146
+ return '(no pending messages)';
147
+ const parts = msgs.map((m) => {
148
+ const kind = m.kind === 'interrupt' ? 'INTERRUPT' : m.kind.toUpperCase();
149
+ const from = m.from === 'user' ? 'user' : `@${m.from}`;
150
+ return `<${kind} from ${from}>\n${m.content}\n</${kind}>`;
151
+ });
152
+ return `[New messages for you]\n\n${parts.join('\n\n')}`;
153
+ }
154
+ onTraffic(msg) {
155
+ this.shared.recordMessage(msg);
156
+ this.opts.bus.emit('onAgentMessage', msg);
157
+ if (!this.leadIds.includes(msg.to))
158
+ return;
159
+ if (msg.kind === 'interrupt') {
160
+ this.runtimes.get(msg.to)?.abort();
161
+ return;
162
+ }
163
+ // Wake an idle lead so it can act on the incoming message immediately.
164
+ if (!this.jobs.has(msg.to) && this.messenger.hasPending(msg.to)) {
165
+ this.startRun(msg.to, this.drainMailbox(msg.to));
166
+ }
167
+ }
168
+ /** Actionable mail excludes interrupt notices (those are handled by abort). */
169
+ hasActionableMail(id) {
170
+ return this.messenger.pendingOf(id).some((m) => m.kind !== 'interrupt');
171
+ }
172
+ async dispatchRole(req) {
173
+ return this.roleDispatcher(req);
174
+ }
175
+ async quiesce() {
176
+ let budget = QUIESCENCE_STEPS;
177
+ while (budget-- > 0) {
178
+ await this.waitForAll();
179
+ const next = this.leadIds.filter((id) => !this.jobs.has(id) && this.hasActionableMail(id));
180
+ if (next.length === 0)
181
+ break;
182
+ for (const id of next)
183
+ this.startRun(id, this.drainMailbox(id));
184
+ }
185
+ for (const id of this.leadIds) {
186
+ if (!this.jobs.has(id))
187
+ this.setStatus(id, 'idle');
188
+ }
189
+ }
190
+ async waitForAll() {
191
+ while (this.jobs.size > 0) {
192
+ await Promise.allSettled([...this.jobs.values()]);
193
+ }
194
+ }
195
+ setStatus(id, status) {
196
+ this.statuses.set(id, status);
197
+ const rt = this.runtimes.get(id);
198
+ this.opts.bus.emit('onAgentStatus', { id, name: rt?.identity.name ?? id, status });
199
+ }
200
+ }
201
+ function leadIdFor(name, index) {
202
+ const slug = name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
203
+ return `lead_${slug || String(index + 1)}`;
204
+ }
@@ -0,0 +1,88 @@
1
+ const MAX_FACTS_BYTES = 1600;
2
+ const JOURNAL_WINDOW = 40;
3
+ export class SharedContext {
4
+ facts = [];
5
+ journal = [];
6
+ opts;
7
+ constructor(opts = {}) {
8
+ this.opts = {
9
+ maxFactsBytes: opts.maxFactsBytes ?? MAX_FACTS_BYTES,
10
+ journalWindow: opts.journalWindow ?? JOURNAL_WINDOW,
11
+ };
12
+ }
13
+ get factCount() {
14
+ return this.facts.length;
15
+ }
16
+ get journalCount() {
17
+ return this.journal.length;
18
+ }
19
+ getFacts() {
20
+ return [...this.facts];
21
+ }
22
+ /** Recent journal as plain text lines (used by read_shared). */
23
+ getJournal(depth) {
24
+ return this.dump(depth ?? JOURNAL_WINDOW * 2).journal.join('\n');
25
+ }
26
+ addFact(fact) {
27
+ if (!fact.trim())
28
+ return;
29
+ this.facts.push(fact.trim());
30
+ let total = this.facts.join('\n').length;
31
+ while (total > this.opts.maxFactsBytes && this.facts.length > 1) {
32
+ this.facts.shift();
33
+ total = this.facts.join('\n').length;
34
+ }
35
+ }
36
+ recordActivity(entry) {
37
+ this.journal.push({ ...entry, id: `j_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`, createdAt: Date.now() });
38
+ }
39
+ recordMessage(msg) {
40
+ this.recordActivity({
41
+ kind: 'agent-message',
42
+ text: msg.content,
43
+ to: msg.to,
44
+ from: msg.from,
45
+ });
46
+ }
47
+ recordTask(task, verb) {
48
+ this.recordActivity({ kind: 'task', text: `[${verb}] ${task.role}${task.result ? `: ${truncate(task.result, 200)}` : ''}`, to: task.from });
49
+ }
50
+ /** Relevance-scoped excerpt for a given agent id. */
51
+ excerptFor(agentId, includeFacts = true) {
52
+ const parts = [];
53
+ if (includeFacts && this.facts.length > 0) {
54
+ parts.push(`# Shared project facts\n${this.facts.map((f) => `- ${f}`).join('\n')}`);
55
+ }
56
+ const own = this.journal.filter((e) => e.to === agentId || e.to === 'all');
57
+ // Recent window is visibility-scoped: non-message activity (task outcomes,
58
+ // activity log) plus messages actually addressed to this agent. Peer-addressed
59
+ // messages must never leak into another agent's excerpt.
60
+ const window = this.journal
61
+ .filter((e) => e.kind !== 'agent-message' || e.to === agentId || e.to === 'all')
62
+ .slice(-this.opts.journalWindow);
63
+ const merged = mergeByDate(own.filter((e) => !window.includes(e)), window);
64
+ if (merged.length > 0) {
65
+ const lines = merged.map((e) => {
66
+ const who = e.from ? `@${e.from}` : 'activity';
67
+ const at = e.to ? ` → ${e.to === 'all' ? 'all' : `@${e.to}`}` : '';
68
+ return `- [${e.kind}] ${who}${at}: ${e.text}`;
69
+ });
70
+ parts.push(`# Shared activity\n${lines.join('\n')}`);
71
+ }
72
+ return parts.join('\n\n');
73
+ }
74
+ /** Full journal + facts (used by the read_shared tool) — callers should bound reads. */
75
+ dump(maxEntries) {
76
+ const journal = this.journal.slice(-(maxEntries ?? this.journal.length));
77
+ return {
78
+ facts: [...this.facts],
79
+ journal: journal.map((e) => `[${e.kind}]${e.from ? ` @${e.from}` : ''}${e.to ? ` → @${e.to}` : ''}: ${e.text}`),
80
+ };
81
+ }
82
+ }
83
+ function mergeByDate(a, b) {
84
+ return [...a, ...b].sort((x, y) => x.createdAt - y.createdAt);
85
+ }
86
+ function truncate(s, n) {
87
+ return s.length <= n ? s : `${s.slice(0, n)}…`;
88
+ }
@@ -0,0 +1,187 @@
1
+ import { builtInRoleIds } from './roles.js';
2
+ /** Register the orchestration-only tools into a shared tool registry (idempotent). */
3
+ export function registerOrchestrationTools(registry, delegate) {
4
+ registry.register(createAgentMessageTool());
5
+ registry.register(createAgentInterruptTool());
6
+ for (const t of createSharedContextTools())
7
+ registry.register(t);
8
+ registry.register(createDelegateTaskTool(delegate));
9
+ }
10
+ export const ROLE_HINT = `role: one of ${builtInRoleIds.join(', ')} (a custom role word is also allowed).`;
11
+ /** Boss-level communication: send a message to another agent, optionally waiting for its reply. */
12
+ export function createAgentMessageTool() {
13
+ return {
14
+ name: 'agent_message',
15
+ description: 'Send a message to another agent in this session and (optionally) wait for its reply. Use to collaborate: propose changes, ask for input, raise concerns, request the peer inspect something. When wait=true your run parks until the peer replies or the timeout elapses — the peer processes your message in parallel.',
16
+ parameters: {
17
+ type: 'object',
18
+ properties: {
19
+ to: { type: 'string', description: 'Target agent id or name (e.g. "atl_1" or "Atlas"). Pass "all" to broadcast.' },
20
+ content: { type: 'string', description: 'The message body.' },
21
+ wait: { type: 'boolean', description: 'Park until the peer replies. Default false.' },
22
+ replyTimeoutMs: { type: 'number', description: 'Max wait in ms when wait=true (default 60000).' },
23
+ },
24
+ required: ['to', 'content'],
25
+ },
26
+ async run(args, ctx) {
27
+ const to = String(args.to ?? '');
28
+ const content = String(args.content ?? '').trim();
29
+ if (!content)
30
+ return { content: 'Error: empty message.', isError: true };
31
+ if (!ctx.agent)
32
+ return { content: 'Error: agent messaging is unavailable outside an orchestration session.', isError: true };
33
+ try {
34
+ ctx.agent.send(to, content, 'message');
35
+ }
36
+ catch (err) {
37
+ return { content: `Error: ${err.message}`, isError: true };
38
+ }
39
+ if (args.wait === true) {
40
+ const timeout = Math.min(Number(args.replyTimeoutMs ?? 60_000), 300_000);
41
+ const reply = await ctx.agent.waitForReply(to, timeout);
42
+ return { content: `[agent_message] delivered to @${to}; reply received:\n${reply}` };
43
+ }
44
+ return { content: `[agent_message] delivered to @${to}.` };
45
+ },
46
+ };
47
+ }
48
+ /** Halt another agent's current work; it will process your reason next. */
49
+ export function createAgentInterruptTool() {
50
+ return {
51
+ name: 'agent_interrupt',
52
+ description: 'Interrupt another agent immediately. Its current run is aborted and your reason is delivered as its next input. Use when a peer is going in a direction that will cause damage or waste: warn precisely, then continue your own reasoning.',
53
+ parameters: {
54
+ type: 'object',
55
+ properties: {
56
+ target: { type: 'string', description: 'Target agent id or name.' },
57
+ reason: { type: 'string', description: 'Concise reason, referencing concrete facts (file, behavior).' },
58
+ },
59
+ required: ['target', 'reason'],
60
+ },
61
+ async run(args, ctx) {
62
+ const target = String(args.target ?? '');
63
+ const reason = String(args.reason ?? '').trim();
64
+ if (!reason)
65
+ return { content: 'Error: empty reason.', isError: true };
66
+ if (!ctx.agent)
67
+ return { content: 'Error: agent messaging is unavailable outside an orchestration session.', isError: true };
68
+ try {
69
+ ctx.agent.interrupt(target, reason);
70
+ }
71
+ catch (err) {
72
+ return { content: `Error: ${err.message}`, isError: true };
73
+ }
74
+ return { content: `[agent_interrupt] @${target} interrupted.` };
75
+ },
76
+ };
77
+ }
78
+ /** Read the shared orchestration context on demand. */
79
+ export function createSharedContextTools() {
80
+ const readTool = {
81
+ name: 'read_shared',
82
+ description: 'Read the shared orchestration context: project facts other agents have recorded and the recent shared activity journal (messages, task outcomes). Use when you need awareness of what peers decided or reported. Scoped reads only; the excerpt is bounded.',
83
+ parameters: {
84
+ type: 'object',
85
+ properties: {
86
+ scope: { type: 'string', enum: ['auto', 'all', 'facts', 'journal'], description: 'auto = facts + recent journal (default).' },
87
+ depth: { type: 'number', description: 'How many recent journal entries to include (default 40, max 200).' },
88
+ },
89
+ },
90
+ async run(args, ctx) {
91
+ if (!ctx.shared)
92
+ return { content: 'Error: shared context is unavailable outside an orchestration session.', isError: true };
93
+ const scope = String(args.scope ?? 'auto');
94
+ const depth = Math.min(Number(args.depth ?? 40), 200);
95
+ if (scope === 'all') {
96
+ return { content: ctx.shared.getJournal(depth) };
97
+ }
98
+ if (scope === 'journal') {
99
+ return { content: ctx.shared.getJournal(depth) };
100
+ }
101
+ if (scope === 'facts') {
102
+ const facts = ctx.shared.getFacts();
103
+ return { content: facts.length ? facts.map((f) => `- ${f}`).join('\n') : '(no shared facts yet)' };
104
+ }
105
+ return { content: ctx.shared.getExcerptForSelf() };
106
+ },
107
+ };
108
+ const noteTool = {
109
+ name: 'note_shared',
110
+ description: 'Record a short, durable project fact into the shared context (e.g. "state manager changed to zustand", "deploy script uses produce.sh"). Facts persist for the session and are visible to all agents — use sparingly for decisions worth remembering.',
111
+ parameters: {
112
+ type: 'object',
113
+ properties: {
114
+ fact: { type: 'string', description: 'One short fact (under ~140 chars).' },
115
+ },
116
+ required: ['fact'],
117
+ },
118
+ async run(args, ctx) {
119
+ if (!ctx.shared)
120
+ return { content: 'Error: shared context is unavailable outside an orchestration session.', isError: true };
121
+ const fact = String(args.fact ?? '').trim();
122
+ if (!fact)
123
+ return { content: 'Error: empty fact.', isError: true };
124
+ ctx.shared.addFact(fact);
125
+ return { content: '[note_shared] recorded.' };
126
+ },
127
+ };
128
+ return [readTool, noteTool];
129
+ }
130
+ /** Schema-only tool; execution is intercepted by AgentLoop.runDelegation. */
131
+ export function createDelegateTaskTool(delegate) {
132
+ return {
133
+ name: 'delegate_task',
134
+ description: `Dispatch work to a specialized role agent. You (the lead) decide the role, the exact task, and whether to run several role agents in parallel. The role agent is a full agent: it has the same tool access as you and an isolated context seeded with your task, and returns a structured summary. Roles: ${builtInRoleIds.join(', ')}. Parallel: use the 'parallel' array to run several independent role agents concurrently. Do NOT delegate work you should just do yourself.`,
135
+ parameters: {
136
+ type: 'object',
137
+ properties: {
138
+ role: { type: 'string', description: ROLE_HINT },
139
+ task: { type: 'string', description: 'Self-contained, specific assignment. Include file paths, constraints, and definition of done.' },
140
+ files: { type: 'array', items: { type: 'string' }, description: 'Relevant files to bound scope.' },
141
+ parallel: {
142
+ type: 'array',
143
+ items: {
144
+ type: 'object',
145
+ properties: {
146
+ role: { type: 'string', description: ROLE_HINT },
147
+ task: { type: 'string', description: 'Self-contained assignment.' },
148
+ files: { type: 'array', items: { type: 'string' } },
149
+ },
150
+ required: ['role', 'task'],
151
+ },
152
+ description: 'Run multiple independent role agents concurrently. Each returns its own summary.',
153
+ },
154
+ },
155
+ oneOf: [
156
+ { required: ['role', 'task'] },
157
+ { required: ['parallel'] },
158
+ ],
159
+ },
160
+ async run(args, ctx) {
161
+ if (!delegate)
162
+ return { content: 'Error: delegation is unavailable in this context.', isError: true };
163
+ return runDelegationSchemas(delegate, args, ctx);
164
+ },
165
+ };
166
+ }
167
+ async function runDelegationSchemas(delegate, args, _ctx) {
168
+ const jobs = [];
169
+ const parallelRaw = Array.isArray(args.parallel) ? args.parallel : [];
170
+ if (parallelRaw.length > 0) {
171
+ for (const p of parallelRaw) {
172
+ jobs.push({ role: String(p.role ?? ''), task: String(p.task ?? ''), files: Array.isArray(p.files) ? p.files.map(String) : undefined });
173
+ }
174
+ }
175
+ else {
176
+ jobs.push({ role: String(args.role ?? ''), task: String(args.task ?? ''), files: Array.isArray(args.files) ? args.files.map(String) : undefined });
177
+ }
178
+ const invalid = jobs.find((j) => !j.task.trim());
179
+ if (invalid)
180
+ return { content: 'Error: delegate_task requires a non-empty task for every job.', isError: true };
181
+ const results = await Promise.all(jobs.map((j) => delegate(j)));
182
+ const owns = results.every((r) => r.ok);
183
+ const lines = results
184
+ .map((r, i) => `<delegated-${jobs[i].role} ok="${r.ok}">\n${r.summary}\n</delegated-${jobs[i].role}>`)
185
+ .join('\n\n');
186
+ return { content: lines, isError: !owns };
187
+ }
@@ -0,0 +1,3 @@
1
+ export function newId() {
2
+ return `o_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
3
+ }
@@ -0,0 +1,58 @@
1
+ export class PermissionManager {
2
+ mode;
3
+ allowCommands;
4
+ denyCommands;
5
+ allowPaths;
6
+ denyPaths;
7
+ prompt;
8
+ constructor(config, prompt) {
9
+ this.mode = config.mode;
10
+ this.allowCommands = config.allowCommands ?? [];
11
+ this.denyCommands = config.denyCommands ?? [];
12
+ this.allowPaths = config.allowPaths ?? [];
13
+ this.denyPaths = config.denyPaths ?? [];
14
+ this.prompt = prompt ?? null;
15
+ }
16
+ async checkCommand(command) {
17
+ const trimmed = command.trim();
18
+ if (this.denyCommands.some((d) => matchesPrefix(d, trimmed)))
19
+ return 'deny';
20
+ if (this.mode === 'deny')
21
+ return 'deny';
22
+ if (this.allowCommands.some((a) => matchesPrefix(a, trimmed)))
23
+ return 'allow';
24
+ if (this.mode === 'allow')
25
+ return 'allow';
26
+ // ask
27
+ if (this.prompt) {
28
+ const ok = await this.prompt.ask(`Allow shell: ${command.slice(0, 80)}`);
29
+ return ok ? 'allow' : 'deny';
30
+ }
31
+ return 'deny';
32
+ }
33
+ async checkPath(path, op) {
34
+ if (this.denyPaths.some((d) => pathStartsWith(path, d)))
35
+ return 'deny';
36
+ if (op === 'read' && (this.mode === 'allow'))
37
+ return 'allow';
38
+ if (this.allowPaths.some((a) => pathStartsWith(path, a)))
39
+ return 'allow';
40
+ if (op === 'read')
41
+ return 'allow'; // reads are generally fine
42
+ if (this.mode === 'deny')
43
+ return 'deny';
44
+ if (this.mode === 'allow')
45
+ return 'allow';
46
+ if (this.prompt) {
47
+ const ok = await this.prompt.ask(`Allow writing: ${path}`);
48
+ return ok ? 'allow' : 'deny';
49
+ }
50
+ return 'deny';
51
+ }
52
+ }
53
+ function matchesPrefix(rule, value) {
54
+ return value === rule || value.startsWith(`${rule} `);
55
+ }
56
+ function pathStartsWith(value, rule) {
57
+ return value === rule || value.startsWith(rule.endsWith('/') ? rule : rule + '/');
58
+ }
@@ -0,0 +1,115 @@
1
+ import { readFileSync, readdirSync, statSync } from 'node:fs';
2
+ import { resolve, dirname, join, basename } from 'node:path';
3
+ /**
4
+ * Project-scoped instruction loading, modeled on Agent Build / Claude Code:
5
+ *
6
+ * - AGENTS.md / .orbit/AGENTS.md / CLAUDE.md — loaded from the current
7
+ * directory, walking up toward the filesystem root. Every file is injected
8
+ * into the system prompt so the agent inherits the project's conventions.
9
+ * - .orbit/skills/*.md — named skill files the agent can invoke via
10
+ * `/skill <name>` (or automatically if a skill named "always" is present).
11
+ *
12
+ * Files are read once at startup; the combined text is a stable part of the
13
+ * system prompt, so a later edit requires a restart to take effect.
14
+ */
15
+ const CONVENTION_FILES = ['AGENTS.md', '.orbit/AGENTS.md', '.agents/AGENTS.md', 'CLAUDE.md'];
16
+ const MAX_TOTAL_BYTES = 120_000;
17
+ export function loadProjectContext(cwd) {
18
+ const files = [];
19
+ const bodies = [];
20
+ let total = 0;
21
+ collectUpward(cwd, (dir) => {
22
+ for (const name of CONVENTION_FILES) {
23
+ const p = join(dir, name);
24
+ if (files.includes(p))
25
+ continue;
26
+ const raw = readSafe(p);
27
+ if (raw === null)
28
+ continue;
29
+ files.push(p);
30
+ bodies.push(`## ${relLabel(p, cwd)}\n${raw.trim()}\n`);
31
+ total += raw.length;
32
+ if (total > MAX_TOTAL_BYTES)
33
+ return true; // stop climbing
34
+ }
35
+ return total > MAX_TOTAL_BYTES;
36
+ });
37
+ const skillsDir = findSkillsDir(cwd);
38
+ const skillBodies = {};
39
+ const skills = [];
40
+ if (skillsDir) {
41
+ for (const f of readdirSafe(skillsDir)) {
42
+ if (!f.endsWith('.md'))
43
+ continue;
44
+ const p = join(skillsDir, f);
45
+ const name = basename(f, '.md');
46
+ const body = readSafe(p);
47
+ if (body === null)
48
+ continue;
49
+ skillBodies[name] = body;
50
+ const summary = body.split('\n').find((l) => l.trim().startsWith('#') || l.trim() !== '')?.trim() ?? '';
51
+ skills.push({ name, path: p, summary: summary.slice(0, 120) });
52
+ }
53
+ skills.sort((a, b) => a.name.localeCompare(b.name));
54
+ }
55
+ return { files, conventions: bodies.join('\n'), skills, skillBodies };
56
+ }
57
+ function collectUpward(start, visit) {
58
+ let dir = resolve(start);
59
+ for (let depth = 0; depth < 12; depth++) {
60
+ let stop = false;
61
+ try {
62
+ stop = visit(dir);
63
+ }
64
+ catch {
65
+ /* ignore unreadable dirs */
66
+ }
67
+ if (stop)
68
+ return;
69
+ const parent = dirname(dir);
70
+ if (parent === dir)
71
+ return;
72
+ dir = parent;
73
+ }
74
+ }
75
+ function findSkillsDir(cwd) {
76
+ let dir = resolve(cwd);
77
+ for (let depth = 0; depth < 8; depth++) {
78
+ for (const candidate of ['.orbit/skills', '.agents/skills', 'skills']) {
79
+ const p = join(dir, candidate);
80
+ try {
81
+ const st = statSync(p);
82
+ if (st.isDirectory())
83
+ return p;
84
+ }
85
+ catch {
86
+ /* ignore */
87
+ }
88
+ }
89
+ const parent = dirname(dir);
90
+ if (parent === dir)
91
+ return null;
92
+ dir = parent;
93
+ }
94
+ return null;
95
+ }
96
+ function readSafe(p) {
97
+ try {
98
+ return readFileSync(p, 'utf8');
99
+ }
100
+ catch {
101
+ return null;
102
+ }
103
+ }
104
+ function readdirSafe(dir) {
105
+ try {
106
+ return readdirSync(dir);
107
+ }
108
+ catch {
109
+ return [];
110
+ }
111
+ }
112
+ function relLabel(p, cwd) {
113
+ const rel = p.startsWith(cwd) ? p.slice(cwd.length).replace(/^\//, '') : p;
114
+ return rel || basename(p);
115
+ }