@monster-orange/den 0.4.12 → 0.4.14
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.md +151 -0
- package/README.md +7 -7
- package/docs/distributions.md +189 -11
- package/package.json +1 -1
- package/src/shared/agents/conversations.ts +227 -0
- package/src/shared/agents/default-pack/be-dev.md +1 -0
- package/src/shared/agents/default-pack/devops.md +1 -0
- package/src/shared/agents/default-pack/fe-dev.md +1 -0
- package/src/shared/agents/default-pack/orchestrator.md +3 -1
- package/src/shared/agents/default-pack/planner.md +1 -0
- package/src/shared/agents/default-pack/reviewer.md +1 -0
- package/src/shared/agents/default-pack/tester.md +16 -0
- package/src/shared/agents/default-pack/ux-designer.md +1 -0
- package/src/shared/agents/den-claude.ts +128 -13
- package/src/shared/agents/permissions.ts +108 -0
- package/src/shared/agents/persona-store.ts +286 -0
- package/src/shared/agents/personas.ts +116 -12
- package/src/shared/agents/records.ts +33 -0
- package/src/shared/agents/roles.ts +288 -0
- package/src/shared/agents/runtime.ts +50 -0
- package/src/shared/den-config.ts +22 -1
- package/src/shared/environment-manager/service-runner.ts +31 -12
- package/src/shared/environment-manager/workspaces.ts +11 -0
- package/src/ui/cli/agent-ops.ts +796 -29
- package/src/ui/cli/agents-command.ts +97 -3
- package/src/ui/cli/env-probe.ts +4 -1
- package/src/ui/mcp/api.ts +12 -1
- package/src/ui/mcp/tools.ts +60 -1
- package/src/ui/server/agent-resume-grant.ts +35 -0
- package/src/ui/server/agent-routes.ts +412 -22
- package/src/ui/server/public/agent-start.js +3 -2
- package/src/ui/server/public/agents-page.js +693 -49
- package/src/ui/server/public/agents.html +76 -15
- package/src/ui/server/public/i18n/es.js +143 -0
- package/src/ui/server/public/i18n/ja.js +144 -0
- package/src/ui/server/public/persona-ui.css +563 -0
- package/src/ui/server/public/persona-ui.js +796 -0
- package/src/ui/server/public/settings-page.js +4 -9
- package/src/ui/server/public/settings-registry.js +22 -0
- package/src/ui/server/public/settings.html +1 -0
- package/src/ui/server/public/shell.js +140 -0
- package/src/ui/server/public/topbar.js +2 -2
- package/src/ui/server/request-guard.ts +17 -4
- package/src/ui/server/route-classes.ts +31 -0
- package/src/ui/server/session-routes.ts +21 -5
- package/src/ui/server/ui-assets.ts +3 -0
package/src/ui/cli/agent-ops.ts
CHANGED
|
@@ -81,6 +81,31 @@ import {
|
|
|
81
81
|
resolvePackDirs,
|
|
82
82
|
type Persona,
|
|
83
83
|
} from "../../shared/agents/personas.js";
|
|
84
|
+
import {
|
|
85
|
+
checkModelChoice,
|
|
86
|
+
checkPersonaInput,
|
|
87
|
+
deleteLocalPersona,
|
|
88
|
+
localPersonaExists,
|
|
89
|
+
localPersonasDir,
|
|
90
|
+
newPersonaId,
|
|
91
|
+
PersonaInputError,
|
|
92
|
+
writeLocalPersona,
|
|
93
|
+
type PersonaInput,
|
|
94
|
+
} from "../../shared/agents/persona-store.js";
|
|
95
|
+
import {
|
|
96
|
+
categoryRegistry,
|
|
97
|
+
roleRegistry,
|
|
98
|
+
rolePreset,
|
|
99
|
+
type Category,
|
|
100
|
+
type RoleTemplate,
|
|
101
|
+
} from "../../shared/agents/roles.js";
|
|
102
|
+
import {
|
|
103
|
+
checkCommands,
|
|
104
|
+
checkIntent,
|
|
105
|
+
intentOf,
|
|
106
|
+
type PermissionIntent,
|
|
107
|
+
} from "../../shared/agents/permissions.js";
|
|
108
|
+
import { softNewlineSeq } from "../../shared/agents/catalog.js";
|
|
84
109
|
import {
|
|
85
110
|
AGENT_ID_RE,
|
|
86
111
|
agentFilesDir,
|
|
@@ -93,6 +118,7 @@ import {
|
|
|
93
118
|
checkDisplayName,
|
|
94
119
|
writeRuntimeFiles,
|
|
95
120
|
type AgentRecord,
|
|
121
|
+
type Override,
|
|
96
122
|
} from "../../shared/agents/records.js";
|
|
97
123
|
import {
|
|
98
124
|
agentSessionEnv,
|
|
@@ -103,9 +129,19 @@ import {
|
|
|
103
129
|
syncConfiguredRuntimes,
|
|
104
130
|
type AgentMcp,
|
|
105
131
|
type LaunchInput,
|
|
132
|
+
type LaunchOutput,
|
|
106
133
|
type Runtime,
|
|
107
134
|
} from "../../shared/agents/runtimes.js";
|
|
108
|
-
import type {
|
|
135
|
+
import type {
|
|
136
|
+
RuntimeDetect,
|
|
137
|
+
RuntimeModel,
|
|
138
|
+
RuntimePermissions,
|
|
139
|
+
} from "../../shared/agents/runtime.js";
|
|
140
|
+
import {
|
|
141
|
+
OPEN_ELSEWHERE_MESSAGE,
|
|
142
|
+
openElsewhere,
|
|
143
|
+
type RuntimeConversation,
|
|
144
|
+
} from "../../shared/agents/conversations.js";
|
|
109
145
|
import {
|
|
110
146
|
archiveHandoff,
|
|
111
147
|
DEFAULT_ROTATE,
|
|
@@ -155,6 +191,11 @@ export interface AgentsDeps extends SessionsDeps {
|
|
|
155
191
|
distRoot?: () => string;
|
|
156
192
|
/** A workspace's checkout directory: where its agents run. */
|
|
157
193
|
workspaceDir?: (ws: string) => string;
|
|
194
|
+
/**
|
|
195
|
+
* AG-17.1: the folders a workspace's conversations may have run in: its
|
|
196
|
+
* own folder, and each repo's path, a repo added by path outside it too.
|
|
197
|
+
*/
|
|
198
|
+
workspaceFolders?: (ws: string) => string[];
|
|
158
199
|
/** The MCP server binary and its env; Den adds the scoped args. */
|
|
159
200
|
mcpServer?: () => { command: string[] | string; env: Record<string, string> };
|
|
160
201
|
/**
|
|
@@ -191,6 +232,7 @@ export interface AgentsResolved extends Resolved {
|
|
|
191
232
|
agentsConfig: () => ResolvedAgentsConfig;
|
|
192
233
|
distRoot: () => string;
|
|
193
234
|
workspaceDir: (ws: string) => string;
|
|
235
|
+
workspaceFolders: (ws: string) => string[];
|
|
194
236
|
mcpServer: () => { command: string[] | string; env: Record<string, string> };
|
|
195
237
|
exitWatchMs: number;
|
|
196
238
|
stopWaitMs: number;
|
|
@@ -220,6 +262,8 @@ export class AgentError extends Error {
|
|
|
220
262
|
readonly usage: boolean;
|
|
221
263
|
readonly code?: string;
|
|
222
264
|
readonly hints: string[];
|
|
265
|
+
/** AG-10.10: the running agent a start collided with, so a page can open it. */
|
|
266
|
+
readonly running?: { id: string; sessionId: string };
|
|
223
267
|
constructor(
|
|
224
268
|
message: string,
|
|
225
269
|
o: {
|
|
@@ -227,6 +271,7 @@ export class AgentError extends Error {
|
|
|
227
271
|
usage?: boolean;
|
|
228
272
|
code?: string;
|
|
229
273
|
hints?: string[];
|
|
274
|
+
running?: { id: string; sessionId: string };
|
|
230
275
|
} = {},
|
|
231
276
|
) {
|
|
232
277
|
super(message);
|
|
@@ -235,6 +280,7 @@ export class AgentError extends Error {
|
|
|
235
280
|
this.usage = o.usage ?? false;
|
|
236
281
|
this.code = o.code;
|
|
237
282
|
this.hints = o.hints ?? [];
|
|
283
|
+
if (o.running) this.running = o.running;
|
|
238
284
|
}
|
|
239
285
|
}
|
|
240
286
|
|
|
@@ -246,6 +292,7 @@ const refused = (message: string, hints: string[] = [], status = 409) =>
|
|
|
246
292
|
new AgentError(message, { status, hints });
|
|
247
293
|
|
|
248
294
|
let workspaceDirFn: ((ws: string) => string) | null = null;
|
|
295
|
+
let repoPathsFn: ((ws: string) => string[]) | null = null;
|
|
249
296
|
/** The workspaces config, for start and resume (loaded on first use). */
|
|
250
297
|
export async function loadAgentWorkspaces(d: AgentsDeps): Promise<void> {
|
|
251
298
|
await loadWorkspaces(d);
|
|
@@ -257,6 +304,9 @@ export async function loadAgentWorkspaces(d: AgentsDeps): Promise<void> {
|
|
|
257
304
|
if (!cfg) throw usage(`workspace "${name}" does not exist`, 404);
|
|
258
305
|
return ws.resolveWorkspaceDir(name, cfg, getReposRoot());
|
|
259
306
|
};
|
|
307
|
+
// A by-path repo's own folder (repo add <path>), wherever it is.
|
|
308
|
+
repoPathsFn = (name) =>
|
|
309
|
+
ws.repoFolders(ws.readWorkspacesFile().workspaces[name]);
|
|
260
310
|
}
|
|
261
311
|
|
|
262
312
|
/** The distribution's bin shim (as `mcp connect` registers), else Den's CLI. */
|
|
@@ -287,6 +337,17 @@ export function resolveAgentsDeps(d: AgentsDeps): AgentsResolved {
|
|
|
287
337
|
if (!workspaceDirFn) throw new Error("workspaces not loaded");
|
|
288
338
|
return workspaceDirFn(ws);
|
|
289
339
|
}),
|
|
340
|
+
workspaceFolders:
|
|
341
|
+
d.workspaceFolders ??
|
|
342
|
+
((ws) => {
|
|
343
|
+
const dir = d.workspaceDir
|
|
344
|
+
? d.workspaceDir(ws)
|
|
345
|
+
: (() => {
|
|
346
|
+
if (!workspaceDirFn) throw new Error("workspaces not loaded");
|
|
347
|
+
return workspaceDirFn(ws);
|
|
348
|
+
})();
|
|
349
|
+
return [dir, ...(repoPathsFn?.(ws) ?? [])];
|
|
350
|
+
}),
|
|
290
351
|
mcpServer: d.mcpServer ?? (() => defaultMcpServer(base.cli)),
|
|
291
352
|
exitWatchMs: d.exitWatchMs ?? EXIT_WATCH_MS,
|
|
292
353
|
stopWaitMs: d.stopWaitMs ?? STOP_WAIT_MS,
|
|
@@ -360,13 +421,25 @@ export interface AgentRow {
|
|
|
360
421
|
/** The name a person gave it (AG-4b), when one did. */
|
|
361
422
|
displayName?: string;
|
|
362
423
|
/**
|
|
363
|
-
*
|
|
364
|
-
* runtime's own (Claude Code's /rename), when there is one.
|
|
424
|
+
* The name its session shows (AG-4b): the latest of Den's rename and the
|
|
425
|
+
* runtime's own (Claude Code's /rename), when there is one. 0.4.12 called
|
|
426
|
+
* this sessionName; sessionName is the record's session handle
|
|
427
|
+
* (`<dist>-<ws>:<id>`), as it has been since 0.4.0.
|
|
365
428
|
*/
|
|
366
|
-
|
|
429
|
+
sessionTitle?: string;
|
|
367
430
|
persona?: string;
|
|
368
431
|
runtime?: string;
|
|
369
432
|
model?: string;
|
|
433
|
+
/** Its model version: "latest" or pinned (AG-12.3). */
|
|
434
|
+
version?: string;
|
|
435
|
+
/** The permission intent it started with (AG-13). */
|
|
436
|
+
permission?: PermissionIntent;
|
|
437
|
+
/** Its extra allowed commands, when set for it (AG-13.4). */
|
|
438
|
+
allow?: string[];
|
|
439
|
+
/** What it overrides of its persona (AG-10.9). */
|
|
440
|
+
overrides?: Override[];
|
|
441
|
+
/** A newer version of its pinned model (AG-12.4). */
|
|
442
|
+
newer?: string;
|
|
370
443
|
workspace?: string;
|
|
371
444
|
cwd?: string;
|
|
372
445
|
/** running, stopping (stopped, its session not yet gone), exited
|
|
@@ -473,7 +546,7 @@ export function agentRows(
|
|
|
473
546
|
return {
|
|
474
547
|
agent: rec.id,
|
|
475
548
|
...recordFields(rec),
|
|
476
|
-
...(named ? {
|
|
549
|
+
...(named ? { sessionTitle: named } : {}),
|
|
477
550
|
state: st,
|
|
478
551
|
startedAt: s?.startedAt ?? rec.startedAt,
|
|
479
552
|
resumable,
|
|
@@ -504,11 +577,32 @@ function recordFields(rec: AgentRecord): Partial<AgentRow> {
|
|
|
504
577
|
persona: rec.persona,
|
|
505
578
|
runtime: rec.runtime,
|
|
506
579
|
...(rec.model ? { model: rec.model } : {}),
|
|
580
|
+
...(rec.version ? { version: rec.version } : {}),
|
|
581
|
+
...(rec.permission ? { permission: rec.permission } : {}),
|
|
582
|
+
...(rec.allow ? { allow: [...rec.allow] } : {}),
|
|
583
|
+
...(rec.overrides?.length ? { overrides: [...rec.overrides] } : {}),
|
|
584
|
+
...newerOf(rec),
|
|
507
585
|
workspace: rec.workspace,
|
|
508
586
|
cwd: rec.cwd,
|
|
509
587
|
};
|
|
510
588
|
}
|
|
511
589
|
|
|
590
|
+
/**
|
|
591
|
+
* AG-12.4: a pinned version older than its model's newest, as
|
|
592
|
+
* `{ newer: <latest> }`; nothing otherwise.
|
|
593
|
+
*/
|
|
594
|
+
export function newerOf(
|
|
595
|
+
rec: Pick<AgentRecord, "runtime" | "model" | "version">,
|
|
596
|
+
): {
|
|
597
|
+
newer?: string;
|
|
598
|
+
} {
|
|
599
|
+
if (!rec.version || rec.version === "latest" || !rec.model) return {};
|
|
600
|
+
const m = getRuntime(rec.runtime)
|
|
601
|
+
?.models?.()
|
|
602
|
+
.find((x) => x.id === rec.model);
|
|
603
|
+
return m && m.latest !== rec.version ? { newer: m.latest } : {};
|
|
604
|
+
}
|
|
605
|
+
|
|
512
606
|
/**
|
|
513
607
|
* Note in each record whose session the host lists as exited that it
|
|
514
608
|
* ended, with its code (AG-14.1: "Exited (code n)"), so it still says so
|
|
@@ -586,6 +680,7 @@ function personas(
|
|
|
586
680
|
resolvePackDirs(cfg.personaPacks, d.distRoot()),
|
|
587
681
|
undefined,
|
|
588
682
|
{ defaultPack: cfg.defaultPackGuardrails, packs: cfg.packGuardrails },
|
|
683
|
+
localPersonasDir(d.stateDir()),
|
|
589
684
|
);
|
|
590
685
|
} catch (e) {
|
|
591
686
|
throw refused((e as Error).message, [], 422);
|
|
@@ -593,13 +688,112 @@ function personas(
|
|
|
593
688
|
}
|
|
594
689
|
|
|
595
690
|
/** A persona as listed: everything but its role prompt. */
|
|
596
|
-
export type ListedPersona = Omit<Persona, "prompt" | "guardrailsSet"
|
|
691
|
+
export type ListedPersona = Omit<Persona, "prompt" | "guardrailsSet"> & {
|
|
692
|
+
/** Its permission mode as Den's intent (AG-13), when it is one. */
|
|
693
|
+
permission?: PermissionIntent;
|
|
694
|
+
};
|
|
597
695
|
|
|
598
|
-
|
|
696
|
+
function listed(p: Persona): ListedPersona {
|
|
697
|
+
const { prompt: _p, guardrailsSet: _g, ...rest } = p;
|
|
698
|
+
const permission = intentOf(p.permissionMode);
|
|
699
|
+
return { ...rest, ...(permission ? { permission } : {}) };
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
/** Den's default pack, then agents.persona_packs, then local ones, by id. */
|
|
599
703
|
export function listPersonas(d: AgentsResolved): ListedPersona[] {
|
|
600
704
|
return [...personas(d, agentsConfig(d)).values()]
|
|
601
705
|
.sort((a, b) => a.id.localeCompare(b.id))
|
|
602
|
-
.map(
|
|
706
|
+
.map(listed);
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
/** A persona with its instructions (Details, Edit: AG-10.6). */
|
|
710
|
+
export function personaDetail(
|
|
711
|
+
d: AgentsResolved,
|
|
712
|
+
id: string,
|
|
713
|
+
): ListedPersona & { instructions: string } {
|
|
714
|
+
const p = personas(d, agentsConfig(d)).get(id);
|
|
715
|
+
if (!p) throw refused(`no persona "${id}"`, [], 404);
|
|
716
|
+
return { ...listed(p), instructions: p.prompt };
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
/** The role and category registries (AG-10.5, AG-11.1). */
|
|
720
|
+
export function registries(d: AgentsResolved): {
|
|
721
|
+
roles: RoleTemplate[];
|
|
722
|
+
categories: Category[];
|
|
723
|
+
} {
|
|
724
|
+
const cfg = agentsConfig(d);
|
|
725
|
+
return {
|
|
726
|
+
roles: roleRegistry(cfg.roles),
|
|
727
|
+
categories: categoryRegistry(cfg.categories),
|
|
728
|
+
};
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
/** What a persona's fields may name (persona-store.ts checks them). */
|
|
732
|
+
function personaChoices(d: AgentsResolved) {
|
|
733
|
+
syncCatalog(d);
|
|
734
|
+
const reg = registries(d);
|
|
735
|
+
return {
|
|
736
|
+
roles: reg.roles.map((r) => r.id),
|
|
737
|
+
categories: reg.categories.map((c) => c.id),
|
|
738
|
+
runtimes: runtimeIds()
|
|
739
|
+
.filter((id) => getRuntime(id)?.catalog || id === DEFAULT_RUNTIME_ID)
|
|
740
|
+
.map((id) => ({ id, models: getRuntime(id)?.models?.() ?? [] })),
|
|
741
|
+
};
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
function checkedInput(d: AgentsResolved, body: unknown): PersonaInput {
|
|
745
|
+
try {
|
|
746
|
+
return checkPersonaInput(body, personaChoices(d));
|
|
747
|
+
} catch (e) {
|
|
748
|
+
if (e instanceof PersonaInputError) throw usage(e.message);
|
|
749
|
+
throw e;
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
/**
|
|
754
|
+
* New persona (AG-10.7): a local one, its id from its name (or role),
|
|
755
|
+
* never one any persona has. Returns it as listed.
|
|
756
|
+
*/
|
|
757
|
+
export function createPersona(d: AgentsResolved, body: unknown): ListedPersona {
|
|
758
|
+
const input = checkedInput(d, body);
|
|
759
|
+
const all = personas(d, agentsConfig(d));
|
|
760
|
+
const id = newPersonaId(input, new Set(all.keys()));
|
|
761
|
+
writeLocalPersona(d.stateDir(), id, input);
|
|
762
|
+
return personaDetail(d, id);
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
/**
|
|
766
|
+
* Edit persona `id` (AG-10.7): only a local one is written. A built-in or
|
|
767
|
+
* a distribution's persona is read-only: 409, and the page duplicates it.
|
|
768
|
+
*/
|
|
769
|
+
export function updatePersona(
|
|
770
|
+
d: AgentsResolved,
|
|
771
|
+
id: string,
|
|
772
|
+
body: unknown,
|
|
773
|
+
): ListedPersona {
|
|
774
|
+
const p = personas(d, agentsConfig(d)).get(id);
|
|
775
|
+
if (!p) throw refused(`no persona "${id}"`, [], 404);
|
|
776
|
+
if (p.origin !== "local" || !localPersonaExists(d.stateDir(), id))
|
|
777
|
+
throw new AgentError(
|
|
778
|
+
`${id} is ${p.origin === "builtin" ? "built in" : `the ${p.origin}'s`}: duplicate it to edit a copy of your own`,
|
|
779
|
+
{ status: 409, code: "read_only" },
|
|
780
|
+
);
|
|
781
|
+
writeLocalPersona(d.stateDir(), id, checkedInput(d, body));
|
|
782
|
+
return personaDetail(d, id);
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
/** Delete local persona `id` (never a built-in or a distribution's). */
|
|
786
|
+
export function deletePersona(d: AgentsResolved, id: string): void {
|
|
787
|
+
const p = personas(d, agentsConfig(d)).get(id);
|
|
788
|
+
if (!p) throw refused(`no persona "${id}"`, [], 404);
|
|
789
|
+
if (p.origin !== "local" || !deleteLocalPersona(d.stateDir(), id))
|
|
790
|
+
throw new AgentError(
|
|
791
|
+
`${id} isn't yours to delete: only local personas are`,
|
|
792
|
+
{
|
|
793
|
+
status: 409,
|
|
794
|
+
code: "read_only",
|
|
795
|
+
},
|
|
796
|
+
);
|
|
603
797
|
}
|
|
604
798
|
|
|
605
799
|
export interface RuntimeChoice {
|
|
@@ -633,6 +827,12 @@ export interface RuntimeChoice {
|
|
|
633
827
|
/** It declares resume. */
|
|
634
828
|
resume: boolean;
|
|
635
829
|
default: boolean;
|
|
830
|
+
/** Its models (AG-12.3); [] when it takes no model choice from Den. */
|
|
831
|
+
models: RuntimeModel[];
|
|
832
|
+
/** How it expresses permission intents (AG-13.2), when it can. */
|
|
833
|
+
permissions?: RuntimePermissions;
|
|
834
|
+
/** Its Shift+Enter bytes: also how a first instruction's newlines go. */
|
|
835
|
+
softNewline?: string;
|
|
636
836
|
}
|
|
637
837
|
|
|
638
838
|
/**
|
|
@@ -761,6 +961,8 @@ export async function listRuntimes(
|
|
|
761
961
|
: {}),
|
|
762
962
|
resume: Boolean(rt.resume),
|
|
763
963
|
default: id === DEFAULT_RUNTIME_ID,
|
|
964
|
+
models: rt.models?.() ?? [],
|
|
965
|
+
...(rt.permissions ? { permissions: rt.permissions } : {}),
|
|
764
966
|
};
|
|
765
967
|
}),
|
|
766
968
|
);
|
|
@@ -841,13 +1043,26 @@ function sessionOf(
|
|
|
841
1043
|
}
|
|
842
1044
|
|
|
843
1045
|
/**
|
|
844
|
-
* The id
|
|
845
|
-
*
|
|
1046
|
+
* The id a new start gets without --id (AG-10.10): the persona's id while
|
|
1047
|
+
* no agent has it; `<persona>-<ws>` when another workspace's agent has it;
|
|
1048
|
+
* else the first of `<base>-2`, `<base>-3`… no agent has. A stopped agent's
|
|
1049
|
+
* id is never reused by a new start: only its Resume or Start fresh.
|
|
846
1050
|
*/
|
|
847
|
-
function defaultId(
|
|
1051
|
+
export function defaultId(
|
|
1052
|
+
stateDir: string,
|
|
1053
|
+
persona: string,
|
|
1054
|
+
ws: string,
|
|
1055
|
+
): string {
|
|
848
1056
|
const rec = readRecord(stateDir, persona);
|
|
849
|
-
if (!rec
|
|
850
|
-
|
|
1057
|
+
if (!rec) return persona;
|
|
1058
|
+
const base = rec.workspace === ws ? persona : `${persona}-${ws}`.slice(0, 64);
|
|
1059
|
+
if (base !== persona && !readRecord(stateDir, base)) return base;
|
|
1060
|
+
const stem = base.slice(0, 58);
|
|
1061
|
+
for (let n = 2; n < 1000; n++) {
|
|
1062
|
+
const id = `${stem}-${n}`;
|
|
1063
|
+
if (!readRecord(stateDir, id)) return id;
|
|
1064
|
+
}
|
|
1065
|
+
throw refused(`no free agent id for ${persona}: give one with --id`);
|
|
851
1066
|
}
|
|
852
1067
|
|
|
853
1068
|
function checkId(where: string, id: string): void {
|
|
@@ -1037,6 +1252,8 @@ function launchInput(
|
|
|
1037
1252
|
bin?: string,
|
|
1038
1253
|
/** The grants to serve (a resume's, narrowed); default the config's. */
|
|
1039
1254
|
only?: readonly string[],
|
|
1255
|
+
/** AG-13: the intent and the allowed commands (preset and extras). */
|
|
1256
|
+
perms: { permission?: PermissionIntent; allow?: string[] } = {},
|
|
1040
1257
|
): LaunchInput {
|
|
1041
1258
|
const grants = only ? [...only] : (cfg.agents[id]?.grants ?? []);
|
|
1042
1259
|
for (const g of grants)
|
|
@@ -1065,9 +1282,32 @@ function launchInput(
|
|
|
1065
1282
|
env: d.env,
|
|
1066
1283
|
...(bin ? { bin } : {}),
|
|
1067
1284
|
rotateAfter: after,
|
|
1285
|
+
...(perms.permission ? { permission: perms.permission } : {}),
|
|
1286
|
+
...(perms.allow?.length ? { allow: [...perms.allow] } : {}),
|
|
1068
1287
|
};
|
|
1069
1288
|
}
|
|
1070
1289
|
|
|
1290
|
+
/** The model a runtime is given: a pinned version, else the model. */
|
|
1291
|
+
export function launchModel(
|
|
1292
|
+
model: string | undefined,
|
|
1293
|
+
version: string | undefined,
|
|
1294
|
+
): string | undefined {
|
|
1295
|
+
return version && version !== "latest" ? version : model;
|
|
1296
|
+
}
|
|
1297
|
+
|
|
1298
|
+
/**
|
|
1299
|
+
* The commands an agent may run without asking (AG-13.3, 13.4): its
|
|
1300
|
+
* persona role's preset, then its extra commands.
|
|
1301
|
+
*/
|
|
1302
|
+
function allowedFor(
|
|
1303
|
+
d: AgentsResolved,
|
|
1304
|
+
persona: Persona,
|
|
1305
|
+
extras: readonly string[],
|
|
1306
|
+
): string[] {
|
|
1307
|
+
const preset = rolePreset(registries(d).roles, persona.role);
|
|
1308
|
+
return [...new Set([...preset, ...extras])];
|
|
1309
|
+
}
|
|
1310
|
+
|
|
1071
1311
|
/** The rotation limit a launch counts against: 0 when its runtime can't relaunch. */
|
|
1072
1312
|
function launchAfter(rt: Runtime, input: LaunchInput): number {
|
|
1073
1313
|
return rt.firstPrompt ? (input.rotateAfter ?? 0) : 0;
|
|
@@ -1108,6 +1348,114 @@ export interface StartRequest {
|
|
|
1108
1348
|
runtime?: string;
|
|
1109
1349
|
model?: string;
|
|
1110
1350
|
id?: string;
|
|
1351
|
+
/** "latest" or a pinned version of the model (AG-12.3). */
|
|
1352
|
+
version?: string;
|
|
1353
|
+
/** The permission intent for this launch (AG-13.6). */
|
|
1354
|
+
permission?: string;
|
|
1355
|
+
/** Extra allowed commands for this launch, instead of the persona's. */
|
|
1356
|
+
allow?: unknown;
|
|
1357
|
+
/** AG-10.10: a display name for this agent (AG-4b's), to tell two apart. */
|
|
1358
|
+
name?: unknown;
|
|
1359
|
+
/**
|
|
1360
|
+
* The page's start (AG-10.1): a model must be one the runtime offers,
|
|
1361
|
+
* never a free string (the CLI's --model for generic stays free).
|
|
1362
|
+
*/
|
|
1363
|
+
strict?: boolean;
|
|
1364
|
+
}
|
|
1365
|
+
|
|
1366
|
+
/** What a start resolved, checked, before it launches. */
|
|
1367
|
+
interface Choices {
|
|
1368
|
+
model?: string;
|
|
1369
|
+
version: string;
|
|
1370
|
+
permission?: PermissionIntent;
|
|
1371
|
+
extras: string[];
|
|
1372
|
+
overrides: Override[];
|
|
1373
|
+
}
|
|
1374
|
+
|
|
1375
|
+
/**
|
|
1376
|
+
* The model, version, permission and extra commands a start runs with
|
|
1377
|
+
* (AG-10.9, 12.3, 13.6): the request's, else the persona's; each checked
|
|
1378
|
+
* against what the runtime offers. What differs from the persona is an
|
|
1379
|
+
* override, and the list tags it.
|
|
1380
|
+
*/
|
|
1381
|
+
function startChoices(
|
|
1382
|
+
req: StartRequest,
|
|
1383
|
+
persona: Persona,
|
|
1384
|
+
rt: Runtime,
|
|
1385
|
+
): Choices {
|
|
1386
|
+
const where = "agents start";
|
|
1387
|
+
const models = rt.models?.() ?? [];
|
|
1388
|
+
const overrides: Override[] = [];
|
|
1389
|
+
const personaRt = persona.runtime ?? DEFAULT_RUNTIME_ID;
|
|
1390
|
+
if (req.runtime && req.runtime !== personaRt) overrides.push("runtime");
|
|
1391
|
+
// The persona's model is for its own runtime only.
|
|
1392
|
+
const sameRt = rt.id === personaRt;
|
|
1393
|
+
const personaModel = sameRt ? persona.model : undefined;
|
|
1394
|
+
const personaVersion = (sameRt && persona.version) || "latest";
|
|
1395
|
+
let model = req.model ?? personaModel;
|
|
1396
|
+
let version =
|
|
1397
|
+
req.version ??
|
|
1398
|
+
(req.model === undefined || req.model === personaModel
|
|
1399
|
+
? personaVersion
|
|
1400
|
+
: "latest");
|
|
1401
|
+
if (req.model !== undefined && req.strict && !models.length)
|
|
1402
|
+
throw usage(`${where}: ${rt.id} takes no model choice from Den`);
|
|
1403
|
+
if (req.version !== undefined && req.version !== "latest" && !models.length)
|
|
1404
|
+
throw usage(`${where}: ${rt.id} takes no model version from Den`);
|
|
1405
|
+
// The page's choices are checked against the catalog; the CLI's --model
|
|
1406
|
+
// stays as given (a full model name), unless a version comes with it.
|
|
1407
|
+
if (
|
|
1408
|
+
models.length &&
|
|
1409
|
+
(req.strict || req.version !== undefined) &&
|
|
1410
|
+
(req.model !== undefined || req.version !== undefined)
|
|
1411
|
+
)
|
|
1412
|
+
try {
|
|
1413
|
+
const c = checkModelChoice({ id: rt.id, models }, model, version);
|
|
1414
|
+
model = c.model;
|
|
1415
|
+
version = c.version;
|
|
1416
|
+
} catch (e) {
|
|
1417
|
+
throw usage(`${where}: ${(e as Error).message}`);
|
|
1418
|
+
}
|
|
1419
|
+
if (req.model !== undefined && req.model !== personaModel)
|
|
1420
|
+
overrides.push("model");
|
|
1421
|
+
if (version !== personaVersion) overrides.push("version");
|
|
1422
|
+
let permission: PermissionIntent | undefined;
|
|
1423
|
+
if (req.permission !== undefined) {
|
|
1424
|
+
try {
|
|
1425
|
+
permission = checkIntent(req.permission, "permission");
|
|
1426
|
+
} catch (e) {
|
|
1427
|
+
throw usage(`${where}: ${(e as Error).message}`);
|
|
1428
|
+
}
|
|
1429
|
+
if (!rt.permissions)
|
|
1430
|
+
throw usage(
|
|
1431
|
+
`${where}: ${rt.id} can't take a permission mode from Den yet`,
|
|
1432
|
+
);
|
|
1433
|
+
if (permission !== intentOf(persona.permissionMode))
|
|
1434
|
+
overrides.push("permission");
|
|
1435
|
+
}
|
|
1436
|
+
let extras = persona.allowedCommands;
|
|
1437
|
+
if (req.allow !== undefined) {
|
|
1438
|
+
try {
|
|
1439
|
+
extras = checkCommands(req.allow, "allow");
|
|
1440
|
+
} catch (e) {
|
|
1441
|
+
throw usage(`${where}: ${(e as Error).message}`);
|
|
1442
|
+
}
|
|
1443
|
+
if (extras.length && !rt.permissions)
|
|
1444
|
+
throw usage(
|
|
1445
|
+
`${where}: ${rt.id} can't take allowed commands from Den yet`,
|
|
1446
|
+
);
|
|
1447
|
+
const same =
|
|
1448
|
+
extras.length === persona.allowedCommands.length &&
|
|
1449
|
+
extras.every((c) => persona.allowedCommands.includes(c));
|
|
1450
|
+
if (!same) overrides.push("allow");
|
|
1451
|
+
}
|
|
1452
|
+
return {
|
|
1453
|
+
...(model ? { model } : {}),
|
|
1454
|
+
version,
|
|
1455
|
+
...(permission ? { permission } : {}),
|
|
1456
|
+
extras: [...extras],
|
|
1457
|
+
overrides,
|
|
1458
|
+
};
|
|
1111
1459
|
}
|
|
1112
1460
|
|
|
1113
1461
|
/** What a relaunch from handoff adds to a start (den#4). */
|
|
@@ -1116,6 +1464,11 @@ interface StartMore {
|
|
|
1116
1464
|
rotations?: AgentRecord["rotations"];
|
|
1117
1465
|
/** Called once the old session is known ended, before the launch. */
|
|
1118
1466
|
beforeLaunch?: () => void;
|
|
1467
|
+
/**
|
|
1468
|
+
* AG-17.1, bring in a conversation: launch the runtime's resume of this
|
|
1469
|
+
* one (its id, in the folder it ran in) instead of a new conversation.
|
|
1470
|
+
*/
|
|
1471
|
+
bringIn?: { token: string; cwd: string };
|
|
1119
1472
|
}
|
|
1120
1473
|
|
|
1121
1474
|
export async function startAgent(
|
|
@@ -1136,10 +1489,18 @@ export async function startAgent(
|
|
|
1136
1489
|
);
|
|
1137
1490
|
const id = req.id ?? defaultId(d.stateDir(), persona.id, ws);
|
|
1138
1491
|
checkId(where, id);
|
|
1492
|
+
let given: string | null = null;
|
|
1493
|
+
if (req.name !== undefined)
|
|
1494
|
+
try {
|
|
1495
|
+
given = checkDisplayName(req.name);
|
|
1496
|
+
} catch (e) {
|
|
1497
|
+
throw usage(`${where}: ${(e as Error).message}`);
|
|
1498
|
+
}
|
|
1139
1499
|
syncCatalog(d);
|
|
1140
1500
|
const rt = runtimeFor(req.runtime ?? persona.runtime ?? DEFAULT_RUNTIME_ID);
|
|
1141
1501
|
const det = await availableOrRefuse(d, rt, ws);
|
|
1142
|
-
const
|
|
1502
|
+
const ch = startChoices(req, persona, rt);
|
|
1503
|
+
const model = launchModel(ch.model, ch.version);
|
|
1143
1504
|
|
|
1144
1505
|
const prev = readRecord(d.stateDir(), id);
|
|
1145
1506
|
if (prev && prev.workspace !== ws)
|
|
@@ -1147,19 +1508,38 @@ export async function startAgent(
|
|
|
1147
1508
|
`agent ${id} belongs to workspace ${prev.workspace}; give this one another --id`,
|
|
1148
1509
|
);
|
|
1149
1510
|
ptyOrRefuse(d);
|
|
1511
|
+
const allow = allowedFor(d, persona, ch.extras);
|
|
1150
1512
|
const input = {
|
|
1151
|
-
...launchInput(d, cfg, persona, ws, id, model, det.path
|
|
1513
|
+
...launchInput(d, cfg, persona, ws, id, model, det.path, undefined, {
|
|
1514
|
+
...(ch.permission ? { permission: ch.permission } : {}),
|
|
1515
|
+
...(rt.permissions ? { allow } : {}),
|
|
1516
|
+
}),
|
|
1152
1517
|
...(more.firstPrompt ? { firstPrompt: more.firstPrompt } : {}),
|
|
1518
|
+
// AG-17.1: the runtime's resume, in the folder the conversation ran in.
|
|
1519
|
+
...(more.bringIn
|
|
1520
|
+
? { worktree: more.bringIn.cwd, resumeToken: more.bringIn.token }
|
|
1521
|
+
: {}),
|
|
1153
1522
|
};
|
|
1523
|
+
// AG-13.4: a command a runtime can't express is said, never dropped.
|
|
1524
|
+
const unexpressed =
|
|
1525
|
+
!rt.permissions && allow.length
|
|
1526
|
+
? [
|
|
1527
|
+
`${rt.id} can't take allowed commands from Den yet: ${allow.join(", ")} not applied (it asks as usual)`,
|
|
1528
|
+
]
|
|
1529
|
+
: [];
|
|
1154
1530
|
|
|
1155
1531
|
return withClient(d, async (c) => {
|
|
1156
1532
|
const sessions = await c.list();
|
|
1157
1533
|
const old = prev ? sessionOf(sessions, prev) : undefined;
|
|
1158
1534
|
if (old && !old.exited)
|
|
1159
|
-
throw
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1535
|
+
throw new AgentError(`agent ${id} is running (${old.name})`, {
|
|
1536
|
+
status: 409,
|
|
1537
|
+
hints: [`Attach: ${d.cli} sessions attach ${old.name}`],
|
|
1538
|
+
running: { id, sessionId: old.ref.id },
|
|
1539
|
+
});
|
|
1540
|
+
const out = more.bringIn
|
|
1541
|
+
? { ...broughtIn(rt, input), resumeToken: more.bringIn.token }
|
|
1542
|
+
: launchOrThrow(() => rt.launch(input));
|
|
1163
1543
|
more.beforeLaunch?.();
|
|
1164
1544
|
// A new conversation: a handoff left from the last one is kept aside,
|
|
1165
1545
|
// so the next rotation needs its own (den#4).
|
|
@@ -1179,20 +1559,35 @@ export async function startAgent(
|
|
|
1179
1559
|
id,
|
|
1180
1560
|
persona: persona.id,
|
|
1181
1561
|
runtime: rt.id,
|
|
1182
|
-
...(model ? { model } : {}),
|
|
1562
|
+
...(ch.model ? { model: ch.model } : {}),
|
|
1563
|
+
...(ch.model ? { version: ch.version } : {}),
|
|
1564
|
+
...(ch.permission ? { permission: ch.permission } : {}),
|
|
1565
|
+
...(req.allow !== undefined ? { allow: ch.extras } : {}),
|
|
1566
|
+
...(ch.overrides.length ? { overrides: ch.overrides } : {}),
|
|
1183
1567
|
workspace: ws,
|
|
1184
1568
|
cwd: input.worktree,
|
|
1185
1569
|
sessionName: sessionName(d.dist, ws, id),
|
|
1186
1570
|
// AG-4b: a name given to the agent is the agent's, not its
|
|
1187
|
-
// conversation's: Start fresh and Relaunch keep it.
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
:
|
|
1571
|
+
// conversation's: Start fresh and Relaunch keep it. AG-10.8:
|
|
1572
|
+
// else its persona's name ("Rita").
|
|
1573
|
+
// AG-10.10: a name given at start wins.
|
|
1574
|
+
...(given
|
|
1575
|
+
? { displayName: given, displayNameAt: new Date().toISOString() }
|
|
1576
|
+
: prev?.displayName
|
|
1577
|
+
? {
|
|
1578
|
+
displayName: prev.displayName,
|
|
1579
|
+
...(prev.displayNameAt
|
|
1580
|
+
? { displayNameAt: prev.displayNameAt }
|
|
1581
|
+
: {}),
|
|
1582
|
+
}
|
|
1583
|
+
: persona.displayName
|
|
1584
|
+
? { displayName: persona.displayName }
|
|
1585
|
+
: {}),
|
|
1192
1586
|
// 0.4.8: launched on this token, so a resume continues it.
|
|
1193
1587
|
...(out.resumeToken
|
|
1194
1588
|
? { resumeToken: out.resumeToken, launched: true }
|
|
1195
1589
|
: {}),
|
|
1590
|
+
...(more.bringIn ? { broughtInAt: new Date().toISOString() } : {}),
|
|
1196
1591
|
},
|
|
1197
1592
|
argv: out.argv,
|
|
1198
1593
|
env: out.env,
|
|
@@ -1204,10 +1599,192 @@ export async function startAgent(
|
|
|
1204
1599
|
more.rotations?.length ? { rotations: more.rotations } : {},
|
|
1205
1600
|
);
|
|
1206
1601
|
const early = await exitedAtOnce(d, c, info);
|
|
1207
|
-
return {
|
|
1602
|
+
return {
|
|
1603
|
+
rec,
|
|
1604
|
+
info,
|
|
1605
|
+
warnings: [...(out.warnings ?? []), ...unexpressed],
|
|
1606
|
+
early,
|
|
1607
|
+
};
|
|
1608
|
+
});
|
|
1609
|
+
}
|
|
1610
|
+
|
|
1611
|
+
/** A bring-in's launch: the runtime's resume (AG-17.1), never a new one. */
|
|
1612
|
+
function broughtIn(
|
|
1613
|
+
rt: Runtime,
|
|
1614
|
+
input: LaunchInput,
|
|
1615
|
+
): Omit<LaunchOutput, "resumeToken"> {
|
|
1616
|
+
const out = launchOrThrow(() => rt.resume?.(input) ?? null);
|
|
1617
|
+
if (!out) throw refused(`${rt.id} can't continue a conversation`);
|
|
1618
|
+
return out;
|
|
1619
|
+
}
|
|
1620
|
+
|
|
1621
|
+
// ── AG-17.1: bring in a conversation ────────────────────────────────────────
|
|
1622
|
+
|
|
1623
|
+
/** A runtime's conversation as Bring in lists it. */
|
|
1624
|
+
export interface ListedConversation extends RuntimeConversation {
|
|
1625
|
+
runtime: string;
|
|
1626
|
+
/** Written in the last minute: still open in another terminal. */
|
|
1627
|
+
open: boolean;
|
|
1628
|
+
/** The Den agent already continuing it, if one is. */
|
|
1629
|
+
agent?: string;
|
|
1630
|
+
}
|
|
1631
|
+
|
|
1632
|
+
/** Each runtime, and whether it can bring a conversation in. */
|
|
1633
|
+
export interface BringInRuntime {
|
|
1634
|
+
id: string;
|
|
1635
|
+
label: string;
|
|
1636
|
+
bringIn: boolean;
|
|
1637
|
+
/** Why it can't, for the page. */
|
|
1638
|
+
why?: string;
|
|
1639
|
+
}
|
|
1640
|
+
|
|
1641
|
+
function bringInRuntimes(): BringInRuntime[] {
|
|
1642
|
+
return runtimeIds().map((id) => {
|
|
1643
|
+
const rt = getRuntime(id)!;
|
|
1644
|
+
const label = rt.label ?? id;
|
|
1645
|
+
return rt.listSessions
|
|
1646
|
+
? { id, label, bringIn: true }
|
|
1647
|
+
: {
|
|
1648
|
+
id,
|
|
1649
|
+
label,
|
|
1650
|
+
bringIn: false,
|
|
1651
|
+
why: `${label} can't bring in a conversation yet`,
|
|
1652
|
+
};
|
|
1208
1653
|
});
|
|
1209
1654
|
}
|
|
1210
1655
|
|
|
1656
|
+
/** The conversation id each agent of this state dir continues. */
|
|
1657
|
+
function agentByToken(d: AgentsResolved): Map<string, string> {
|
|
1658
|
+
const m = new Map<string, string>();
|
|
1659
|
+
for (const r of listRecords(d.stateDir()))
|
|
1660
|
+
if (r.resumeToken) m.set(`${r.runtime} ${r.resumeToken}`, r.id);
|
|
1661
|
+
return m;
|
|
1662
|
+
}
|
|
1663
|
+
|
|
1664
|
+
/**
|
|
1665
|
+
* The conversations Bring in offers for workspace `ws` (AG-17.1): each
|
|
1666
|
+
* runtime's own (only those with listSessions), that ran in the
|
|
1667
|
+
* workspace's folder or inside it, newest first. Reading never starts
|
|
1668
|
+
* anything; a runtime's history is read only at the ends of each file.
|
|
1669
|
+
*/
|
|
1670
|
+
export function listConversations(
|
|
1671
|
+
d: AgentsResolved,
|
|
1672
|
+
ws: string,
|
|
1673
|
+
runtime?: string,
|
|
1674
|
+
): { runtimes: BringInRuntime[]; conversations: ListedConversation[] } {
|
|
1675
|
+
const where = "agents import";
|
|
1676
|
+
checkWorkspaceName(where, ws);
|
|
1677
|
+
if (!d.workspaceExists(ws))
|
|
1678
|
+
throw usage(`${where}: workspace "${ws}" does not exist`, 404);
|
|
1679
|
+
syncCatalog(d);
|
|
1680
|
+
if (runtime !== undefined) runtimeFor(runtime);
|
|
1681
|
+
// Its folder and every repo's (one under another is scanned once).
|
|
1682
|
+
const folders = [
|
|
1683
|
+
...new Set(d.workspaceFolders(ws).map((f) => path.resolve(f))),
|
|
1684
|
+
];
|
|
1685
|
+
const scan = folders.filter(
|
|
1686
|
+
(f) => !folders.some((o) => o !== f && f.startsWith(o + path.sep)),
|
|
1687
|
+
);
|
|
1688
|
+
const taken = agentByToken(d);
|
|
1689
|
+
const now = Date.now();
|
|
1690
|
+
const conversations: ListedConversation[] = [];
|
|
1691
|
+
const seen = new Set<string>();
|
|
1692
|
+
for (const id of runtimeIds()) {
|
|
1693
|
+
if (runtime !== undefined && id !== runtime) continue;
|
|
1694
|
+
const rt = getRuntime(id);
|
|
1695
|
+
if (!rt?.listSessions) continue;
|
|
1696
|
+
for (const c of scan.flatMap((f) => rt.listSessions!(f, d.env))) {
|
|
1697
|
+
if (seen.has(`${id} ${c.id}`)) continue;
|
|
1698
|
+
seen.add(`${id} ${c.id}`);
|
|
1699
|
+
const agent = taken.get(`${id} ${c.id}`);
|
|
1700
|
+
conversations.push({
|
|
1701
|
+
...c,
|
|
1702
|
+
runtime: id,
|
|
1703
|
+
open: openElsewhere(c.lastAt, now),
|
|
1704
|
+
...(agent ? { agent } : {}),
|
|
1705
|
+
});
|
|
1706
|
+
}
|
|
1707
|
+
}
|
|
1708
|
+
conversations.sort((a, b) => b.lastAt.localeCompare(a.lastAt));
|
|
1709
|
+
return { runtimes: bringInRuntimes(), conversations };
|
|
1710
|
+
}
|
|
1711
|
+
|
|
1712
|
+
export interface ImportRequest {
|
|
1713
|
+
workspace: string;
|
|
1714
|
+
/** The runtime's conversation id. */
|
|
1715
|
+
session: string;
|
|
1716
|
+
persona: string;
|
|
1717
|
+
runtime?: string;
|
|
1718
|
+
id?: string;
|
|
1719
|
+
}
|
|
1720
|
+
|
|
1721
|
+
/** The persona's id, else the first `<persona>-<n>` no agent has. */
|
|
1722
|
+
function freeId(d: AgentsResolved, persona: string): string {
|
|
1723
|
+
if (!readRecord(d.stateDir(), persona)) return persona;
|
|
1724
|
+
for (let n = 2; n < 1000; n++) {
|
|
1725
|
+
const id = `${persona.slice(0, 58)}-${n}`;
|
|
1726
|
+
if (!readRecord(d.stateDir(), id)) return id;
|
|
1727
|
+
}
|
|
1728
|
+
throw refused(`no free agent id for ${persona}: give one with --id`);
|
|
1729
|
+
}
|
|
1730
|
+
|
|
1731
|
+
/**
|
|
1732
|
+
* Bring a conversation in (AG-17.1): the runtime's resume of it, under Den
|
|
1733
|
+
* (its token, hooks, guards, the folder it ran in, the audit trail), as a
|
|
1734
|
+
* new agent with the persona given. Afterwards it is an agent like any
|
|
1735
|
+
* other: stop, resume, rename. Refused while the conversation is open in
|
|
1736
|
+
* another terminal (written in the last minute), when an agent already
|
|
1737
|
+
* continues it, or when it isn't one of the workspace's.
|
|
1738
|
+
*/
|
|
1739
|
+
export async function importAgent(
|
|
1740
|
+
d: AgentsResolved,
|
|
1741
|
+
req: ImportRequest,
|
|
1742
|
+
): Promise<Started> {
|
|
1743
|
+
const where = "agents import";
|
|
1744
|
+
const runtime = req.runtime ?? DEFAULT_RUNTIME_ID;
|
|
1745
|
+
const { runtimes, conversations } = listConversations(
|
|
1746
|
+
d,
|
|
1747
|
+
req.workspace,
|
|
1748
|
+
runtime,
|
|
1749
|
+
);
|
|
1750
|
+
const rt = runtimes.find((r) => r.id === runtime);
|
|
1751
|
+
if (!rt?.bringIn)
|
|
1752
|
+
throw refused(
|
|
1753
|
+
rt?.why ?? `${runtime} can't bring in a conversation`,
|
|
1754
|
+
[],
|
|
1755
|
+
422,
|
|
1756
|
+
);
|
|
1757
|
+
const c = conversations.find((x) => x.id === req.session);
|
|
1758
|
+
if (!c)
|
|
1759
|
+
throw refused(
|
|
1760
|
+
`${where}: no ${rt.label} conversation "${req.session}" in workspace ${req.workspace}'s folder`,
|
|
1761
|
+
[`List them: ${d.cli} agents import -w ${req.workspace}`],
|
|
1762
|
+
404,
|
|
1763
|
+
);
|
|
1764
|
+
if (c.agent)
|
|
1765
|
+
throw new AgentError(
|
|
1766
|
+
`${where}: agent ${c.agent} already continues this conversation`,
|
|
1767
|
+
{
|
|
1768
|
+
code: "already_agent",
|
|
1769
|
+
hints: [
|
|
1770
|
+
`Resume it: ${d.cli} agents resume ${c.agent} -w ${req.workspace}`,
|
|
1771
|
+
],
|
|
1772
|
+
},
|
|
1773
|
+
);
|
|
1774
|
+
if (c.open)
|
|
1775
|
+
throw new AgentError(OPEN_ELSEWHERE_MESSAGE, { code: "open_elsewhere" });
|
|
1776
|
+
if (!fs.statSync(c.cwd, { throwIfNoEntry: false })?.isDirectory())
|
|
1777
|
+
throw refused(`${where}: the folder it ran in, ${c.cwd}, is gone`);
|
|
1778
|
+
const id = req.id ?? freeId(d, req.persona);
|
|
1779
|
+
if (readRecord(d.stateDir(), id))
|
|
1780
|
+
throw refused(`${where}: agent ${id} exists; give this one another --id`);
|
|
1781
|
+
return startAgent(
|
|
1782
|
+
d,
|
|
1783
|
+
{ workspace: req.workspace, persona: req.persona, runtime, id },
|
|
1784
|
+
{ bringIn: { token: c.id, cwd: c.cwd } },
|
|
1785
|
+
);
|
|
1786
|
+
}
|
|
1787
|
+
|
|
1211
1788
|
// ── stop, resume ────────────────────────────────────────────────────────────
|
|
1212
1789
|
|
|
1213
1790
|
/** The code of a refusal naming another workspace's agent. */
|
|
@@ -1377,9 +1954,21 @@ export async function resumeAgent(
|
|
|
1377
1954
|
persona,
|
|
1378
1955
|
rec.workspace,
|
|
1379
1956
|
rec.id,
|
|
1380
|
-
rec.model,
|
|
1957
|
+
launchModel(rec.model, rec.version),
|
|
1381
1958
|
det.path,
|
|
1382
1959
|
grants,
|
|
1960
|
+
{
|
|
1961
|
+
...(rec.permission ? { permission: rec.permission } : {}),
|
|
1962
|
+
...(rt.permissions
|
|
1963
|
+
? {
|
|
1964
|
+
allow: allowedFor(
|
|
1965
|
+
d,
|
|
1966
|
+
persona,
|
|
1967
|
+
rec.allow ?? persona.allowedCommands,
|
|
1968
|
+
),
|
|
1969
|
+
}
|
|
1970
|
+
: {}),
|
|
1971
|
+
},
|
|
1383
1972
|
);
|
|
1384
1973
|
// Resume in the cwd the agent had, wherever the workspace points now.
|
|
1385
1974
|
const at = {
|
|
@@ -1488,11 +2077,24 @@ export async function startFresh(
|
|
|
1488
2077
|
workspace: rec.workspace,
|
|
1489
2078
|
persona: rec.persona,
|
|
1490
2079
|
runtime: rec.runtime,
|
|
1491
|
-
...(rec
|
|
2080
|
+
...keptChoices(rec),
|
|
1492
2081
|
id: rec.id,
|
|
1493
2082
|
});
|
|
1494
2083
|
}
|
|
1495
2084
|
|
|
2085
|
+
/**
|
|
2086
|
+
* What a record was started with (AG-10.9), for Start fresh and Relaunch:
|
|
2087
|
+
* the same model, version, permission and extra commands.
|
|
2088
|
+
*/
|
|
2089
|
+
function keptChoices(rec: AgentRecord): Partial<StartRequest> {
|
|
2090
|
+
return {
|
|
2091
|
+
...(rec.model ? { model: rec.model } : {}),
|
|
2092
|
+
...(rec.version ? { version: rec.version } : {}),
|
|
2093
|
+
...(rec.permission ? { permission: rec.permission } : {}),
|
|
2094
|
+
...(rec.allow ? { allow: [...rec.allow] } : {}),
|
|
2095
|
+
};
|
|
2096
|
+
}
|
|
2097
|
+
|
|
1496
2098
|
// ── den#4: relaunch from handoff ────────────────────────────────────────────
|
|
1497
2099
|
|
|
1498
2100
|
export interface Relaunched extends Started {
|
|
@@ -1573,7 +2175,7 @@ export async function relaunchAgent(
|
|
|
1573
2175
|
workspace: rec.workspace,
|
|
1574
2176
|
persona: rec.persona,
|
|
1575
2177
|
runtime: rec.runtime,
|
|
1576
|
-
...(rec
|
|
2178
|
+
...keptChoices(rec),
|
|
1577
2179
|
id: rec.id,
|
|
1578
2180
|
},
|
|
1579
2181
|
{
|
|
@@ -1800,3 +2402,168 @@ export async function resumeInterrupted(
|
|
|
1800
2402
|
}
|
|
1801
2403
|
return { resumed, failed };
|
|
1802
2404
|
}
|
|
2405
|
+
|
|
2406
|
+
// ── AG-10.1: the first instruction, a plain session ─────────────────────────
|
|
2407
|
+
|
|
2408
|
+
/** The longest first instruction. */
|
|
2409
|
+
export const MAX_INSTRUCTION = 8000;
|
|
2410
|
+
|
|
2411
|
+
/**
|
|
2412
|
+
* A first instruction, checked: text of up to MAX_INSTRUCTION characters,
|
|
2413
|
+
* newlines and tabs allowed, no other control character (no escape
|
|
2414
|
+
* sequence can ride along) and no bidi override. Blank is none (null).
|
|
2415
|
+
*/
|
|
2416
|
+
export function checkInstruction(v: unknown): string | null {
|
|
2417
|
+
if (v === undefined || v === null) return null;
|
|
2418
|
+
if (typeof v !== "string") throw usage("instruction must be a string");
|
|
2419
|
+
const t = v.replace(/\r\n?/g, "\n").trim();
|
|
2420
|
+
if (!t) return null;
|
|
2421
|
+
if (t.length > MAX_INSTRUCTION)
|
|
2422
|
+
throw usage(`instruction must be at most ${MAX_INSTRUCTION} characters`);
|
|
2423
|
+
// eslint-disable-next-line no-control-regex
|
|
2424
|
+
if (/[\x00-\x08\x0b-\x1f\x7f-\x9f\u202a-\u202e\u2066-\u2069]/.test(t))
|
|
2425
|
+
throw usage("instruction can't have control characters");
|
|
2426
|
+
return t;
|
|
2427
|
+
}
|
|
2428
|
+
|
|
2429
|
+
/** How long the session must print nothing before it counts as ready. */
|
|
2430
|
+
export const READY_QUIET_MS = 800;
|
|
2431
|
+
/** The longest a first instruction waits for the session to be ready. */
|
|
2432
|
+
export const READY_MAX_MS = 30_000;
|
|
2433
|
+
const READY_POLL_MS = 150;
|
|
2434
|
+
const PASTE_ON = "\x1b[?2004h";
|
|
2435
|
+
const PASTE_OFF = "\x1b[?2004l";
|
|
2436
|
+
|
|
2437
|
+
/**
|
|
2438
|
+
* The bytes a first instruction is typed as: a bracketed paste when the
|
|
2439
|
+
* program turned bracketed paste on (its output has ESC[?2004h, last), so
|
|
2440
|
+
* newlines stay newlines; else each newline as the runtime's soft newline
|
|
2441
|
+
* (catalog softNewline). Then Enter, separately.
|
|
2442
|
+
*/
|
|
2443
|
+
export function instructionBytes(
|
|
2444
|
+
text: string,
|
|
2445
|
+
said: string,
|
|
2446
|
+
softNewline: string,
|
|
2447
|
+
): { body: string; enter: string } {
|
|
2448
|
+
const paste = said.lastIndexOf(PASTE_ON) > said.lastIndexOf(PASTE_OFF);
|
|
2449
|
+
return {
|
|
2450
|
+
body: paste
|
|
2451
|
+
? `\x1b[200~${text}\x1b[201~`
|
|
2452
|
+
: text.split("\n").join(softNewline),
|
|
2453
|
+
enter: "\r",
|
|
2454
|
+
};
|
|
2455
|
+
}
|
|
2456
|
+
|
|
2457
|
+
/**
|
|
2458
|
+
* Send an agent its first instruction once it is ready (AG-10.1): it has
|
|
2459
|
+
* printed something and then nothing for READY_QUIET_MS (a TUI drawn and
|
|
2460
|
+
* waiting), or READY_MAX_MS passed. Typed into its session over the host's
|
|
2461
|
+
* write path (the same as a keystroke from the pane), never as an argument
|
|
2462
|
+
* or through a shell. False when the session ended first or there is no
|
|
2463
|
+
* host. Never throws.
|
|
2464
|
+
*/
|
|
2465
|
+
export async function sendFirstInstruction(
|
|
2466
|
+
d: AgentsResolved,
|
|
2467
|
+
sessionId: string,
|
|
2468
|
+
runtime: string,
|
|
2469
|
+
text: string,
|
|
2470
|
+
o: { quietMs?: number; maxMs?: number } = {},
|
|
2471
|
+
): Promise<boolean> {
|
|
2472
|
+
const quiet = o.quietMs ?? READY_QUIET_MS;
|
|
2473
|
+
const max = o.maxMs ?? READY_MAX_MS;
|
|
2474
|
+
let c: HostClient | null = null;
|
|
2475
|
+
try {
|
|
2476
|
+
c = await d.connectExisting();
|
|
2477
|
+
if (!c) return false;
|
|
2478
|
+
const deadline = Date.now() + max;
|
|
2479
|
+
let last = "";
|
|
2480
|
+
let changedAt = Date.now();
|
|
2481
|
+
let said = "";
|
|
2482
|
+
for (;;) {
|
|
2483
|
+
const s = (await c.list()).find((x) => x.ref.id === sessionId);
|
|
2484
|
+
if (!s || s.exited) return false;
|
|
2485
|
+
said = (await c.scrollback(sessionId, 256 * 1024)).toString("utf-8");
|
|
2486
|
+
if (said !== last) {
|
|
2487
|
+
last = said;
|
|
2488
|
+
changedAt = Date.now();
|
|
2489
|
+
} else if (said && Date.now() - changedAt >= quiet) break;
|
|
2490
|
+
if (Date.now() >= deadline) break;
|
|
2491
|
+
await new Promise((r) => setTimeout(r, READY_POLL_MS));
|
|
2492
|
+
}
|
|
2493
|
+
const soft = softNewlineSeq(getRuntime(runtime)?.catalog);
|
|
2494
|
+
const b = instructionBytes(text, said, soft);
|
|
2495
|
+
c.write(sessionId, Buffer.from(b.body, "utf-8"));
|
|
2496
|
+
await new Promise((r) => setTimeout(r, 120));
|
|
2497
|
+
c.write(sessionId, Buffer.from(b.enter, "utf-8"));
|
|
2498
|
+
// Let the host take the bytes before the connection closes.
|
|
2499
|
+
await c.list();
|
|
2500
|
+
return true;
|
|
2501
|
+
} catch {
|
|
2502
|
+
return false;
|
|
2503
|
+
} finally {
|
|
2504
|
+
c?.close();
|
|
2505
|
+
}
|
|
2506
|
+
}
|
|
2507
|
+
|
|
2508
|
+
/** A plain session started from the page (AG-10.1 "Plain session"). */
|
|
2509
|
+
export interface PlainStarted {
|
|
2510
|
+
info: SessionInfo;
|
|
2511
|
+
}
|
|
2512
|
+
|
|
2513
|
+
/**
|
|
2514
|
+
* Plain session (AG-10.1): the person's login shell ($SHELL when it is an
|
|
2515
|
+
* absolute path to an executable, else /bin/sh) in the workspace's folder,
|
|
2516
|
+
* named shell, shell-2, … in that workspace. Its env is a plain session's
|
|
2517
|
+
* (the allowlist, every runtime's scrubEnv); it counts against
|
|
2518
|
+
* sessions.max_concurrent, never the agents' cap.
|
|
2519
|
+
*/
|
|
2520
|
+
export async function startPlainSession(
|
|
2521
|
+
d: AgentsResolved,
|
|
2522
|
+
ws: string,
|
|
2523
|
+
): Promise<PlainStarted> {
|
|
2524
|
+
checkWorkspaceName("sessions start", ws);
|
|
2525
|
+
if (!d.workspaceExists(ws))
|
|
2526
|
+
throw usage(`workspace "${ws}" does not exist`, 404);
|
|
2527
|
+
const dir = d.workspaceDir(ws);
|
|
2528
|
+
if (!fs.statSync(dir, { throwIfNoEntry: false })?.isDirectory())
|
|
2529
|
+
throw refused(`workspace ${ws}'s directory ${dir} does not exist`);
|
|
2530
|
+
ptyOrRefuse(d);
|
|
2531
|
+
const sh = d.env.SHELL;
|
|
2532
|
+
let shell = "/bin/sh";
|
|
2533
|
+
if (sh && path.isAbsolute(sh))
|
|
2534
|
+
try {
|
|
2535
|
+
fs.accessSync(sh, fs.constants.X_OK);
|
|
2536
|
+
shell = sh;
|
|
2537
|
+
} catch {
|
|
2538
|
+
/* the default */
|
|
2539
|
+
}
|
|
2540
|
+
const { plainSessionEnv } = await import("./sessions-command.js");
|
|
2541
|
+
return withClient(d, async (c) => {
|
|
2542
|
+
const names = new Set((await c.list()).map((s) => s.name));
|
|
2543
|
+
let n = 1;
|
|
2544
|
+
let name = sessionName(d.dist, ws, "shell");
|
|
2545
|
+
while (names.has(name)) name = sessionName(d.dist, ws, `shell-${++n}`);
|
|
2546
|
+
const info = await c
|
|
2547
|
+
.start(
|
|
2548
|
+
{
|
|
2549
|
+
name,
|
|
2550
|
+
cwd: dir,
|
|
2551
|
+
argv: [shell, "-l"],
|
|
2552
|
+
env: plainSessionEnv(d.env),
|
|
2553
|
+
cols: 80,
|
|
2554
|
+
rows: 24,
|
|
2555
|
+
},
|
|
2556
|
+
{ limit: d.maxSessions(), pool: "sessions" },
|
|
2557
|
+
)
|
|
2558
|
+
.catch((e: unknown) => {
|
|
2559
|
+
if (e instanceof HostError && e.code === "cap")
|
|
2560
|
+
throw new AgentError(e.message, {
|
|
2561
|
+
status: 429,
|
|
2562
|
+
code: "cap",
|
|
2563
|
+
hints: ["Stop a session first, or raise sessions.max_concurrent."],
|
|
2564
|
+
});
|
|
2565
|
+
throw e;
|
|
2566
|
+
});
|
|
2567
|
+
return { info };
|
|
2568
|
+
});
|
|
2569
|
+
}
|