@inhuman.tools/harness-sdk 0.0.0-stage → 0.2.0

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 (123) hide show
  1. package/LICENSE.md +7 -0
  2. package/dist/client-agents-tree.d.ts +123 -0
  3. package/dist/client-agents-tree.d.ts.map +1 -0
  4. package/dist/client-agents-tree.js +504 -0
  5. package/dist/client-agents-tree.js.map +1 -0
  6. package/dist/client-agents.d.ts +50 -0
  7. package/dist/client-agents.d.ts.map +1 -0
  8. package/dist/client-agents.js +299 -0
  9. package/dist/client-agents.js.map +1 -0
  10. package/dist/client.d.ts +89 -0
  11. package/dist/client.d.ts.map +1 -0
  12. package/dist/client.js +589 -0
  13. package/dist/client.js.map +1 -0
  14. package/dist/index.d.ts +10 -0
  15. package/dist/index.d.ts.map +1 -0
  16. package/dist/index.js +10 -0
  17. package/dist/index.js.map +1 -0
  18. package/dist/launch/continue-pick.d.ts +17 -0
  19. package/dist/launch/continue-pick.d.ts.map +1 -0
  20. package/dist/launch/continue-pick.js +20 -0
  21. package/dist/launch/continue-pick.js.map +1 -0
  22. package/dist/launch/index.d.ts +6 -0
  23. package/dist/launch/index.d.ts.map +1 -0
  24. package/dist/launch/index.js +6 -0
  25. package/dist/launch/index.js.map +1 -0
  26. package/dist/launch/model-flags.d.ts +44 -0
  27. package/dist/launch/model-flags.d.ts.map +1 -0
  28. package/dist/launch/model-flags.js +216 -0
  29. package/dist/launch/model-flags.js.map +1 -0
  30. package/dist/launch/streams.d.ts +15 -0
  31. package/dist/launch/streams.d.ts.map +1 -0
  32. package/dist/launch/streams.js +2 -0
  33. package/dist/launch/streams.js.map +1 -0
  34. package/dist/launch/term-continue.d.ts +13 -0
  35. package/dist/launch/term-continue.d.ts.map +1 -0
  36. package/dist/launch/term-continue.js +17 -0
  37. package/dist/launch/term-continue.js.map +1 -0
  38. package/dist/launch/term-session-key.d.ts +8 -0
  39. package/dist/launch/term-session-key.d.ts.map +1 -0
  40. package/dist/launch/term-session-key.js +76 -0
  41. package/dist/launch/term-session-key.js.map +1 -0
  42. package/dist/observable-abort-signal.d.ts +7 -0
  43. package/dist/observable-abort-signal.d.ts.map +1 -0
  44. package/dist/observable-abort-signal.js +56 -0
  45. package/dist/observable-abort-signal.js.map +1 -0
  46. package/dist/open-external.d.ts +2 -0
  47. package/dist/open-external.d.ts.map +1 -0
  48. package/dist/open-external.js +24 -0
  49. package/dist/open-external.js.map +1 -0
  50. package/dist/paths.d.ts +7 -0
  51. package/dist/paths.d.ts.map +1 -0
  52. package/dist/paths.js +16 -0
  53. package/dist/paths.js.map +1 -0
  54. package/dist/text/agent-cover.d.ts +43 -0
  55. package/dist/text/agent-cover.d.ts.map +1 -0
  56. package/dist/text/agent-cover.js +123 -0
  57. package/dist/text/agent-cover.js.map +1 -0
  58. package/dist/text/format-shell.d.ts +5 -0
  59. package/dist/text/format-shell.d.ts.map +1 -0
  60. package/dist/text/format-shell.js +53 -0
  61. package/dist/text/format-shell.js.map +1 -0
  62. package/dist/text/identity.d.ts +67 -0
  63. package/dist/text/identity.d.ts.map +1 -0
  64. package/dist/text/identity.js +433 -0
  65. package/dist/text/identity.js.map +1 -0
  66. package/dist/text/index.d.ts +14 -0
  67. package/dist/text/index.d.ts.map +1 -0
  68. package/dist/text/index.js +15 -0
  69. package/dist/text/index.js.map +1 -0
  70. package/dist/text/liveness.d.ts +62 -0
  71. package/dist/text/liveness.d.ts.map +1 -0
  72. package/dist/text/liveness.js +270 -0
  73. package/dist/text/liveness.js.map +1 -0
  74. package/dist/text/mcp-identity.d.ts +25 -0
  75. package/dist/text/mcp-identity.d.ts.map +1 -0
  76. package/dist/text/mcp-identity.js +133 -0
  77. package/dist/text/mcp-identity.js.map +1 -0
  78. package/dist/text/pretty-identity.d.ts +15 -0
  79. package/dist/text/pretty-identity.d.ts.map +1 -0
  80. package/dist/text/pretty-identity.js +112 -0
  81. package/dist/text/pretty-identity.js.map +1 -0
  82. package/dist/text/sugarcoat.d.ts +29 -0
  83. package/dist/text/sugarcoat.d.ts.map +1 -0
  84. package/dist/text/sugarcoat.js +148 -0
  85. package/dist/text/sugarcoat.js.map +1 -0
  86. package/dist/text/unwrap-program.d.ts +2 -0
  87. package/dist/text/unwrap-program.d.ts.map +1 -0
  88. package/dist/text/unwrap-program.js +137 -0
  89. package/dist/text/unwrap-program.js.map +1 -0
  90. package/dist/text/work-text.d.ts +72 -0
  91. package/dist/text/work-text.d.ts.map +1 -0
  92. package/dist/text/work-text.js +337 -0
  93. package/dist/text/work-text.js.map +1 -0
  94. package/dist/transport/unix.d.ts +19 -0
  95. package/dist/transport/unix.d.ts.map +1 -0
  96. package/dist/transport/unix.js +68 -0
  97. package/dist/transport/unix.js.map +1 -0
  98. package/package.json +79 -3
  99. package/src/client-agents-tree.ts +642 -0
  100. package/src/client-agents.ts +230 -0
  101. package/src/client.ts +686 -0
  102. package/src/index.ts +27 -0
  103. package/src/launch/continue-pick.ts +28 -0
  104. package/src/launch/index.ts +5 -0
  105. package/src/launch/model-flags.ts +273 -0
  106. package/src/launch/streams.ts +13 -0
  107. package/src/launch/term-continue.ts +31 -0
  108. package/src/launch/term-session-key.ts +70 -0
  109. package/src/observable-abort-signal.ts +66 -0
  110. package/src/open-external.ts +22 -0
  111. package/src/paths.ts +20 -0
  112. package/src/text/agent-cover.ts +136 -0
  113. package/src/text/format-shell.ts +52 -0
  114. package/src/text/identity.ts +473 -0
  115. package/src/text/index.ts +29 -0
  116. package/src/text/liveness.ts +322 -0
  117. package/src/text/mcp-identity.ts +148 -0
  118. package/src/text/pretty-identity.ts +120 -0
  119. package/src/text/sugarcoat.ts +165 -0
  120. package/src/text/unwrap-program.ts +133 -0
  121. package/src/text/work-text.ts +388 -0
  122. package/src/transport/unix.ts +84 -0
  123. package/README.md +0 -3
