@ouro.bot/cli 0.1.0-alpha.895 → 0.1.0-alpha.898
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.json +19 -0
- package/deploy/unraid/sanctuary.ouro/bundle-meta.json +1 -1
- package/deploy/unraid/sanctuary.xml +1 -1
- package/dist/heart/daemon/runtime-logging.js +1 -1
- package/dist/heart/daemon/sense-manager.js +20 -20
- package/dist/heart/identity.js +4 -4
- package/dist/heart/private-runtime/policy.js +4 -4
- package/dist/heart/sense-truth.js +2 -2
- package/dist/heart/{cmux-config.js → shepherd-config.js} +37 -26
- package/dist/heart/steward-policy.js +0 -26
- package/dist/heart/turn-context.js +5 -5
- package/dist/mind/prompt.js +5 -5
- package/dist/repertoire/guardrails.js +4 -4
- package/dist/repertoire/tools-shepherd.js +180 -0
- package/dist/repertoire/tools.js +4 -4
- package/dist/senses/{cmux → shepherd}/client.js +3 -3
- package/dist/senses/shepherd/cmux.js +332 -0
- package/dist/senses/shepherd/herdr.js +311 -0
- package/dist/senses/shepherd/host.js +14 -0
- package/dist/senses/shepherd/judge.js +98 -0
- package/dist/senses/{cmux → shepherd}/redact.js +3 -3
- package/dist/senses/shepherd/returns.js +79 -0
- package/dist/senses/shepherd/sense.js +288 -0
- package/dist/senses/{cmux-entry.js → shepherd-entry.js} +10 -9
- package/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
- package/dist/repertoire/tools-cmux.js +0 -396
- package/dist/senses/cmux/answer.js +0 -191
- package/dist/senses/cmux/attention.js +0 -265
- package/dist/senses/cmux/casebook.js +0 -416
- package/dist/senses/cmux/floor.js +0 -407
- package/dist/senses/cmux/principles.js +0 -72
- package/dist/senses/cmux/sense.js +0 -287
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.shepherdToolDefinitions = exports.shepherdSignalToolDefinition = exports.shepherdReadToolDefinition = exports.shepherdOverviewToolDefinition = void 0;
|
|
37
|
+
exports.shepherdToolsEnabled = shepherdToolsEnabled;
|
|
38
|
+
const fs = __importStar(require("node:fs"));
|
|
39
|
+
const path = __importStar(require("node:path"));
|
|
40
|
+
const runtime_credentials_1 = require("../heart/runtime-credentials");
|
|
41
|
+
const shepherd_config_1 = require("../heart/shepherd-config");
|
|
42
|
+
const runtime_1 = require("../nerves/runtime");
|
|
43
|
+
const host_1 = require("../senses/shepherd/host");
|
|
44
|
+
const redact_1 = require("../senses/shepherd/redact");
|
|
45
|
+
const returns_1 = require("../senses/shepherd/returns");
|
|
46
|
+
/**
|
|
47
|
+
* Shepherd's tools: every coding-agent session on this machine's terminal host with what Shepherd
|
|
48
|
+
* last decided about it (`shepherd_overview`), what a session shows (`shepherd_read`), and a status
|
|
49
|
+
* line or notification for the human (`shepherd_signal`).
|
|
50
|
+
*/
|
|
51
|
+
const STATUS_MAX_CHARS = 120;
|
|
52
|
+
const READ_MAX_CHARS = 16_000;
|
|
53
|
+
function text(value) {
|
|
54
|
+
return typeof value === "string" ? value : "";
|
|
55
|
+
}
|
|
56
|
+
function shepherdToolsEnabled(agentRoot) {
|
|
57
|
+
if (!agentRoot)
|
|
58
|
+
return false;
|
|
59
|
+
try {
|
|
60
|
+
const config = JSON.parse(fs.readFileSync(path.join(agentRoot, "agent.json"), "utf-8"));
|
|
61
|
+
return config.senses?.shepherd?.enabled === true;
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
return false;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
function hostFor(agentName) {
|
|
68
|
+
const machine = (0, runtime_credentials_1.readMachineRuntimeCredentialConfig)(agentName);
|
|
69
|
+
const resolved = (0, shepherd_config_1.resolveShepherdConnection)(agentName, machine.ok ? machine.config : {});
|
|
70
|
+
return resolved.ok ? (0, host_1.createShepherdHost)(resolved.connection, { agentName }) : resolved.error;
|
|
71
|
+
}
|
|
72
|
+
async function withHost(tool, ctx, run) {
|
|
73
|
+
if (!ctx?.agentName || !ctx.agentRoot)
|
|
74
|
+
return JSON.stringify({ error: "the Shepherd tools need an agent runtime" });
|
|
75
|
+
const host = hostFor(ctx.agentName);
|
|
76
|
+
if (typeof host === "string")
|
|
77
|
+
return JSON.stringify({ error: host });
|
|
78
|
+
try {
|
|
79
|
+
const result = await run(host);
|
|
80
|
+
(0, runtime_1.emitNervesEvent)({ component: "repertoire", event: "repertoire.shepherd_tool", message: "ran a Shepherd tool", meta: { tool } });
|
|
81
|
+
return JSON.stringify(result);
|
|
82
|
+
}
|
|
83
|
+
catch (error) {
|
|
84
|
+
(0, runtime_1.emitNervesEvent)({ level: "warn", component: "repertoire", event: "repertoire.shepherd_tool_error", message: "Shepherd tool call failed", meta: { tool, error: error.message } });
|
|
85
|
+
return JSON.stringify({ error: error.message });
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
exports.shepherdOverviewToolDefinition = {
|
|
89
|
+
tool: {
|
|
90
|
+
type: "function",
|
|
91
|
+
function: {
|
|
92
|
+
name: "shepherd_overview",
|
|
93
|
+
description: "Every terminal session on this machine's terminal host (cmux), with the coding agent there when known, and the last time Shepherd judged a return of control in it: when, what kind (premature, gate, done, unclear, loop_guard, error), whether it answered, and why. Read-only.",
|
|
94
|
+
parameters: { type: "object", properties: {}, additionalProperties: false },
|
|
95
|
+
},
|
|
96
|
+
},
|
|
97
|
+
handler: (_args, ctx) => withHost("shepherd_overview", ctx, async (host) => {
|
|
98
|
+
const last = new Map();
|
|
99
|
+
for (const record of (0, returns_1.readReturns)(ctx.agentRoot))
|
|
100
|
+
last.set(record.session, record);
|
|
101
|
+
return {
|
|
102
|
+
host: host.name,
|
|
103
|
+
sessions: (await host.list()).map((session) => {
|
|
104
|
+
const record = last.get(session.id);
|
|
105
|
+
return { ...session, ...(record ? { lastReturn: { at: record.at, kind: record.kind, action: record.action, reason: record.reason, reply: record.reply, task: record.task } } : {}) };
|
|
106
|
+
}),
|
|
107
|
+
};
|
|
108
|
+
}),
|
|
109
|
+
summaryKeys: [],
|
|
110
|
+
riskProfile: { mutates: "none", risk: "low" },
|
|
111
|
+
};
|
|
112
|
+
function boundedLines(value) {
|
|
113
|
+
const parsed = typeof value === "number" ? value : Number.parseInt(text(value), 10);
|
|
114
|
+
return Number.isFinite(parsed) ? Math.min(400, Math.max(1, Math.trunc(parsed))) : 60;
|
|
115
|
+
}
|
|
116
|
+
exports.shepherdReadToolDefinition = {
|
|
117
|
+
tool: {
|
|
118
|
+
type: "function",
|
|
119
|
+
function: {
|
|
120
|
+
name: "shepherd_read",
|
|
121
|
+
description: "Read the text of one terminal session (what a coding agent shows now and what it did recently). Secret-shaped strings are redacted and the text is bounded. Read-only. Take the session id or ref from shepherd_overview.",
|
|
122
|
+
parameters: {
|
|
123
|
+
type: "object",
|
|
124
|
+
properties: {
|
|
125
|
+
session: { type: "string", description: "Session id or ref (surface:12) from shepherd_overview." },
|
|
126
|
+
lines: { type: "number", description: "How many lines, 1-400. Default 60." },
|
|
127
|
+
},
|
|
128
|
+
required: ["session"],
|
|
129
|
+
additionalProperties: false,
|
|
130
|
+
},
|
|
131
|
+
},
|
|
132
|
+
},
|
|
133
|
+
handler: (args, ctx) => {
|
|
134
|
+
const session = text(args.session).trim();
|
|
135
|
+
if (!session)
|
|
136
|
+
return Promise.resolve(JSON.stringify({ error: "name a session id or ref from shepherd_overview" }));
|
|
137
|
+
return withHost("shepherd_read", ctx, async (host) => {
|
|
138
|
+
const screen = (0, redact_1.redactSecrets)(await host.read(session, boundedLines(args.lines)));
|
|
139
|
+
return { session, text: screen.slice(-READ_MAX_CHARS), truncated: screen.length > READ_MAX_CHARS };
|
|
140
|
+
});
|
|
141
|
+
},
|
|
142
|
+
summaryKeys: ["session"],
|
|
143
|
+
riskProfile: { mutates: "none", risk: "low" },
|
|
144
|
+
};
|
|
145
|
+
exports.shepherdSignalToolDefinition = {
|
|
146
|
+
tool: {
|
|
147
|
+
type: "function",
|
|
148
|
+
function: {
|
|
149
|
+
name: "shepherd_signal",
|
|
150
|
+
description: `Tell the human something through the terminal host. status sets Shepherd's one-line status on that session's workspace (at most ${STATUS_MAX_CHARS} characters; an empty string clears it). notify_title with notify_body also posts a desktop notification there. It never types into a terminal.`,
|
|
151
|
+
parameters: {
|
|
152
|
+
type: "object",
|
|
153
|
+
properties: {
|
|
154
|
+
session: { type: "string", description: "Session id or ref from shepherd_overview." },
|
|
155
|
+
status: { type: "string", description: "One line for the status, or an empty string to clear it." },
|
|
156
|
+
notify_title: { type: "string", description: "Desktop notification title." },
|
|
157
|
+
notify_body: { type: "string", description: "Desktop notification body." },
|
|
158
|
+
},
|
|
159
|
+
required: ["session", "status"],
|
|
160
|
+
additionalProperties: false,
|
|
161
|
+
},
|
|
162
|
+
},
|
|
163
|
+
},
|
|
164
|
+
handler: (args, ctx) => {
|
|
165
|
+
const session = text(args.session).trim();
|
|
166
|
+
const status = text(args.status).trim();
|
|
167
|
+
const title = text(args.notify_title).trim();
|
|
168
|
+
if (!session)
|
|
169
|
+
return Promise.resolve(JSON.stringify({ error: "name a session id or ref from shepherd_overview" }));
|
|
170
|
+
if (status.length > STATUS_MAX_CHARS || /[\r\n]/.test(status))
|
|
171
|
+
return Promise.resolve(JSON.stringify({ error: `status must be one line of at most ${STATUS_MAX_CHARS} characters` }));
|
|
172
|
+
return withHost("shepherd_signal", ctx, async (host) => {
|
|
173
|
+
await host.signal(session, status || null, title ? { title, body: text(args.notify_body) } : undefined);
|
|
174
|
+
return { session, status: status ? "set" : "cleared", notified: !!title };
|
|
175
|
+
});
|
|
176
|
+
},
|
|
177
|
+
summaryKeys: ["session", "status"],
|
|
178
|
+
riskProfile: { mutates: "external_side_effect", risk: "high", reason: "changes what the terminal host shows the human (a status line or a desktop notification)" },
|
|
179
|
+
};
|
|
180
|
+
exports.shepherdToolDefinitions = [exports.shepherdOverviewToolDefinition, exports.shepherdReadToolDefinition, exports.shepherdSignalToolDefinition];
|
package/dist/repertoire/tools.js
CHANGED
|
@@ -37,7 +37,7 @@ const tools_unraid_1 = require("./tools-unraid");
|
|
|
37
37
|
const tools_flow_1 = require("./tools-flow");
|
|
38
38
|
const tools_steward_policy_1 = require("./tools-steward-policy");
|
|
39
39
|
const tools_house_care_1 = require("./tools-house-care");
|
|
40
|
-
const
|
|
40
|
+
const tools_shepherd_1 = require("./tools-shepherd");
|
|
41
41
|
const steward_policy_1 = require("../heart/steward-policy");
|
|
42
42
|
const relationship_authorization_1 = require("./relationship-authorization");
|
|
43
43
|
const tool_approval_1 = require("../heart/tool-approval");
|
|
@@ -98,7 +98,7 @@ Object.defineProperty(exports, "speakTool", { enumerable: true, get: function ()
|
|
|
98
98
|
var tools_surface_2 = require("./tools-surface");
|
|
99
99
|
Object.defineProperty(exports, "surfaceToolDef", { enumerable: true, get: function () { return tools_surface_2.surfaceToolDef; } });
|
|
100
100
|
// All tool definitions in a single registry
|
|
101
|
-
const additionalDefinitions = [...tools_bluebubbles_1.bluebubblesToolDefinitions, ...tools_teams_1.teamsToolDefinitions, ...ado_semantic_1.adoSemanticToolDefinitions, ...tools_github_1.githubToolDefinitions, ...tools_bundle_1.bundleToolDefinitions, ...tools_voice_1.voiceToolDefinitions, ...tools_unraid_1.unraidToolDefinitions, tools_steward_policy_1.stewardPolicyToolDefinition, ...tools_house_care_1.houseCareToolDefinitions, tools_surface_1.surfaceToolDefinition, ...
|
|
101
|
+
const additionalDefinitions = [...tools_bluebubbles_1.bluebubblesToolDefinitions, ...tools_teams_1.teamsToolDefinitions, ...ado_semantic_1.adoSemanticToolDefinitions, ...tools_github_1.githubToolDefinitions, ...tools_bundle_1.bundleToolDefinitions, ...tools_voice_1.voiceToolDefinitions, ...tools_unraid_1.unraidToolDefinitions, tools_steward_policy_1.stewardPolicyToolDefinition, ...tools_house_care_1.houseCareToolDefinitions, tools_surface_1.surfaceToolDefinition, ...tools_shepherd_1.shepherdToolDefinitions];
|
|
102
102
|
const COMMERCE_AUTHORITY_TOOLS = new Set(["stripe_create_card", "flight_hold", "flight_book"]);
|
|
103
103
|
class ToolSelectionError extends Error {
|
|
104
104
|
}
|
|
@@ -172,8 +172,8 @@ function selectToolsForChannel(capabilities, toolPreferences, _context, provider
|
|
|
172
172
|
});
|
|
173
173
|
ordinary = [
|
|
174
174
|
...tools_base_1.baseToolDefinitions,
|
|
175
|
-
// The
|
|
176
|
-
...((0,
|
|
175
|
+
// The Shepherd tools exist only for an agent that turned the sense on in agent.json.
|
|
176
|
+
...((0, tools_shepherd_1.shepherdToolsEnabled)(context?.agentRoot ?? safeGetAgentRoot()) ? tools_shepherd_1.shepherdToolDefinitions : []),
|
|
177
177
|
...(capabilities?.channel === "bluebubbles" ? tools_bluebubbles_1.bluebubblesToolDefinitions : []),
|
|
178
178
|
...(capabilities?.channel === "voice" ? tools_voice_1.voiceToolDefinitions : []),
|
|
179
179
|
...((0, tools_voice_local_1.localAudioToolGate)({ ...context, context: _context }).ok ? [tools_voice_local_1.voiceLocalAudioToolDefinition] : []),
|
|
@@ -37,7 +37,7 @@ exports.CmuxSocketError = void 0;
|
|
|
37
37
|
exports.createCmuxClient = createCmuxClient;
|
|
38
38
|
const node_crypto_1 = require("node:crypto");
|
|
39
39
|
const net = __importStar(require("node:net"));
|
|
40
|
-
const
|
|
40
|
+
const shepherd_config_1 = require("../../heart/shepherd-config");
|
|
41
41
|
const runtime_1 = require("../../nerves/runtime");
|
|
42
42
|
class CmuxSocketError extends Error {
|
|
43
43
|
code;
|
|
@@ -63,7 +63,7 @@ function parseRecord(line) {
|
|
|
63
63
|
}
|
|
64
64
|
function createCmuxClient(connection, options = {}) {
|
|
65
65
|
const timeoutMs = options.timeoutMs ?? 5_000;
|
|
66
|
-
const repair = (0,
|
|
66
|
+
const repair = (0, shepherd_config_1.shepherdRepairHint)(options.agentName ?? "<agent>");
|
|
67
67
|
const wrap = (line) => connection.auth.kind === "capability"
|
|
68
68
|
? `_cmux_capability_v1 ${connection.auth.token} ${line}`
|
|
69
69
|
: line;
|
|
@@ -146,7 +146,7 @@ function createCmuxClient(connection, options = {}) {
|
|
|
146
146
|
async call(method, params = {}) {
|
|
147
147
|
const text = await request(v2Line(method, params));
|
|
148
148
|
const result = parseV2(text);
|
|
149
|
-
(0, runtime_1.emitNervesEvent)({ component: "senses", event: "senses.
|
|
149
|
+
(0, runtime_1.emitNervesEvent)({ component: "senses", event: "senses.shepherd_call", message: "cmux socket call answered", meta: { method } });
|
|
150
150
|
return result;
|
|
151
151
|
},
|
|
152
152
|
async command(line) {
|
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.QUIET_MAX_MS = exports.QUIET_MS = exports.POLL_MS = exports.BACKGROUND_GRACE_MS = exports.GRACE_MS = exports.CMUX_STATUS_KEY = void 0;
|
|
37
|
+
exports.realSchedule = realSchedule;
|
|
38
|
+
exports.readCmuxHookSession = readCmuxHookSession;
|
|
39
|
+
exports.createCmuxHost = createCmuxHost;
|
|
40
|
+
const node_crypto_1 = require("node:crypto");
|
|
41
|
+
const fs = __importStar(require("node:fs"));
|
|
42
|
+
const os = __importStar(require("node:os"));
|
|
43
|
+
const path = __importStar(require("node:path"));
|
|
44
|
+
const runtime_1 = require("../../nerves/runtime");
|
|
45
|
+
/**
|
|
46
|
+
* The cmux host (0.64.22 or later). A return is cmux's own "agent stopped" signal: an
|
|
47
|
+
* `agent.hook.Stop` event, which cmux publishes for every agent its hooks cover (Claude Code,
|
|
48
|
+
* Codex, Copilot and more). After a short grace period, a stop is dropped if the agent resumed. A
|
|
49
|
+
* stop that cmux's session store says left background work running waits longer, and is then
|
|
50
|
+
* passed on marked as such: the agent is still working, not handing back control. Terminals no hook has spoken for
|
|
51
|
+
* (an agent cmux does not hook, such as Agency today) are polled: output that stops changing for a
|
|
52
|
+
* quiet window counts as a return. cmux has no bell or output event, so polling is the fallback.
|
|
53
|
+
*/
|
|
54
|
+
exports.CMUX_STATUS_KEY = "ouro";
|
|
55
|
+
exports.GRACE_MS = 3_000;
|
|
56
|
+
/** A stop with background work still running waits this much longer before it is judged (as working, unless the work finished). */
|
|
57
|
+
exports.BACKGROUND_GRACE_MS = 30_000;
|
|
58
|
+
exports.POLL_MS = 4_000;
|
|
59
|
+
exports.QUIET_MS = 8_000;
|
|
60
|
+
/** A quiet terminal judged as still working or not an agent is checked less often, doubling up to this. */
|
|
61
|
+
exports.QUIET_MAX_MS = 300_000;
|
|
62
|
+
const POLL_LINES = 40;
|
|
63
|
+
const RECONNECT_DELAYS_MS = [1_000, 2_000, 5_000, 10_000, 30_000];
|
|
64
|
+
const STOP_HOOKS = new Set(["Stop", "StopFailure"]);
|
|
65
|
+
/** Hooks that say nothing about whether the main agent is working. */
|
|
66
|
+
const NEUTRAL_HOOKS = new Set(["SubagentStop", "Notification"]);
|
|
67
|
+
const NAMED_KEYS = new Set(["enter", "escape", "up", "down", "tab"]);
|
|
68
|
+
const SUBMIT_DELAY_MS = 300;
|
|
69
|
+
/** A timer that can be cancelled; the sense and the host take it as an option so tests can drive time. */
|
|
70
|
+
function realSchedule(fn, ms) {
|
|
71
|
+
const timer = setTimeout(fn, ms);
|
|
72
|
+
return () => clearTimeout(timer);
|
|
73
|
+
}
|
|
74
|
+
function isRecord(value) {
|
|
75
|
+
return !!value && typeof value === "object" && !Array.isArray(value);
|
|
76
|
+
}
|
|
77
|
+
function str(value) {
|
|
78
|
+
return typeof value === "string" && value.length > 0 ? value : null;
|
|
79
|
+
}
|
|
80
|
+
function list(value) {
|
|
81
|
+
return Array.isArray(value) ? value.filter(isRecord) : [];
|
|
82
|
+
}
|
|
83
|
+
/** A v1 socket argument: quoted, with the characters cmux's tokenizer treats as special escaped. */
|
|
84
|
+
function socketQuote(value) {
|
|
85
|
+
return `"${value.replace(/\\/g, "\\\\").replace(/"/g, "\\\"")}"`;
|
|
86
|
+
}
|
|
87
|
+
/** cmux keeps each hooked agent's session in `~/.cmuxterm/<agent>-hook-sessions.json`, keyed by the agent's own session id. */
|
|
88
|
+
function readCmuxHookSession(homeDir, agent, sessionId) {
|
|
89
|
+
try {
|
|
90
|
+
const store = JSON.parse(fs.readFileSync(path.join(homeDir, ".cmuxterm", `${path.basename(agent)}-hook-sessions.json`), "utf-8"));
|
|
91
|
+
const sessions = isRecord(store) && isRecord(store.sessions) ? store.sessions : {};
|
|
92
|
+
// Hook events name the session `<agent>-<id>`; the store keys it by the bare id.
|
|
93
|
+
const key = [sessionId, sessionId.replace(`${agent}-`, "")].find((candidate) => isRecord(sessions[candidate]));
|
|
94
|
+
return key ? sessions[key] : null;
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
function createCmuxHost(client, options = {}) {
|
|
101
|
+
const homeDir = options.homeDir ?? os.homedir();
|
|
102
|
+
const now = options.now ?? Date.now;
|
|
103
|
+
const schedule = options.schedule ?? realSchedule;
|
|
104
|
+
/** What hook events taught: which surface a session runs in, and which agent and folder it has. */
|
|
105
|
+
const hooked = new Map();
|
|
106
|
+
const sessionSurface = new Map();
|
|
107
|
+
/** Terminals showing a Shepherd status, so it can be cleared when the agent resumes. */
|
|
108
|
+
const statused = new Map();
|
|
109
|
+
/** Quiet windows stretched by `pace`. */
|
|
110
|
+
const quietMs = new Map();
|
|
111
|
+
/** One status entry per terminal: cmux keys status pills per workspace, and a workspace can hold several agents. */
|
|
112
|
+
const statusKey = (surface) => `${exports.CMUX_STATUS_KEY}_${surface.ref.replace(/\W/g, "_")}`;
|
|
113
|
+
const clearStatus = (surfaceId) => {
|
|
114
|
+
const entry = statused.get(surfaceId);
|
|
115
|
+
if (!entry)
|
|
116
|
+
return;
|
|
117
|
+
statused.delete(surfaceId);
|
|
118
|
+
client.command(`clear_status ${entry.key} --tab=${entry.workspaceId}`).catch((error) => {
|
|
119
|
+
(0, runtime_1.emitNervesEvent)({ level: "warn", component: "senses", event: "senses.shepherd_cmux_status_error", message: "could not clear a Shepherd status", meta: { error: error.message } });
|
|
120
|
+
});
|
|
121
|
+
};
|
|
122
|
+
async function surfaces() {
|
|
123
|
+
const tree = await client.call("system.tree", { all_windows: true });
|
|
124
|
+
return list(tree.windows).flatMap((window) => list(window.workspaces).map((workspace) => ({ window, workspace }))).flatMap(({ window, workspace }) => list(workspace.panes).flatMap((pane) => list(pane.surfaces)).map((surface) => ({
|
|
125
|
+
id: str(surface.id) ?? "",
|
|
126
|
+
ref: str(surface.ref) ?? "",
|
|
127
|
+
workspace: str(workspace.ref),
|
|
128
|
+
workspaceId: str(workspace.id) ?? "",
|
|
129
|
+
title: str(surface.title) ?? "",
|
|
130
|
+
type: str(surface.type) ?? "",
|
|
131
|
+
agent: hooked.get(str(surface.id) ?? "")?.agent ?? null,
|
|
132
|
+
// The human's terminal: the focused surface of the selected workspace in a visible window.
|
|
133
|
+
focused: surface.focused === true && workspace.selected === true && window.visible !== false,
|
|
134
|
+
})));
|
|
135
|
+
}
|
|
136
|
+
async function locate(sessionId) {
|
|
137
|
+
const surface = (await surfaces()).find((entry) => entry.id === sessionId || entry.ref === sessionId);
|
|
138
|
+
if (!surface)
|
|
139
|
+
throw new Error(`no cmux terminal ${sessionId}`);
|
|
140
|
+
return surface;
|
|
141
|
+
}
|
|
142
|
+
async function read(sessionId, lines) {
|
|
143
|
+
const result = await client.call("surface.read_text", { surface_id: sessionId, lines, scrollback: true });
|
|
144
|
+
return typeof result.text === "string" ? result.text : "";
|
|
145
|
+
}
|
|
146
|
+
return {
|
|
147
|
+
name: "cmux",
|
|
148
|
+
async list() {
|
|
149
|
+
return (await surfaces()).filter((surface) => surface.type === "terminal").map(({ id, ref, workspace, title, agent, focused }) => ({ id, ref, workspace, title, agent, focused }));
|
|
150
|
+
},
|
|
151
|
+
read,
|
|
152
|
+
async prompt(sessionId, line) {
|
|
153
|
+
const surface = await locate(sessionId);
|
|
154
|
+
const target = { workspace_id: surface.workspaceId, surface_id: surface.id };
|
|
155
|
+
// A paste lands as one block, so no character of it is read as a keystroke; Enter follows on its own.
|
|
156
|
+
await client.call("terminal.paste", { ...target, text: line, submit_key: "none" });
|
|
157
|
+
await new Promise((resolve) => schedule(() => resolve(undefined), SUBMIT_DELAY_MS));
|
|
158
|
+
await client.call("surface.send_key", { ...target, key: "enter" });
|
|
159
|
+
(0, runtime_1.emitNervesEvent)({ component: "senses", event: "senses.shepherd_cmux_prompted", message: "typed a line into a cmux terminal", meta: { chars: line.length } });
|
|
160
|
+
},
|
|
161
|
+
async key(sessionId, key) {
|
|
162
|
+
const surface = await locate(sessionId);
|
|
163
|
+
const target = { workspace_id: surface.workspaceId, surface_id: surface.id };
|
|
164
|
+
if (NAMED_KEYS.has(key))
|
|
165
|
+
await client.call("surface.send_key", { ...target, key });
|
|
166
|
+
else
|
|
167
|
+
await client.call("surface.send_text", { ...target, text: key });
|
|
168
|
+
},
|
|
169
|
+
async signal(sessionId, status, notification) {
|
|
170
|
+
const surface = await locate(sessionId);
|
|
171
|
+
const key = statusKey(surface);
|
|
172
|
+
await client.command(status
|
|
173
|
+
? `set_status ${key} ${socketQuote(status)} --tab=${surface.workspaceId} --panel=${surface.id}`
|
|
174
|
+
: `clear_status ${key} --tab=${surface.workspaceId}`);
|
|
175
|
+
if (status)
|
|
176
|
+
statused.set(surface.id, { key, workspaceId: surface.workspaceId });
|
|
177
|
+
else
|
|
178
|
+
statused.delete(surface.id);
|
|
179
|
+
if (notification)
|
|
180
|
+
await client.call("notification.create", { title: notification.title, subtitle: "", body: notification.body, workspace_id: surface.workspaceId });
|
|
181
|
+
},
|
|
182
|
+
pace(sessionId, slower) {
|
|
183
|
+
if (slower)
|
|
184
|
+
quietMs.set(sessionId, Math.min((quietMs.get(sessionId) ?? exports.QUIET_MS) * 2, exports.QUIET_MAX_MS));
|
|
185
|
+
else
|
|
186
|
+
quietMs.delete(sessionId);
|
|
187
|
+
},
|
|
188
|
+
watch(handlers) {
|
|
189
|
+
let closed = false;
|
|
190
|
+
let cursor = null;
|
|
191
|
+
let attempt = 0;
|
|
192
|
+
let stream = null;
|
|
193
|
+
let cancelReconnect = null;
|
|
194
|
+
let cancelPoll = null;
|
|
195
|
+
const pending = new Map();
|
|
196
|
+
const screens = new Map();
|
|
197
|
+
const forget = (surfaceId) => {
|
|
198
|
+
hooked.delete(surfaceId);
|
|
199
|
+
for (const [session, surface] of sessionSurface)
|
|
200
|
+
if (surface === surfaceId)
|
|
201
|
+
sessionSurface.delete(session);
|
|
202
|
+
};
|
|
203
|
+
const cancelPending = (surfaceId) => {
|
|
204
|
+
pending.get(surfaceId)?.();
|
|
205
|
+
pending.delete(surfaceId);
|
|
206
|
+
};
|
|
207
|
+
/** A stop becomes a return only if nothing resumed it within the grace period; background work earns it a longer wait. */
|
|
208
|
+
const stopped = (surfaceId, transitionId, agent, sessionId, cwd, waited = false) => {
|
|
209
|
+
cancelPending(surfaceId);
|
|
210
|
+
pending.set(surfaceId, schedule(() => {
|
|
211
|
+
pending.delete(surfaceId);
|
|
212
|
+
const session = sessionId ? readCmuxHookSession(homeDir, agent, sessionId) : null;
|
|
213
|
+
const backgroundWork = session?.hadPendingBackgroundWorkAtStop === true || session?.agentLifecycle === "backgroundWorkPending" || session?.agentLifecycle === "running";
|
|
214
|
+
if (backgroundWork && !waited) {
|
|
215
|
+
(0, runtime_1.emitNervesEvent)({ component: "senses", event: "senses.shepherd_stop_deferred", message: "a cmux stop left background work running; waiting longer", meta: { agent } });
|
|
216
|
+
return stopped(surfaceId, transitionId, agent, sessionId, cwd, true);
|
|
217
|
+
}
|
|
218
|
+
handlers.returned({ sessionId: surfaceId, transitionId, agent, cwd: str(session?.cwd) ?? cwd, lastBody: str(session?.lastBody), backgroundWork });
|
|
219
|
+
}, waited ? exports.BACKGROUND_GRACE_MS : exports.GRACE_MS));
|
|
220
|
+
};
|
|
221
|
+
const onEvent = (frame) => {
|
|
222
|
+
const name = str(frame.name) ?? "";
|
|
223
|
+
const payload = isRecord(frame.payload) ? frame.payload : {};
|
|
224
|
+
const surfaceId = str(payload.surface_id) ?? str(frame.surface_id);
|
|
225
|
+
if (name === "surface.focused" && surfaceId) {
|
|
226
|
+
quietMs.delete(surfaceId);
|
|
227
|
+
return handlers.focused(surfaceId);
|
|
228
|
+
}
|
|
229
|
+
if (name === "surface.closed" && surfaceId) {
|
|
230
|
+
cancelPending(surfaceId);
|
|
231
|
+
forget(surfaceId);
|
|
232
|
+
screens.delete(surfaceId);
|
|
233
|
+
statused.delete(surfaceId);
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
if (!name.startsWith("agent.hook.") || (payload.phase !== undefined && payload.phase !== "received"))
|
|
237
|
+
return;
|
|
238
|
+
const hook = name.slice("agent.hook.".length);
|
|
239
|
+
const sessionId = str(payload.session_id);
|
|
240
|
+
if (sessionId && surfaceId)
|
|
241
|
+
sessionSurface.set(sessionId, surfaceId);
|
|
242
|
+
const surface = surfaceId ?? (sessionId ? sessionSurface.get(sessionId) : undefined);
|
|
243
|
+
if (!surface)
|
|
244
|
+
return;
|
|
245
|
+
const agent = str(payload._source) ?? str(frame.source) ?? "unknown";
|
|
246
|
+
const cwd = str(payload.cwd);
|
|
247
|
+
hooked.set(surface, { agent, cwd, sessionId });
|
|
248
|
+
if (STOP_HOOKS.has(hook))
|
|
249
|
+
return stopped(surface, `cmux:${String(frame.boot_id)}:${String(frame.seq)}`, agent, sessionId, cwd);
|
|
250
|
+
if (NEUTRAL_HOOKS.has(hook))
|
|
251
|
+
return;
|
|
252
|
+
// The agent is working again: no pending stop, and no stale status.
|
|
253
|
+
cancelPending(surface);
|
|
254
|
+
clearStatus(surface);
|
|
255
|
+
// An agent that exited leaves a plain terminal, which the quiet-screen fallback watches again.
|
|
256
|
+
if (hook === "SessionEnd")
|
|
257
|
+
forget(surface);
|
|
258
|
+
};
|
|
259
|
+
const connect = () => {
|
|
260
|
+
cancelReconnect = null;
|
|
261
|
+
stream = client.stream({ ...(cursor !== null ? { after_seq: cursor } : {}), categories: ["agent", "surface"], include_heartbeats: false }, {
|
|
262
|
+
onFrame: (frame) => {
|
|
263
|
+
if (frame.type === "ack") {
|
|
264
|
+
attempt = 0;
|
|
265
|
+
const resume = isRecord(frame.resume) ? frame.resume : {};
|
|
266
|
+
// A first connection, a restart or a gap starts from cmux's latest event: old stops are not replayed as new returns.
|
|
267
|
+
if (cursor === null || resume.gap === true)
|
|
268
|
+
cursor = typeof resume.latest_seq === "number" ? resume.latest_seq : cursor;
|
|
269
|
+
(0, runtime_1.emitNervesEvent)({ component: "senses", event: "senses.shepherd_cmux_connected", message: "cmux event stream connected", meta: { gap: resume.gap === true } });
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
if (frame.type !== "event" || typeof frame.seq !== "number" || (cursor !== null && frame.seq <= cursor))
|
|
273
|
+
return;
|
|
274
|
+
cursor = frame.seq;
|
|
275
|
+
onEvent(frame);
|
|
276
|
+
},
|
|
277
|
+
onClose: (error) => {
|
|
278
|
+
stream = null;
|
|
279
|
+
if (closed)
|
|
280
|
+
return;
|
|
281
|
+
const delay = RECONNECT_DELAYS_MS[Math.min(attempt, RECONNECT_DELAYS_MS.length - 1)];
|
|
282
|
+
attempt += 1;
|
|
283
|
+
(0, runtime_1.emitNervesEvent)({ level: "warn", component: "senses", event: "senses.shepherd_cmux_closed", message: "cmux event stream closed; reconnecting", meta: { delay, error: error?.message ?? null } });
|
|
284
|
+
cancelReconnect = schedule(connect, delay);
|
|
285
|
+
},
|
|
286
|
+
});
|
|
287
|
+
};
|
|
288
|
+
/** Terminals with no hook: output that changed and then held still for the quiet window is a return. */
|
|
289
|
+
const poll = async () => {
|
|
290
|
+
try {
|
|
291
|
+
const terminals = (await surfaces()).filter((surface) => surface.type === "terminal" && !hooked.has(surface.id));
|
|
292
|
+
const seen = new Set(terminals.map((surface) => surface.id));
|
|
293
|
+
for (const id of screens.keys())
|
|
294
|
+
if (!seen.has(id))
|
|
295
|
+
screens.delete(id);
|
|
296
|
+
for (const surface of terminals) {
|
|
297
|
+
const hash = (0, node_crypto_1.createHash)("sha256").update(await read(surface.id, POLL_LINES)).digest("hex");
|
|
298
|
+
const last = screens.get(surface.id);
|
|
299
|
+
const at = now();
|
|
300
|
+
if (!last)
|
|
301
|
+
screens.set(surface.id, { hash, changedAt: at, active: false });
|
|
302
|
+
else if (last.hash !== hash) {
|
|
303
|
+
screens.set(surface.id, { hash, changedAt: at, active: true });
|
|
304
|
+
clearStatus(surface.id);
|
|
305
|
+
}
|
|
306
|
+
else if (last.active && at - last.changedAt >= (quietMs.get(surface.id) ?? exports.QUIET_MS)) {
|
|
307
|
+
last.active = false;
|
|
308
|
+
handlers.returned({ sessionId: surface.id, transitionId: `cmux:quiet:${surface.id}:${last.changedAt}`, agent: null, cwd: null, lastBody: null, backgroundWork: null });
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
catch (error) {
|
|
313
|
+
(0, runtime_1.emitNervesEvent)({ level: "warn", component: "senses", event: "senses.shepherd_cmux_poll_error", message: "could not poll cmux terminals; the next poll retries", meta: { error: error.message } });
|
|
314
|
+
}
|
|
315
|
+
if (!closed)
|
|
316
|
+
cancelPoll = schedule(() => void poll(), exports.POLL_MS);
|
|
317
|
+
};
|
|
318
|
+
connect();
|
|
319
|
+
void poll();
|
|
320
|
+
return {
|
|
321
|
+
close() {
|
|
322
|
+
closed = true;
|
|
323
|
+
cancelReconnect?.();
|
|
324
|
+
cancelPoll?.();
|
|
325
|
+
for (const cancel of pending.values())
|
|
326
|
+
cancel();
|
|
327
|
+
stream?.close();
|
|
328
|
+
},
|
|
329
|
+
};
|
|
330
|
+
},
|
|
331
|
+
};
|
|
332
|
+
}
|