@runuai/host 0.9.8 → 0.9.10
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/lib/agent-cli.ts +30 -5
- package/lib/agents/durable-proc.ts +16 -2
- package/lib/agents/transport.ts +53 -2
- package/lib/orchestrator.ts +125 -26
- package/package.json +1 -1
- package/runner/runner.mjs +3 -3
- package/src/protocol.ts +8 -0
package/lib/agent-cli.ts
CHANGED
|
@@ -4,11 +4,12 @@
|
|
|
4
4
|
* At task-up the host writes two files into the task workspace (bind-mounted at
|
|
5
5
|
* /workspace, like skills — no docker cp/exec needed):
|
|
6
6
|
* - `.uai/cli.mjs` — the self-contained agent CLI (Node ESM, fetch-only).
|
|
7
|
-
* - `.uai/uai.json` — { apiUrl
|
|
7
|
+
* - `.uai/uai.json` — shared, tokenless { apiUrl } config the CLI reads.
|
|
8
8
|
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
* as `node /workspace/.uai/cli.mjs <cmd>`
|
|
9
|
+
* A token is minted here per agent (host-side), carrying the task id, its owner
|
|
10
|
+
* user, that agent's identity, and only that agent's permissions; the cloud
|
|
11
|
+
* verifies it. Agents invoke the CLI as `node /workspace/.uai/cli.mjs <cmd>`
|
|
12
|
+
* (surfaced in the system preamble).
|
|
12
13
|
*/
|
|
13
14
|
import { chmodSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
14
15
|
import { resolve } from "node:path";
|
|
@@ -45,7 +46,7 @@ export function loadTaskCliSecret(taskId: string): string | null {
|
|
|
45
46
|
}
|
|
46
47
|
}
|
|
47
48
|
|
|
48
|
-
/** The union
|
|
49
|
+
/** The union used only to decide whether this task needs the shared CLI file. */
|
|
49
50
|
export function rosterPermissions(roster: RosterAgent[]): string[] {
|
|
50
51
|
return Array.from(new Set(roster.flatMap((a) => a.permissions ?? [])));
|
|
51
52
|
}
|
|
@@ -253,6 +254,29 @@ async function main() {
|
|
|
253
254
|
})).task);
|
|
254
255
|
break;
|
|
255
256
|
}
|
|
257
|
+
case "task close": {
|
|
258
|
+
const allowed = new Set(["status", "reason"]);
|
|
259
|
+
const unknown = Object.keys(flags).filter((flag) => !allowed.has(flag));
|
|
260
|
+
if (pos.length || unknown.length) {
|
|
261
|
+
console.error("uai: task close accepts only --status and --reason");
|
|
262
|
+
process.exit(1);
|
|
263
|
+
}
|
|
264
|
+
const status = flags.status;
|
|
265
|
+
if (status !== "finished" && status !== "canceled") {
|
|
266
|
+
console.error("uai: task close needs --status finished|canceled");
|
|
267
|
+
process.exit(1);
|
|
268
|
+
}
|
|
269
|
+
if (flags.reason === "true") {
|
|
270
|
+
console.error("uai: --reason needs text (quote multiword reasons)");
|
|
271
|
+
process.exit(1);
|
|
272
|
+
}
|
|
273
|
+
const closed = await api("POST", "/api/agent/tasks/close", {
|
|
274
|
+
status,
|
|
275
|
+
...(flags.reason ? { reason: String(flags.reason) } : {}),
|
|
276
|
+
});
|
|
277
|
+
out("task closed as " + closed.status);
|
|
278
|
+
break;
|
|
279
|
+
}
|
|
256
280
|
case "todo list": {
|
|
257
281
|
const todos = (await api("GET", "/api/agent/todos")).todos || [];
|
|
258
282
|
if (!todos.length) { out("punchlist is empty"); break; }
|
|
@@ -329,6 +353,7 @@ async function main() {
|
|
|
329
353
|
" uai people list",
|
|
330
354
|
" uai task list",
|
|
331
355
|
" uai task create --name <n> --prompt <p> [--projects id,id] [--team id] [--agents handle,handle]",
|
|
356
|
+
' uai task close --status finished|canceled [--reason "final report"]',
|
|
332
357
|
" uai memory search <query>",
|
|
333
358
|
" uai memory save <text> [--project id] [--tags a,b]",
|
|
334
359
|
" uai memory delete <id>",
|
|
@@ -249,14 +249,28 @@ export class DurableProcess {
|
|
|
249
249
|
this.exitHandlers.add(handler);
|
|
250
250
|
}
|
|
251
251
|
|
|
252
|
-
/**
|
|
252
|
+
/**
|
|
253
|
+
* Serialise `value` as one JSONL line appended to the runner's inbox.
|
|
254
|
+
* A runner-filtered metadata line immediately before it supplies the local
|
|
255
|
+
* timestamp without changing the protocol frame forwarded to the CLI. Old
|
|
256
|
+
* runners already ignore unknown `__uai` lines, so attached sessions remain
|
|
257
|
+
* wire-compatible across the host update.
|
|
258
|
+
*
|
|
259
|
+
* `__uai` MUST stay the first key of the metadata object. Both runners filter
|
|
260
|
+
* with `line.startsWith('{"__uai"')` — a byte prefix, not a parse — so
|
|
261
|
+
* serialising the keys in any other order emits `{"ts":…`, which the runner
|
|
262
|
+
* then forwards to the CLI's stdin as a user turn. No type in this file
|
|
263
|
+
* expresses that, and a `toEqual` assertion cannot see it; the raw-prefix
|
|
264
|
+
* check in `durable-proc.test.ts` is what pins it.
|
|
265
|
+
*/
|
|
253
266
|
writeLine(value: unknown): void {
|
|
254
267
|
if (this.closed || this.detached) return;
|
|
255
268
|
const json = JSON.stringify(value);
|
|
269
|
+
const inputMeta = JSON.stringify({ __uai: "input", ts: Date.now() });
|
|
256
270
|
if (this.debug) this.log(`-> ${json.slice(0, 1000)}`);
|
|
257
271
|
// Chain appends so concurrent writes can't interleave bytes.
|
|
258
272
|
this.inboxChain = this.inboxChain
|
|
259
|
-
.then(() => fsp.appendFile(this.inboxPath, `${json}\n`))
|
|
273
|
+
.then(() => fsp.appendFile(this.inboxPath, `${inputMeta}\n${json}\n`))
|
|
260
274
|
.catch(() => {
|
|
261
275
|
// Disk error — liveness checks will surface a dead session.
|
|
262
276
|
});
|
package/lib/agents/transport.ts
CHANGED
|
@@ -22,8 +22,16 @@
|
|
|
22
22
|
* feature, and each spawn ships the runner version matching this host.
|
|
23
23
|
*/
|
|
24
24
|
|
|
25
|
-
import {
|
|
26
|
-
|
|
25
|
+
import {
|
|
26
|
+
copyFileSync,
|
|
27
|
+
mkdirSync,
|
|
28
|
+
renameSync,
|
|
29
|
+
rmSync,
|
|
30
|
+
statSync,
|
|
31
|
+
symlinkSync,
|
|
32
|
+
promises as fsp,
|
|
33
|
+
} from "node:fs";
|
|
34
|
+
import { basename, dirname, join } from "node:path";
|
|
27
35
|
|
|
28
36
|
import { and, eq } from "drizzle-orm";
|
|
29
37
|
|
|
@@ -93,6 +101,7 @@ function durableEnabled(): boolean {
|
|
|
93
101
|
|
|
94
102
|
export function createAgentTransport(opts: AgentTransportOptions): LineTransport {
|
|
95
103
|
if (!durableEnabled()) {
|
|
104
|
+
clearCurrentSession(opts.taskId, opts.agentId);
|
|
96
105
|
const { command, args } = dockerExecArgs(
|
|
97
106
|
opts.containerName,
|
|
98
107
|
opts.cli,
|
|
@@ -157,6 +166,7 @@ export function createAgentTransport(opts: AgentTransportOptions): LineTransport
|
|
|
157
166
|
debugLabel: opts.debugLabel,
|
|
158
167
|
});
|
|
159
168
|
proc.onExit(markClosed);
|
|
169
|
+
publishCurrentSession(row.sessionDir);
|
|
160
170
|
return claimTail(tailKey, proc);
|
|
161
171
|
}
|
|
162
172
|
|
|
@@ -194,6 +204,7 @@ export function createAgentTransport(opts: AgentTransportOptions): LineTransport
|
|
|
194
204
|
console.warn(
|
|
195
205
|
`[transport] runner unavailable (${err instanceof Error ? err.message : err}) — falling back to direct pipes for ${opts.agentId}`,
|
|
196
206
|
);
|
|
207
|
+
clearCurrentSession(opts.taskId, opts.agentId);
|
|
197
208
|
const { command, args } = dockerExecArgs(
|
|
198
209
|
opts.containerName,
|
|
199
210
|
opts.cli,
|
|
@@ -263,9 +274,49 @@ export function createAgentTransport(opts: AgentTransportOptions): LineTransport
|
|
|
263
274
|
.run();
|
|
264
275
|
|
|
265
276
|
proc.onExit(markClosed);
|
|
277
|
+
publishCurrentSession(sessionDir);
|
|
266
278
|
return claimTail(tailKey, proc);
|
|
267
279
|
}
|
|
268
280
|
|
|
281
|
+
/**
|
|
282
|
+
* Make the opaque generation directory discoverable from inside the task.
|
|
283
|
+
* The relative link resolves on both sides of the workspace bind mount. The
|
|
284
|
+
* pointer is diagnostic only: failure to publish it must not kill a session.
|
|
285
|
+
*/
|
|
286
|
+
function publishCurrentSession(sessionDir: string): void {
|
|
287
|
+
const parent = dirname(sessionDir);
|
|
288
|
+
const current = join(parent, "current");
|
|
289
|
+
const pending = join(parent, `.current-${process.pid}-${Date.now()}`);
|
|
290
|
+
try {
|
|
291
|
+
symlinkSync(basename(sessionDir), pending, "dir");
|
|
292
|
+
renameSync(pending, current);
|
|
293
|
+
} catch {
|
|
294
|
+
try {
|
|
295
|
+
rmSync(pending, { force: true });
|
|
296
|
+
} catch {
|
|
297
|
+
// Best-effort navigation aid; the generation path remains authoritative.
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/** A direct-pipe session must not leave an older durable inbox looking live. */
|
|
303
|
+
function clearCurrentSession(taskId: string, agentId: string): void {
|
|
304
|
+
try {
|
|
305
|
+
rmSync(
|
|
306
|
+
join(
|
|
307
|
+
taskWorkspaceDir(taskId),
|
|
308
|
+
".uai",
|
|
309
|
+
"sessions",
|
|
310
|
+
agentId,
|
|
311
|
+
"current",
|
|
312
|
+
),
|
|
313
|
+
{ force: true },
|
|
314
|
+
);
|
|
315
|
+
} catch {
|
|
316
|
+
// Same best-effort rule as publishCurrentSession.
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
269
320
|
function heartbeatFresh(sessionDir: string): boolean {
|
|
270
321
|
try {
|
|
271
322
|
return (
|
package/lib/orchestrator.ts
CHANGED
|
@@ -1634,11 +1634,27 @@ export class Orchestrator {
|
|
|
1634
1634
|
return remaining;
|
|
1635
1635
|
}
|
|
1636
1636
|
|
|
1637
|
-
/**
|
|
1637
|
+
/**
|
|
1638
|
+
* Retire turn-local state owned by a runner generation before replacement.
|
|
1639
|
+
*
|
|
1640
|
+
* A replacement's identity guard suppresses the old runner's eventual
|
|
1641
|
+
* terminal event. If the human already stopped that turn, close its cloud
|
|
1642
|
+
* boundary here and discard its replay prompt; otherwise credential refresh
|
|
1643
|
+
* could silently restart canceled work on the new generation.
|
|
1644
|
+
*/
|
|
1638
1645
|
private retireRunnerGeneration(channel: Channel, agentId: string): void {
|
|
1646
|
+
const aborted = channel.interrupted.delete(agentId);
|
|
1639
1647
|
channel.activeTurns.delete(agentId);
|
|
1640
1648
|
channel.openTurns.delete(agentId);
|
|
1641
|
-
|
|
1649
|
+
if (aborted) {
|
|
1650
|
+
channel.lastPrompt.delete(agentId);
|
|
1651
|
+
this.emitHost({
|
|
1652
|
+
kind: "agent.turn_complete",
|
|
1653
|
+
taskId: channel.taskId,
|
|
1654
|
+
agentId,
|
|
1655
|
+
aborted: true,
|
|
1656
|
+
});
|
|
1657
|
+
}
|
|
1642
1658
|
}
|
|
1643
1659
|
|
|
1644
1660
|
private startPrompt(
|
|
@@ -1766,9 +1782,12 @@ export class Orchestrator {
|
|
|
1766
1782
|
// An interrupted turn is a half-turn — its eventual turn_complete must not
|
|
1767
1783
|
// hand the buffered fragment to @-mentioned peers. Flag it so the boundary
|
|
1768
1784
|
// goes out `aborted` and the cloud discards the buffer. Only when the turn
|
|
1769
|
-
// actually
|
|
1770
|
-
//
|
|
1771
|
-
|
|
1785
|
+
// is actually in flight (activeTurns): an idle-agent ESC must not mark the
|
|
1786
|
+
// NEXT legitimate turn as aborted, while a stop before the first output is
|
|
1787
|
+
// still a cancellation and must not be replayed by runner replacement.
|
|
1788
|
+
if ((channel.activeTurns.get(agentId) ?? 0) > 0) {
|
|
1789
|
+
channel.interrupted.add(agentId);
|
|
1790
|
+
}
|
|
1772
1791
|
void session.interrupt();
|
|
1773
1792
|
this.emitSystemNote(taskId, `Stopped @${agentId}.`);
|
|
1774
1793
|
return { ok: true };
|
|
@@ -2786,9 +2805,10 @@ export function assembleFirstTurnPrompt(
|
|
|
2786
2805
|
|
|
2787
2806
|
/**
|
|
2788
2807
|
* Build the system preamble an agent gets on session start. It opens
|
|
2789
|
-
* with how uai's channel works
|
|
2790
|
-
*
|
|
2791
|
-
*
|
|
2808
|
+
* with how uai's channel works. Open-mode agents hand off via `@mention`
|
|
2809
|
+
* because there is no `peer` command / shared tmux (ADR-008); Secretary-mode
|
|
2810
|
+
* crew report through the designated Secretary instead. It then appends the
|
|
2811
|
+
* project context and the agent's persona.
|
|
2792
2812
|
*
|
|
2793
2813
|
* The persona / mission layers (project defaultPrompts, globalContext,
|
|
2794
2814
|
* agent.defaultPrompt) live in the always-on system prompt so they apply
|
|
@@ -2813,6 +2833,12 @@ export function buildSystemPreamble(
|
|
|
2813
2833
|
a.id === agent.id ? `@${a.id} (${a.label}, you)` : `@${a.id} (${a.label})`,
|
|
2814
2834
|
)
|
|
2815
2835
|
.join(", ");
|
|
2836
|
+
const isSecretary =
|
|
2837
|
+
mode === "secretary" && secretaryAgentId === agent.id;
|
|
2838
|
+
const isSecretaryCrew =
|
|
2839
|
+
mode === "secretary" &&
|
|
2840
|
+
secretaryAgentId !== undefined &&
|
|
2841
|
+
!isSecretary;
|
|
2816
2842
|
// ADR-049: with several humans in the chat, brief the agent on who they are
|
|
2817
2843
|
// and how to address one specifically. Single-human tasks keep the original
|
|
2818
2844
|
// wording byte-identical.
|
|
@@ -2820,8 +2846,14 @@ export function buildSystemPreamble(
|
|
|
2820
2846
|
const humanList = (humans ?? [])
|
|
2821
2847
|
.map((h) => `@${h.handle} (${h.name}${h.isOwner ? ", task owner" : ""})`)
|
|
2822
2848
|
.join(", ");
|
|
2823
|
-
const humanIntro =
|
|
2849
|
+
const humanIntro = isSecretaryCrew
|
|
2824
2850
|
? [
|
|
2851
|
+
`The human-facing conversation is owned by @${secretaryAgentId}, the`,
|
|
2852
|
+
"designated Secretary. `@you` and human @handles in your replies remain",
|
|
2853
|
+
"visible as routing hints, but they do not notify a human directly.",
|
|
2854
|
+
]
|
|
2855
|
+
: multiHuman
|
|
2856
|
+
? [
|
|
2825
2857
|
`SEVERAL humans share this channel: ${humanList}. Their messages`,
|
|
2826
2858
|
"arrive prefixed with the sender's name so you can tell them apart.",
|
|
2827
2859
|
"`@you` still works and reaches the human you're currently talking",
|
|
@@ -2836,8 +2868,8 @@ export function buildSystemPreamble(
|
|
|
2836
2868
|
"just asked, acknowledgments — post in the channel WITHOUT",
|
|
2837
2869
|
"@-mentioning; they can read the channel and don't need a ping for",
|
|
2838
2870
|
"every message.",
|
|
2839
|
-
|
|
2840
|
-
|
|
2871
|
+
]
|
|
2872
|
+
: [
|
|
2841
2873
|
"The human you're working with is **@you**. @-mentioning them sends a",
|
|
2842
2874
|
"NOTIFICATION, so use it sparingly — only when you actually need them: a",
|
|
2843
2875
|
"decision you can't make, a blocker, an approval, or you've finished your",
|
|
@@ -2847,7 +2879,7 @@ export function buildSystemPreamble(
|
|
|
2847
2879
|
"something they just asked, acknowledgments — post in the channel WITHOUT",
|
|
2848
2880
|
"@-mentioning @you; they can read the channel and don't need a ping for",
|
|
2849
2881
|
"every message. Do NOT reflexively end messages with @you.",
|
|
2850
|
-
|
|
2882
|
+
];
|
|
2851
2883
|
const projectLines =
|
|
2852
2884
|
projects.length === 0
|
|
2853
2885
|
? ["(none mounted)"]
|
|
@@ -2855,8 +2887,6 @@ export function buildSystemPreamble(
|
|
|
2855
2887
|
(p) =>
|
|
2856
2888
|
`- \`${workspacePath}/${p.slug}\` — git worktree on \`${taskBranch}\``,
|
|
2857
2889
|
);
|
|
2858
|
-
const isSecretary =
|
|
2859
|
-
mode === "secretary" && secretaryAgentId === agent.id;
|
|
2860
2890
|
const dispatchActionBrief = [
|
|
2861
2891
|
"When crew work is needed, run the Secretary CLI action:",
|
|
2862
2892
|
`\`node ${CONTAINER_CLI_PATH} dispatch @agent [@agent…] \"instruction\"\``,
|
|
@@ -2892,6 +2922,22 @@ export function buildSystemPreamble(
|
|
|
2892
2922
|
"`/workspace/.uai/chat.md`. Read it whenever you need that context;",
|
|
2893
2923
|
"it's appended live, so re-read it for the latest.",
|
|
2894
2924
|
];
|
|
2925
|
+
const sessionInboxBrief =
|
|
2926
|
+
mode !== "secretary"
|
|
2927
|
+
? []
|
|
2928
|
+
: isSecretary
|
|
2929
|
+
? [
|
|
2930
|
+
"When durable sessions are active, their current inboxes are linked at",
|
|
2931
|
+
"`/workspace/.uai/sessions/<agent-id>/current/inbox.jsonl`.",
|
|
2932
|
+
'An epoch-ms `{"__uai":"input","ts":…}` line precedes each',
|
|
2933
|
+
"unchanged CLI frame, so the adjacent pair identifies a wake input.",
|
|
2934
|
+
]
|
|
2935
|
+
: [
|
|
2936
|
+
"When durable sessions are active, your current runner inbox is at",
|
|
2937
|
+
`\`/workspace/.uai/sessions/${agent.id}/current/inbox.jsonl\`.`,
|
|
2938
|
+
'An epoch-ms `{"__uai":"input","ts":…}` line precedes each',
|
|
2939
|
+
"unchanged CLI frame, so the adjacent pair identifies a wake input.",
|
|
2940
|
+
];
|
|
2895
2941
|
const secretaryRoleBrief = isSecretary
|
|
2896
2942
|
? [
|
|
2897
2943
|
"## Secretary role",
|
|
@@ -2920,7 +2966,14 @@ export function buildSystemPreamble(
|
|
|
2920
2966
|
"instruction for each",
|
|
2921
2967
|
"crew member you need. Do not merely say that someone else will answer.",
|
|
2922
2968
|
]
|
|
2923
|
-
:
|
|
2969
|
+
: isSecretaryCrew
|
|
2970
|
+
? [
|
|
2971
|
+
"Handles in your dispatch or the backstage transcript are context,",
|
|
2972
|
+
"not proof that another recipient was notified. Answer only for your",
|
|
2973
|
+
"assigned part. If someone else should act, state who and what is",
|
|
2974
|
+
"needed; the Secretary decides whether to dispatch it.",
|
|
2975
|
+
]
|
|
2976
|
+
: [
|
|
2924
2977
|
"When a message already @-mentions several participants at once (the",
|
|
2925
2978
|
"human asking the whole group, or a peer addressing multiple agents),",
|
|
2926
2979
|
"it's a group broadcast — this is a GROUP CHAT and everyone named has",
|
|
@@ -2930,14 +2983,20 @@ export function buildSystemPreamble(
|
|
|
2930
2983
|
"turn`, or `still waiting on @x`. Re-mentioning someone who already got",
|
|
2931
2984
|
"the message only wakes them again and spirals into duplicate replies.",
|
|
2932
2985
|
"Say your piece and stop.",
|
|
2933
|
-
|
|
2986
|
+
];
|
|
2934
2987
|
const handoffBrief = isSecretary
|
|
2935
2988
|
? [
|
|
2936
2989
|
"When crew work finishes, synthesize the outcome for the human and make",
|
|
2937
2990
|
"the next decision or blocker explicit. Do not abandon an unresolved",
|
|
2938
2991
|
"request silently, and do not wake a peer for acknowledgments alone.",
|
|
2939
2992
|
]
|
|
2940
|
-
:
|
|
2993
|
+
: isSecretaryCrew
|
|
2994
|
+
? [
|
|
2995
|
+
"When you finish, report the result, next decision, or blocker plainly",
|
|
2996
|
+
"to the Secretary; no @mention is needed. Do not assume a named peer",
|
|
2997
|
+
"was woken, and do not wait for a direct reply from one.",
|
|
2998
|
+
]
|
|
2999
|
+
: [
|
|
2941
3000
|
"Hand off when you finish your part of the work. When you've made",
|
|
2942
3001
|
"and committed your changes, or completed a review, end your reply by",
|
|
2943
3002
|
"@-mentioning the agent who should act next and telling them what you",
|
|
@@ -2947,7 +3006,7 @@ export function buildSystemPreamble(
|
|
|
2947
3006
|
"peer needs to act, it's fine to stop; only @-mention @you if you need",
|
|
2948
3007
|
"their input or are handing back finished work for them to act on. Don't",
|
|
2949
3008
|
"prolong an agent-to-agent exchange just to fill silence.",
|
|
2950
|
-
|
|
3009
|
+
];
|
|
2951
3010
|
const checkInTranscriptBrief = isSecretary
|
|
2952
3011
|
? [
|
|
2953
3012
|
"Read both transcript files named above, and speak ONLY if you have",
|
|
@@ -2955,11 +3014,13 @@ export function buildSystemPreamble(
|
|
|
2955
3014
|
"PASS reply is discarded and never shown to anyone, so it is always a",
|
|
2956
3015
|
"safe way to decline a turn.",
|
|
2957
3016
|
]
|
|
2958
|
-
:
|
|
3017
|
+
: isSecretaryCrew
|
|
3018
|
+
? []
|
|
3019
|
+
: [
|
|
2959
3020
|
"Read the transcript, and speak ONLY if you have something substantive to",
|
|
2960
3021
|
"add; otherwise reply with exactly `PASS` — a PASS reply is discarded and",
|
|
2961
3022
|
"never shown to anyone, so it is always a safe way to decline a turn.",
|
|
2962
|
-
|
|
3023
|
+
];
|
|
2963
3024
|
const workspaceBrief = isSecretary
|
|
2964
3025
|
? [
|
|
2965
3026
|
"## Workspace layout",
|
|
@@ -3009,19 +3070,35 @@ export function buildSystemPreamble(
|
|
|
3009
3070
|
"or @-mentioning a crew agent in prose does NOT wake them.",
|
|
3010
3071
|
"The only way to hand crew work off is the structured `dispatch` action.",
|
|
3011
3072
|
]
|
|
3012
|
-
:
|
|
3073
|
+
: isSecretaryCrew
|
|
3074
|
+
? [
|
|
3075
|
+
"You are a backstage crew agent in a Secretary-mode task channel.",
|
|
3076
|
+
`@${secretaryAgentId} is the designated Secretary and your sole`,
|
|
3077
|
+
"conversational routing point. Every completed reply you write is",
|
|
3078
|
+
`delivered once to @${secretaryAgentId}, whether it names @you, a`,
|
|
3079
|
+
"human, another crew agent, or nobody. Those names remain visible as",
|
|
3080
|
+
"routing hints, but they do not notify the human or wake a peer.",
|
|
3081
|
+
]
|
|
3082
|
+
: [
|
|
3013
3083
|
"You are one agent in a uai task chat channel, shared with the human",
|
|
3014
3084
|
"and the other agents. To hand work to or ask another agent, mention",
|
|
3015
3085
|
"it by id at the start of a line — e.g. `@codex please review the",
|
|
3016
3086
|
"diff`. uai routes that message into that agent's input.",
|
|
3017
|
-
|
|
3087
|
+
];
|
|
3018
3088
|
const collaborationBrief = isSecretary
|
|
3019
3089
|
? [
|
|
3020
3090
|
"Collaborate through deliberate dispatches. Each dispatch wakes a crew",
|
|
3021
3091
|
"agent and costs a turn, so make every instruction concrete, self-contained,",
|
|
3022
3092
|
"and necessary. Never dispatch greetings, thanks, or acknowledgments.",
|
|
3023
3093
|
]
|
|
3024
|
-
:
|
|
3094
|
+
: isSecretaryCrew
|
|
3095
|
+
? [
|
|
3096
|
+
`Only a structured dispatch from @${secretaryAgentId} wakes a crew`,
|
|
3097
|
+
"agent. Work on the assignment you received. If someone else should",
|
|
3098
|
+
"act, state who and what is needed; the Secretary decides whether to",
|
|
3099
|
+
"dispatch it.",
|
|
3100
|
+
]
|
|
3101
|
+
: [
|
|
3025
3102
|
"An agent only receives a message when it is explicitly @-mentioned",
|
|
3026
3103
|
"(or addressed by the human) — so always @-mention the agent (or @you)",
|
|
3027
3104
|
"you mean. There is NO `peer` command and no shared tmux session;",
|
|
@@ -3040,7 +3117,7 @@ export function buildSystemPreamble(
|
|
|
3040
3117
|
"don't @-mention back. And when there's no active task yet (intros, or",
|
|
3041
3118
|
"you're waiting on the human), answer briefly and then wait — you don't",
|
|
3042
3119
|
"need to @-mention anyone (including @you); they can see the channel.",
|
|
3043
|
-
|
|
3120
|
+
];
|
|
3044
3121
|
const channelConventionsBrief = isSecretary
|
|
3045
3122
|
? [
|
|
3046
3123
|
"Your prose always goes to the human-facing lane; answer plainly without",
|
|
@@ -3049,13 +3126,19 @@ export function buildSystemPreamble(
|
|
|
3049
3126
|
"You may occasionally receive a `[channel check-in]` asking you to catch",
|
|
3050
3127
|
"up on the channel.",
|
|
3051
3128
|
]
|
|
3052
|
-
:
|
|
3129
|
+
: isSecretaryCrew
|
|
3130
|
+
? [
|
|
3131
|
+
"Every reply returns to the Secretary whether or not it contains",
|
|
3132
|
+
"@mentions. Answer plainly; use names only when they help the",
|
|
3133
|
+
"Secretary understand who should hear or act on the result.",
|
|
3134
|
+
]
|
|
3135
|
+
: [
|
|
3053
3136
|
"Two channel conventions (ADR-050): (1) If your reply @-mentions nobody,",
|
|
3054
3137
|
"uai hands it back to whoever prompted you — so when you're ANSWERING,",
|
|
3055
3138
|
"just answer plainly; you don't need to re-mention the asker. Mention",
|
|
3056
3139
|
"someone only to bring them in or hand work off. (2) You may occasionally",
|
|
3057
3140
|
"receive a `[channel check-in]` asking you to catch up on the channel.",
|
|
3058
|
-
|
|
3141
|
+
];
|
|
3059
3142
|
const comms = [
|
|
3060
3143
|
"## uai task channel",
|
|
3061
3144
|
"",
|
|
@@ -3075,6 +3158,8 @@ export function buildSystemPreamble(
|
|
|
3075
3158
|
"",
|
|
3076
3159
|
...transcriptBrief,
|
|
3077
3160
|
"",
|
|
3161
|
+
...sessionInboxBrief,
|
|
3162
|
+
...(sessionInboxBrief.length > 0 ? [""] : []),
|
|
3078
3163
|
...secretaryRoleBrief,
|
|
3079
3164
|
...channelConventionsBrief,
|
|
3080
3165
|
...checkInTranscriptBrief,
|
|
@@ -3172,6 +3257,9 @@ export function buildSystemPreamble(
|
|
|
3172
3257
|
(agent.permissions?.includes("tasks.create")
|
|
3173
3258
|
? ", `task create --name <n> --prompt <p> [--projects id,id] [--team id] [--agents handle,handle]`"
|
|
3174
3259
|
: "") +
|
|
3260
|
+
(agent.permissions?.includes("tasks.close")
|
|
3261
|
+
? ", `task close --status finished|canceled [--reason \"final report\"]`"
|
|
3262
|
+
: "") +
|
|
3175
3263
|
(agent.permissions?.includes("memory.write")
|
|
3176
3264
|
? ", `memory save <text>`"
|
|
3177
3265
|
: "") +
|
|
@@ -3208,6 +3296,17 @@ export function buildSystemPreamble(
|
|
|
3208
3296
|
"`projects list` / `people list`.",
|
|
3209
3297
|
]
|
|
3210
3298
|
: []),
|
|
3299
|
+
...(agent.permissions?.includes("tasks.close")
|
|
3300
|
+
? [
|
|
3301
|
+
"**Closing this task:** `task close` is task-wide, not a way to",
|
|
3302
|
+
"say that only your part is done. It stops every agent and",
|
|
3303
|
+
"destroys this container/session. Use it only when the ENTIRE",
|
|
3304
|
+
"task is terminal and the human's instructions authorize that",
|
|
3305
|
+
"outcome. Make it the final action of your turn and put the",
|
|
3306
|
+
"useful final summary in `--reason`, because your normal response",
|
|
3307
|
+
"may not be delivered after the container tears down.",
|
|
3308
|
+
]
|
|
3309
|
+
: []),
|
|
3211
3310
|
...(agent.permissions?.includes("memory.read") ||
|
|
3212
3311
|
agent.permissions?.includes("memory.write")
|
|
3213
3312
|
? [
|
package/package.json
CHANGED
package/runner/runner.mjs
CHANGED
|
@@ -11,9 +11,9 @@
|
|
|
11
11
|
* <sessionDir>/runner.json pid, protocol, argv, startedAt
|
|
12
12
|
*
|
|
13
13
|
* Meta lines are `{"__uai":"spawn"|"exit", ...}`; the host filters them out
|
|
14
|
-
* before handing lines to the protocol adapters.
|
|
15
|
-
*
|
|
16
|
-
*
|
|
14
|
+
* before handing lines to the protocol adapters. Host metadata/control lines
|
|
15
|
+
* in the inbox use the same shape (`input` timestamps and `stop`); the runner
|
|
16
|
+
* filters those, while every protocol frame goes to the CLI's stdin untouched.
|
|
17
17
|
*
|
|
18
18
|
* Plain Node ≥18, dependency-free, ESM. Testable outside docker: point it
|
|
19
19
|
* at a tmp dir and any line-oriented fake CLI.
|
package/src/protocol.ts
CHANGED
|
@@ -43,6 +43,14 @@ export const MAX_SECRETARY_DISPATCH_ID_CHARS = 256;
|
|
|
43
43
|
|
|
44
44
|
export interface CommandContext {
|
|
45
45
|
commandId: string;
|
|
46
|
+
/**
|
|
47
|
+
* Cloud-side deadline for this command, in ms. Optional and OFF by default:
|
|
48
|
+
* most commands (task-up especially) legitimately take minutes, so a blanket
|
|
49
|
+
* timeout would be wrong. When set, the cloud stops waiting AND forgets the
|
|
50
|
+
* pending entry — without that second half a silent host accumulates one
|
|
51
|
+
* dead resolver per retry.
|
|
52
|
+
*/
|
|
53
|
+
timeoutMs?: number;
|
|
46
54
|
}
|
|
47
55
|
|
|
48
56
|
export type PermissionDecision = { kind: "accept" } | { kind: "decline" };
|