@@ -0,0 +1,28 @@
1
+ /**
2
+ * `--continue` attach target. Glance has no path — a global orphan would
3
+ * be cwd-wrong, so no mapping still mints.
4
+ */
5
+ import { unseatedConversationIds, type UnseatedConversationOverview } from "@inhuman.tools/harness-model";
6
+
7
+ export type ContinuePick = {
8
+ readonly sessionId: string | undefined;
9
+ readonly staleKeyToClear: string | null;
10
+ };
11
+
12
+ export async function pickContinueSession(args: {
13
+ readonly key: string | null;
14
+ readonly termContinue: Record<string, string>;
15
+ readonly sessions: Record<string, UnseatedConversationOverview | undefined>;
16
+ readonly occupied: ReadonlySet<string>;
17
+ readonly sessionExists: (id: string) => Promise<boolean>;
18
+ }): Promise<ContinuePick> {
19
+ const mapped = args.key == null ? undefined : args.termContinue[args.key];
20
+ if (mapped == null || mapped === "") return { sessionId: undefined, staleKeyToClear: null };
21
+ const exists = await args.sessionExists(mapped);
22
+ if (exists && !args.occupied.has(mapped)) return { sessionId: mapped, staleKeyToClear: null };
23
+ const staleKeyToClear = !exists && args.key != null ? args.key : null;
24
+ for (const id of unseatedConversationIds(args.sessions, args.occupied)) {
25
+ if (await args.sessionExists(id)) return { sessionId: id, staleKeyToClear };
26
+ }
27
+ return { sessionId: undefined, staleKeyToClear };
28
+ }
@@ -0,0 +1,5 @@
1
+ export * from "./continue-pick.js";
2
+ export * from "./model-flags.js";
3
+ export * from "./streams.js";
4
+ export * from "./term-continue.js";
5
+ export * from "./term-session-key.js";
@@ -0,0 +1,273 @@
1
+ /**
2
+ * `--connection` / `--model` resolve against live completion catalogs.
3
+ * Session apply is the same kv/set `/model` writes. Unsupported verbs crash
4
+ * before the daemon work they would otherwise start.
5
+ */
6
+ import type { KernelRpc } from "../client.js";
7
+
8
+ /** Same ceiling as scheme_repl inventory enumerate — do not dump OpenRouter. */
9
+ const MODEL_LIST_MAX = 12;
10
+
11
+ export type CliAgentFlags = {
12
+ readonly connection?: string;
13
+ readonly model?: string;
14
+ };
15
+
16
+ export type ResolvedAgent = {
17
+ readonly connection: string;
18
+ readonly model?: string;
19
+ };
20
+
21
+ export type ConnectionRow = {
22
+ readonly id: string;
23
+ readonly name: string;
24
+ readonly driver: string;
25
+ readonly abilityClaim: readonly string[];
26
+ readonly enabled: boolean;
27
+ };
28
+
29
+ export type CompletionCatalogRow = {
30
+ readonly id: string;
31
+ readonly models: readonly string[];
32
+ readonly error: string;
33
+ };
34
+
35
+ export type ResolveAgentInput = {
36
+ readonly flags: CliAgentFlags;
37
+ readonly connections: readonly ConnectionRow[];
38
+ readonly catalogs?: readonly CompletionCatalogRow[];
39
+ };
40
+
41
+ export type ResolveAgentResult =
42
+ { readonly ok: true; readonly agent: ResolvedAgent } | { readonly ok: false; readonly error: string };
43
+
44
+ export function hasCliAgentFlags(flags: CliAgentFlags): boolean {
45
+ return nonempty(flags.connection) != null || nonempty(flags.model) != null;
46
+ }
47
+
48
+ export function unsupportedAgentFlagsError(surface: string): string {
49
+ return [
50
+ `--model and --connection are only valid for a session (TUI, --continue, eval, -p, or a .scm program).`,
51
+ `\`${surface}\` does not select a model.`,
52
+ ].join("\n");
53
+ }
54
+
55
+ export function resolveAgent(input: ResolveAgentInput): ResolveAgentResult {
56
+ const wantedConnection = nonempty(input.flags.connection);
57
+ const wantedModel = nonempty(input.flags.model);
58
+ if (wantedConnection == null && wantedModel == null) {
59
+ return { ok: true, agent: { connection: "" } };
60
+ }
61
+
62
+ const completion = input.connections.filter((row) => row.abilityClaim.includes("completion"));
63
+ const enabledCompletion = completion.filter((row) => row.enabled);
64
+ if (wantedConnection != null) {
65
+ const row = input.connections.find((item) => item.id === wantedConnection);
66
+ if (row == null) {
67
+ return { ok: false, error: unknownConnectionError(wantedConnection, input.connections) };
68
+ }
69
+ if (!row.abilityClaim.includes("completion")) {
70
+ return {
71
+ ok: false,
72
+ error: [
73
+ `connection ${quote(wantedConnection)} is not a completion connection (${row.driver}).`,
74
+ completionLine(enabledCompletion),
75
+ ].join("\n"),
76
+ };
77
+ }
78
+ if (!row.enabled) {
79
+ return { ok: false, error: `connection ${quote(wantedConnection)} is disabled.` };
80
+ }
81
+ }
82
+
83
+ if (wantedModel == null) {
84
+ return { ok: true, agent: { connection: wantedConnection! } };
85
+ }
86
+ if (input.catalogs == null) {
87
+ return {
88
+ ok: true,
89
+ agent:
90
+ wantedConnection == null
91
+ ? { connection: "", model: wantedModel }
92
+ : { connection: wantedConnection, model: wantedModel },
93
+ };
94
+ }
95
+
96
+ const catalogs = input.catalogs;
97
+ if (wantedConnection != null) {
98
+ const catalog = catalogs.find((row) => row.id === wantedConnection);
99
+ if (catalog == null) {
100
+ return {
101
+ ok: false,
102
+ error: `connection ${quote(wantedConnection)} did not return a model catalog.`,
103
+ };
104
+ }
105
+ if (!catalog.models.includes(wantedModel)) {
106
+ return { ok: false, error: missingOnConnectionError(wantedConnection, wantedModel, catalog) };
107
+ }
108
+ return { ok: true, agent: { connection: wantedConnection, model: wantedModel } };
109
+ }
110
+
111
+ const hits = catalogs.filter((row) => row.models.includes(wantedModel));
112
+ if (hits.length === 1) {
113
+ return { ok: true, agent: { connection: hits[0]!.id, model: wantedModel } };
114
+ }
115
+ if (hits.length > 1) {
116
+ const names = hits.map((row) => row.id).sort();
117
+ return {
118
+ ok: false,
119
+ error: [
120
+ `model ${quote(wantedModel)} is available on multiple connections: ${names.join(", ")}.`,
121
+ `Pass --connection to pick one.`,
122
+ ].join("\n"),
123
+ };
124
+ }
125
+ return { ok: false, error: missingAcrossError(wantedModel, catalogs, enabledCompletion) };
126
+ }
127
+
128
+ export async function resolveCliAgent(rpc: KernelRpc, flags: CliAgentFlags): Promise<ResolveAgentResult> {
129
+ if (!hasCliAgentFlags(flags)) return { ok: true, agent: { connection: "" } };
130
+ const listed = await rpc.request("connect/list", {});
131
+ const connections = connectionRowsOf(listed);
132
+ const preview = resolveAgent({ flags, connections });
133
+ if (!preview.ok) return preview;
134
+ if (nonempty(flags.model) == null) return preview;
135
+ const wantedConnection = nonempty(flags.connection);
136
+ const catalogs = catalogRowsOf(
137
+ await rpc.request("connect/models", wantedConnection == null ? {} : { id: wantedConnection }),
138
+ );
139
+ return resolveAgent({ flags, connections, catalogs });
140
+ }
141
+
142
+ export async function applyResolvedAgent(
143
+ request: (method: string, params: unknown) => Promise<unknown>,
144
+ agent: ResolvedAgent,
145
+ ): Promise<void> {
146
+ const connection = nonempty(agent.connection);
147
+ const model = nonempty(agent.model);
148
+ if (connection == null && model == null) return;
149
+ if (connection != null) {
150
+ await request("kv/set", { key: "connection", value: connection });
151
+ }
152
+ if (model != null) {
153
+ await request("kv/set", { key: "model", value: model });
154
+ }
155
+ }
156
+
157
+ function connectionRowsOf(payload: unknown): ConnectionRow[] {
158
+ if (payload == null || typeof payload !== "object" || !("connections" in payload)) return [];
159
+ const raw = (payload as { connections: unknown }).connections;
160
+ if (!Array.isArray(raw)) return [];
161
+ return raw.flatMap((row) => {
162
+ if (row == null || typeof row !== "object") return [];
163
+ const rec = row as Record<string, unknown>;
164
+ const id = typeof rec.id === "string" ? rec.id : "";
165
+ if (id === "") return [];
166
+ const can = Array.isArray(rec.abilityClaim) ? rec.abilityClaim.map(String) : [];
167
+ return [
168
+ {
169
+ id,
170
+ name: typeof rec.name === "string" ? rec.name : id,
171
+ driver: typeof rec.driver === "string" ? rec.driver : "",
172
+ abilityClaim: can,
173
+ enabled: rec.enabled !== false,
174
+ },
175
+ ];
176
+ });
177
+ }
178
+
179
+ function catalogRowsOf(payload: unknown): CompletionCatalogRow[] {
180
+ if (payload == null || typeof payload !== "object" || !("connections" in payload)) return [];
181
+ const raw = (payload as { connections: unknown }).connections;
182
+ if (!Array.isArray(raw)) return [];
183
+ return raw.flatMap((row) => {
184
+ if (row == null || typeof row !== "object") return [];
185
+ const rec = row as Record<string, unknown>;
186
+ const id = typeof rec.id === "string" ? rec.id : "";
187
+ if (id === "") return [];
188
+ return [
189
+ {
190
+ id,
191
+ models: Array.isArray(rec.models) ? rec.models.map(String) : [],
192
+ error: typeof rec.error === "string" ? rec.error : "",
193
+ },
194
+ ];
195
+ });
196
+ }
197
+
198
+ function unknownConnectionError(id: string, connections: readonly ConnectionRow[]): string {
199
+ const completion = connections.filter((row) => row.abilityClaim.includes("completion") && row.enabled);
200
+ const mcp = connections.filter(
201
+ (row) => row.abilityClaim.includes("mcp") && !row.abilityClaim.includes("completion") && row.enabled,
202
+ );
203
+ const lines = [`unknown connection ${quote(id)}.`];
204
+ if (connections.length === 0) {
205
+ lines.push("No connections are configured.");
206
+ return lines.join("\n");
207
+ }
208
+ lines.push(completionLine(completion));
209
+ if (mcp.length > 0) {
210
+ lines.push(`MCP connections: ${ids(mcp)}.`);
211
+ }
212
+ return lines.join("\n");
213
+ }
214
+
215
+ function missingOnConnectionError(connection: string, model: string, catalog: CompletionCatalogRow): string {
216
+ const lines = [`model ${quote(model)} is not available on connection ${quote(connection)}.`];
217
+ if (catalog.models.length === 0) {
218
+ const status = catalog.error.trim() === "" ? "no models loaded" : catalog.error.trim();
219
+ lines.push(`Connection ${quote(connection)} has no models (${status}).`);
220
+ return lines.join("\n");
221
+ }
222
+ lines.push(`Models on ${connection}: ${formatModels(catalog.models)}.`);
223
+ return lines.join("\n");
224
+ }
225
+
226
+ function missingAcrossError(
227
+ model: string,
228
+ catalogs: readonly CompletionCatalogRow[],
229
+ completion: readonly ConnectionRow[],
230
+ ): string {
231
+ const lines = [`model ${quote(model)} was not found on any completion connection.`];
232
+ if (catalogs.length === 0) {
233
+ lines.push(completionLine(completion));
234
+ return lines.join("\n");
235
+ }
236
+ const searched = catalogs.map((row) => {
237
+ const models = row.models.length === 0 ? emptyCatalogNote(row) : formatModels(row.models);
238
+ return `${row.id} (${models})`;
239
+ });
240
+ lines.push(`Searched: ${searched.join("; ")}.`);
241
+ return lines.join("\n");
242
+ }
243
+
244
+ function emptyCatalogNote(row: CompletionCatalogRow): string {
245
+ return row.error.trim() === "" ? "no models loaded" : row.error.trim();
246
+ }
247
+
248
+ function completionLine(rows: readonly ConnectionRow[]): string {
249
+ if (rows.length === 0) return "No completion connections are configured.";
250
+ return `Completion connections: ${ids(rows)}.`;
251
+ }
252
+
253
+ function formatModels(models: readonly string[]): string {
254
+ if (models.length <= MODEL_LIST_MAX) return models.join(", ");
255
+ const shown = models.slice(0, MODEL_LIST_MAX);
256
+ return `${shown.join(", ")}, and ${models.length - shown.length} more`;
257
+ }
258
+
259
+ function ids(rows: readonly ConnectionRow[]): string {
260
+ return [...rows]
261
+ .map((row) => row.id)
262
+ .sort()
263
+ .join(", ");
264
+ }
265
+
266
+ function nonempty(value: string | undefined): string | undefined {
267
+ const trimmed = value?.trim() ?? "";
268
+ return trimmed === "" ? undefined : trimmed;
269
+ }
270
+
271
+ function quote(value: string): string {
272
+ return JSON.stringify(value);
273
+ }
@@ -0,0 +1,13 @@
1
+ /**
2
+ * What a shell takes from its host: the process streams, plus the working
3
+ * directory when the caller is not the process (tests, embedders).
4
+ */
5
+ export type Streams = {
6
+ readonly stdin: NodeJS.ReadStream;
7
+ readonly stdout: NodeJS.WriteStream;
8
+ readonly stderr: NodeJS.WriteStream;
9
+ readonly cwd?: string;
10
+ };
11
+
12
+ /** `Streams` plus the one-line error sink a shell reports through. Carries no command-parser types. */
13
+ export type ShellStreams = Streams & { readonly writeErr: (error: unknown) => void };
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Tab→session cookie. `scope !== ""` is not a continue target (workers).
3
+ */
4
+ import type { SessionRoot } from "@inhuman.tools/harness-model";
5
+
6
+ import { termSessionKey, type EnvBag } from "./term-session-key.js";
7
+
8
+ export type KernelRequest = (method: string, params?: unknown) => Promise<unknown>;
9
+
10
+ export function termContinuePayload(
11
+ env: EnvBag,
12
+ session: Pick<SessionRoot, "sessionId" | "agent">,
13
+ ): { key: string; sessionId: string } | null {
14
+ const key = termSessionKey(env);
15
+ if (key == null || session.agent.spec.recipeScope !== "") return null;
16
+ return { key, sessionId: session.sessionId };
17
+ }
18
+
19
+ export async function stampTermContinue(
20
+ request: KernelRequest,
21
+ env: EnvBag,
22
+ session: Pick<SessionRoot, "sessionId" | "agent">,
23
+ ): Promise<void> {
24
+ const payload = termContinuePayload(env, session);
25
+ if (payload == null) return;
26
+ await request("term-continue/set", payload);
27
+ }
28
+
29
+ export async function clearTermContinue(request: KernelRequest, key: string): Promise<void> {
30
+ await request("term-continue/clear", { key });
31
+ }
@@ -0,0 +1,70 @@
1
+ /**
2
+ * Tab/pane identity for `--continue`. Null if the process has no emulator
3
+ * cookie and no mux pane — miss is mint. Outer TERM_SESSION_ID is not
4
+ * identity under tmux: mix every live mux token.
5
+ */
6
+
7
+ export type EnvBag = Record<string, string | undefined>;
8
+
9
+ export function termSessionKey(env: EnvBag): string | null {
10
+ const parts: string[] = [];
11
+ const emu = emulatorCookie(env);
12
+ if (emu != null) parts.push(`emu=${emu}`);
13
+
14
+ const tmux = tmuxFields(env);
15
+ if (tmux != null) {
16
+ parts.push(`tmux_sock=${tmux.socket}`, `tmux_pid=${tmux.pid}`, `tmux_pane=${tmux.pane}`);
17
+ }
18
+ if (env.ZELLIJ_PANE_ID != null && env.ZELLIJ_PANE_ID !== "") {
19
+ parts.push(`zellij_pane=${env.ZELLIJ_PANE_ID}`);
20
+ if (env.ZELLIJ_SESSION_NAME) parts.push(`zellij_session=${env.ZELLIJ_SESSION_NAME}`);
21
+ if (env.ZELLIJ_SOCKET_DIR) parts.push(`zellij_sock=${env.ZELLIJ_SOCKET_DIR}`);
22
+ }
23
+ if (env.STY != null && env.STY !== "") {
24
+ parts.push(`screen=${env.STY}`, `screen_win=${env.WINDOW === undefined || env.WINDOW === "" ? "0" : env.WINDOW}`);
25
+ }
26
+
27
+ return parts.length === 0 ? null : parts.join(";");
28
+ }
29
+
30
+ function emulatorCookie(env: EnvBag): string | null {
31
+ const term = trim(env.TERM_SESSION_ID);
32
+ if (term != null) {
33
+ const colon = term.lastIndexOf(":");
34
+ return colon === -1 ? term : term.slice(colon + 1);
35
+ }
36
+ const wt = trim(env.WT_SESSION);
37
+ if (wt != null) return wt;
38
+ const wezPane = trim(env.WEZTERM_PANE);
39
+ const wezSock = trim(env.WEZTERM_UNIX_SOCKET);
40
+ if (wezPane != null && wezSock != null) return `wez:${wezSock}:${wezPane}`;
41
+ const kittyWin = trim(env.KITTY_WINDOW_ID);
42
+ const kittyPid = trim(env.KITTY_PID);
43
+ if (kittyWin != null && kittyPid != null) return `kitty:${kittyPid}:${kittyWin}`;
44
+ const konsole = trim(env.SHELL_SESSION_ID);
45
+ if (konsole != null) return konsole;
46
+ const gnome = trim(env.GNOME_TERMINAL_SCREEN);
47
+ if (gnome != null) return gnome;
48
+ return null;
49
+ }
50
+
51
+ function tmuxFields(env: EnvBag): { socket: string; pid: string; pane: string } | null {
52
+ const raw = trim(env.TMUX);
53
+ const pane = trim(env.TMUX_PANE);
54
+ if (raw == null || pane == null) return null;
55
+ const last = raw.lastIndexOf(",");
56
+ if (last <= 0) return null;
57
+ const rest = raw.slice(0, last);
58
+ const mid = rest.lastIndexOf(",");
59
+ if (mid <= 0) return null;
60
+ const socket = rest.slice(0, mid);
61
+ const pid = rest.slice(mid + 1);
62
+ if (socket === "" || pid === "") return null;
63
+ return { socket, pid, pane };
64
+ }
65
+
66
+ function trim(value: string | undefined): string | null {
67
+ if (value == null) return null;
68
+ const next = value.trim();
69
+ return next === "" ? null : next;
70
+ }
@@ -0,0 +1,66 @@
1
+ /**
2
+ * AbortSignal whose `aborted` and `reason` reads track in MobX.
3
+ * Native AbortSignal is an illegal constructor; instances come from
4
+ * AbortController / AbortSignal.abort|timeout|any, then {@link from}.
5
+ */
6
+ import { action, createAtom } from "mobx";
7
+
8
+ const observed = new WeakSet<AbortSignal>();
9
+
10
+ const abortedDesc = Object.getOwnPropertyDescriptor(AbortSignal.prototype, "aborted");
11
+ const reasonDesc = Object.getOwnPropertyDescriptor(AbortSignal.prototype, "reason");
12
+
13
+ function observe(signal: AbortSignal): void {
14
+ if (abortedDesc?.get == null || reasonDesc?.get == null) {
15
+ throw new Error("AbortSignal.aborted/reason are not getters");
16
+ }
17
+ const atom = createAtom("AbortSignal");
18
+ const abortedGet = abortedDesc.get;
19
+ const reasonGet = reasonDesc.get;
20
+ Object.defineProperty(signal, "aborted", {
21
+ configurable: true,
22
+ enumerable: true,
23
+ get(): boolean {
24
+ atom.reportObserved();
25
+ return abortedGet.call(signal);
26
+ },
27
+ });
28
+ Object.defineProperty(signal, "reason", {
29
+ configurable: true,
30
+ enumerable: false,
31
+ get(): unknown {
32
+ atom.reportObserved();
33
+ return reasonGet.call(signal);
34
+ },
35
+ });
36
+ if (!abortedGet.call(signal)) {
37
+ signal.addEventListener(
38
+ "abort",
39
+ action("abort", () => atom.reportChanged()),
40
+ { once: true },
41
+ );
42
+ }
43
+ }
44
+
45
+ export class ObservableAbortSignal extends AbortSignal {
46
+ static from(signal: AbortSignal): ObservableAbortSignal {
47
+ if (!observed.has(signal)) {
48
+ observe(signal);
49
+ observed.add(signal);
50
+ }
51
+ Object.setPrototypeOf(signal, ObservableAbortSignal.prototype);
52
+ return signal as ObservableAbortSignal;
53
+ }
54
+
55
+ static override abort(reason?: unknown): ObservableAbortSignal {
56
+ return ObservableAbortSignal.from(AbortSignal.abort(reason));
57
+ }
58
+
59
+ static override timeout(milliseconds: number): ObservableAbortSignal {
60
+ return ObservableAbortSignal.from(AbortSignal.timeout(milliseconds));
61
+ }
62
+
63
+ static override any(signals: AbortSignal[]): ObservableAbortSignal {
64
+ return ObservableAbortSignal.from(AbortSignal.any(signals));
65
+ }
66
+ }
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Fire-and-forget URL open. No display (SSH / container) is fine.
3
+ * WIN_START: cmd.exe `start "" url` — without the empty title the quoted
4
+ * URL becomes the window title and navigation is swallowed.
5
+ */
6
+ import { spawn } from "node:child_process";
7
+
8
+ export function openExternalUrl(url: string): void {
9
+ if (!/^https?:\/\//i.test(url.trim())) return;
10
+ try {
11
+ const target = url.trim();
12
+ if (process.platform === "darwin") {
13
+ spawn("open", [target], { detached: true, stdio: "ignore" }).unref();
14
+ return;
15
+ }
16
+ if (process.platform === "win32") {
17
+ spawn("cmd", ["/c", "start", "", target], { detached: true, stdio: "ignore" }).unref();
18
+ return;
19
+ }
20
+ spawn("xdg-open", [target], { detached: true, stdio: "ignore" }).unref();
21
+ } catch {}
22
+ }
package/src/paths.ts ADDED
@@ -0,0 +1,20 @@
1
+ import { join } from "node:path";
2
+
3
+ export const HARNESS_DIR = ".harness";
4
+
5
+ export function daemonSocketPath(systemRoot: string): string {
6
+ return join(systemRoot, "daemon.sock");
7
+ }
8
+
9
+ /** One append-only log for host, worker, and daemon. */
10
+ export function daemonLogPath(systemRoot: string): string {
11
+ return join(systemRoot, "harness.log");
12
+ }
13
+
14
+ export function projectHarnessDir(workspacePath: string): string {
15
+ return join(workspacePath, HARNESS_DIR);
16
+ }
17
+
18
+ export function projectSessionsDir(workspacePath: string): string {
19
+ return join(projectHarnessDir(workspacePath), "sessions");
20
+ }
@@ -0,0 +1,136 @@
1
+ /**
2
+ * Ask-cover copy for agent send/ask/message. Rim is dim verb + bright
3
+ * `{model} {Spec} agent`. Prompt (and spec one-liner) ride the composer
4
+ * stack — same T-join as MCP options / shell command. Not a pin.
5
+ *
6
+ * Stdlib one-liners match the named-agent bot table
7
+ * (`packages/harness/src/server/system-plugins/named-agents/agent-stdlib.ts`). User recipes
8
+ * in `KernelRoot.bots` win on the same name.
9
+ */
10
+ import { AgentCallJob, CallJob, AgentMessageJob, BotCallJob } from "@inhuman.tools/harness-model";
11
+
12
+ /** Wire name. Ask stamps `voidLegal` false; send and bot-call stay send. */
13
+ const AGENT_ASK_NAME = "agent__ask";
14
+
15
+ /** CHANGE ONLY with packages/harness/src/server/system-plugins/named-agents/agent-stdlib.ts one-liners. */
16
+ export const STDLIB_AGENT_DESCRIPTIONS: Readonly<Record<string, string>> = {
17
+ "quick-search": "cite matching paths; agent/ask for map",
18
+ explore: "thorough read-only repo investigation",
19
+ "web-research": "search and extract the public web; cite sources",
20
+ implementer: "isolated change from a brief; agent/ask for no history",
21
+ "test-writer": "write and run tests for a named surface",
22
+ "security-auditor": "find exploitable issues; do not patch",
23
+ "design-writer": "write a design doc from a brief",
24
+ "design-reviewer": "stress-test a design; do not rewrite it",
25
+ reviewer: "persistent reviewer for this session; later requests continue the same conversation",
26
+ };
27
+
28
+ export type AgentCoverVerb = "ask" | "send" | "message";
29
+
30
+ export type CoverAskContext = {
31
+ /** Session `kvs.model` — the worker inherits this at mint. */
32
+ readonly model?: string;
33
+ };
34
+
35
+ export function agentCoverVerb(work: CallJob | AgentMessageJob): AgentCoverVerb {
36
+ if (work instanceof AgentMessageJob) return "message";
37
+ return !work.voidLegal || work.name === AGENT_ASK_NAME || work.name === "bot__ask" ? "ask" : "send";
38
+ }
39
+
40
+ export function agentSpecKey(work: object | null | undefined): string {
41
+ if (work instanceof CallJob) {
42
+ const spec = stringArg(work.args, "spec")?.trim() ?? "";
43
+ if (spec !== "") return spec;
44
+ const bot = work instanceof BotCallJob ? work.botName.trim() || work.contributionBotName.trim() : "";
45
+ if (bot !== "") return bot;
46
+ const face = work instanceof AgentCallJob ? (work.faceName ?? "").trim() : "";
47
+ if (face !== "" && face !== "anonymous") return face;
48
+ return "";
49
+ }
50
+ if (work instanceof AgentMessageJob) {
51
+ return stringArg(work.args, "targetName")?.trim() ?? "";
52
+ }
53
+ return "";
54
+ }
55
+
56
+ /** `quick-search` → `Quick Search`. Sentinel `agent` is anonymous, not a spec. */
57
+ export function titleAgentSpec(spec: string): string {
58
+ const raw = (typeof spec === "string" ? spec : "").trim();
59
+ if (raw === "" || raw === "agent") return "";
60
+ return raw
61
+ .split(/[-_]+/)
62
+ .filter((part) => part !== "")
63
+ .map((part) => (part.length <= 3 ? part.toUpperCase() : part.charAt(0).toUpperCase() + part.slice(1).toLowerCase()))
64
+ .join(" ");
65
+ }
66
+
67
+ export function formatAskAgentWho(spec: string): string {
68
+ const titled = titleAgentSpec(spec);
69
+ return titled === "" ? "agent" : `${titled} agent`;
70
+ }
71
+
72
+ /**
73
+ * Occupancy pin list. Oxford comma comes from `Intl.ListFormat` (`en-US`).
74
+ * Noun is English v1 — one `agent` / many `agents` on the whole list.
75
+ */
76
+ export function formatWaitingForAgents(specs: readonly string[], locale = "en-US"): string {
77
+ const names = [...specs].map((spec) => titleAgentSpec(spec)).filter((name) => name !== "");
78
+ if (names.length === 0) {
79
+ if (specs.length === 0) return "";
80
+ return specs.length === 1 ? "waiting for an agent" : "waiting for agents";
81
+ }
82
+ const who = new Intl.ListFormat(locale, { style: "long", type: "conjunction" }).format(names);
83
+ const noun = names.length === 1 ? "agent" : "agents";
84
+ return `waiting for ${who} ${noun}`;
85
+ }
86
+
87
+ /**
88
+ * Isolated-cover rim. Markdown: furniture verb + `**bright**` who.
89
+ * Message does not spawn — no model. Empty spec is still `… agent`.
90
+ */
91
+ export function formatAgentCoverAnnotation(opts: {
92
+ readonly verb: AgentCoverVerb;
93
+ readonly model: string;
94
+ readonly spec: string;
95
+ }): string {
96
+ const model = opts.verb === "message" ? "" : opts.model.trim();
97
+ const spec = titleAgentSpec(opts.spec);
98
+ const who = [model, spec, "agent"].filter((part) => part !== "").join(" ");
99
+ return `${opts.verb} **${who}**`;
100
+ }
101
+
102
+ export function agentCoverPrompt(work: CallJob | AgentMessageJob): string {
103
+ return stringArg(work.args, "message") ?? "";
104
+ }
105
+
106
+ export function agentAskStackedBody(prompt: string, description: string): string {
107
+ const desc = description.trim();
108
+ if (desc === "") return prompt;
109
+ if (prompt === "") return desc;
110
+ return `${desc}\n\n${prompt}`;
111
+ }
112
+
113
+ export function agentExpandBlock(prompt: string, model: string): string {
114
+ const body = prompt.trim();
115
+ const who = model.trim();
116
+ if (who === "") return body;
117
+ if (body === "") return who;
118
+ return `${body}\n${who}`;
119
+ }
120
+
121
+ export function agentRecipeDescription(
122
+ bots: Readonly<Record<string, { readonly description?: string }>>,
123
+ spec: string,
124
+ ): string {
125
+ const key = spec.trim();
126
+ if (key === "") return "";
127
+ const fromFile = (bots[key]?.description ?? "").trim();
128
+ if (fromFile !== "") return fromFile;
129
+ return STDLIB_AGENT_DESCRIPTIONS[key] ?? "";
130
+ }
131
+
132
+ function stringArg(args: Readonly<Record<string, unknown>> | null, key: string): string | undefined {
133
+ if (args === null) return undefined;
134
+ const value = args[key];
135
+ return typeof value === "string" && value !== "" ? value : undefined;
136
+ }