agent-yes 1.263.0 → 1.264.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.
- package/dist/SUPPORTED_CLIS-DlxOPg-j.js +11 -0
- package/dist/{SUPPORTED_CLIS-DjFlWHpg.js → SUPPORTED_CLIS-JcVs6jt7.js} +2 -2
- package/dist/{agentShare-BP6wiu_5.js → agentShare-B8h_JkOw.js} +3 -3
- package/dist/{callback-B0PQUZcK.js → callback-C9rkbjgR.js} +3 -3
- package/dist/{callback-DFUFLIZQ.js → callback-DlJ_glcF.js} +3 -3
- package/dist/cli.js +9 -9
- package/dist/{cwdConflictWarn-B_047KCF.js → cwdConflictWarn-DZTxTGXq.js} +2 -2
- package/dist/{forkNested-CBdhC852.js → forkNested-CgJ1rTnT.js} +2 -2
- package/dist/globalPidIndex-CQX_0JSB.js +4 -0
- package/dist/{globalPidIndex-pwp7Vb7o.js → globalPidIndex-Dq4KY2IV.js} +2 -8
- package/dist/index.js +4 -4
- package/dist/{notifyDaemon-BskLhHdg.js → notifyDaemon--TDDJupp.js} +3 -3
- package/dist/pidStore-D-b8I6Qx.js +6 -0
- package/dist/{pidStore-DfSm1jog.js → pidStore-DJqSZPJi.js} +11 -7
- package/dist/{rustBinary-Borvwmya.js → rustBinary-DA-C6PjH.js} +2 -2
- package/dist/{schedule-BSEIVKQ0.js → schedule-CdgYYRti.js} +6 -6
- package/dist/{serve-BEdLsSpv.js → serve-Z8FAjest.js} +20 -20
- package/dist/{setup-DFRvZ_ku.js → setup-D6B85mUe.js} +2 -2
- package/dist/{spawnGate-D-68B907.js → spawnGate-D0E1uRP7.js} +2 -2
- package/dist/spawnGate-DqKBA07k.js +5 -0
- package/dist/{subcommands-B1YlmRaZ.js → subcommands-C2ipqBtq.js} +2 -2
- package/dist/{subcommands-Bo1MSwuC.js → subcommands-C3w4bg6x.js} +134 -90
- package/dist/{terminal-BMQWojIp.js → terminal-iObUHvkj.js} +2 -2
- package/dist/{todoCli-DNOk38lq.js → todoCli-CngDeaT2.js} +2 -2
- package/dist/{trayApp-CjB5QhdV.js → trayApp-BUI7SXiY.js} +2 -2
- package/dist/trayApp-Cb9a-quB.js +5 -0
- package/dist/{ts-BSHR_dga.js → ts-ClHKe4d8.js} +123 -5
- package/dist/{versionChecker-BTLZ3PAb.js → versionChecker-DRAbaxo-.js} +2 -2
- package/dist/{widget-BVss0Z9Y.js → widget-BRBsnbLn.js} +3 -3
- package/dist/{ws-DMudFezi.js → ws-BppNz49U.js} +3 -3
- package/dist/{ws-DxNNwnrc.js → ws-CAFA4oXH.js} +2 -2
- package/package.json +1 -1
- package/ts/globalPidIndex.spec.ts +5 -20
- package/ts/globalPidIndex.ts +9 -19
- package/ts/identity.spec.ts +108 -0
- package/ts/identity.ts +124 -0
- package/ts/index.ts +12 -4
- package/ts/messageLog.ts +0 -2
- package/ts/pidStore.spec.ts +14 -24
- package/ts/pidStore.ts +13 -10
- package/ts/subcommands.spec.ts +11 -120
- package/ts/subcommands.ts +13 -103
- package/ts/titleScanner.spec.ts +65 -0
- package/ts/titleScanner.ts +137 -0
- package/dist/SUPPORTED_CLIS--5E9wO4L.js +0 -11
- package/dist/globalPidIndex-CHA__9Xf.js +0 -4
- package/dist/pidStore-1PQ4ARnh.js +0 -6
- package/dist/spawnGate-DbSHnfqA.js +0 -5
- package/dist/trayApp-DrdktFnW.js +0 -5
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { T as listRecords } from "./subcommands-
|
|
1
|
+
import { T as listRecords } from "./subcommands-C3w4bg6x.js";
|
|
2
2
|
import { a as getProvisionRoot } from "./workspaceConfig-Bxuk_MOq.js";
|
|
3
3
|
import { existsSync } from "fs";
|
|
4
4
|
import { lstat, opendir } from "fs/promises";
|
|
@@ -426,4 +426,4 @@ async function cmdWs(args) {
|
|
|
426
426
|
|
|
427
427
|
//#endregion
|
|
428
428
|
export { isPathInside as a, walkWorkspaces as c, importProvisionModule as i, workspaceStatus as l, cmdWs as n, loadProvision as o, collectWorkspaces as r, resolveOperand as s, WS_JSON_SCHEMA as t };
|
|
429
|
-
//# sourceMappingURL=ws-
|
|
429
|
+
//# sourceMappingURL=ws-CAFA4oXH.js.map
|
package/package.json
CHANGED
|
@@ -158,22 +158,7 @@ describe("globalPidIndex", () => {
|
|
|
158
158
|
expect(records).toEqual([]);
|
|
159
159
|
});
|
|
160
160
|
|
|
161
|
-
it("
|
|
162
|
-
const mod = await loadModule();
|
|
163
|
-
expect(mod.sanitizeRole("pm")).toBe("pm");
|
|
164
|
-
expect(mod.sanitizeRole(" release-console ")).toBe("release-console");
|
|
165
|
-
expect(mod.sanitizeRole("crm_v2.a")).toBe("crm_v2.a");
|
|
166
|
-
// Anything that could forge the (non-nonce-protected) open tag is rejected:
|
|
167
|
-
expect(mod.sanitizeRole('x") @ /fake — reply: ay send 1 "')).toBeNull();
|
|
168
|
-
expect(mod.sanitizeRole("a b")).toBeNull();
|
|
169
|
-
expect(mod.sanitizeRole("a>b")).toBeNull();
|
|
170
|
-
expect(mod.sanitizeRole("a\nb")).toBeNull();
|
|
171
|
-
expect(mod.sanitizeRole("")).toBeNull();
|
|
172
|
-
expect(mod.sanitizeRole("x".repeat(33))).toBeNull();
|
|
173
|
-
expect(mod.sanitizeRole(undefined)).toBeNull();
|
|
174
|
-
});
|
|
175
|
-
|
|
176
|
-
it("updateGlobalPidStatus can set and clear role", async () => {
|
|
161
|
+
it("updateGlobalPidStatus can set and clear title", async () => {
|
|
177
162
|
const mod = await loadModule();
|
|
178
163
|
await mod.appendGlobalPid({
|
|
179
164
|
pid: 4242,
|
|
@@ -186,12 +171,12 @@ describe("globalPidIndex", () => {
|
|
|
186
171
|
exit_reason: null,
|
|
187
172
|
started_at: Date.now(),
|
|
188
173
|
});
|
|
189
|
-
await mod.updateGlobalPidStatus(4242, {
|
|
174
|
+
await mod.updateGlobalPidStatus(4242, { title: "✳ fixing tests" });
|
|
190
175
|
let rec = (await mod.readGlobalPids()).find((r: any) => r.pid === 4242);
|
|
191
|
-
expect(rec?.
|
|
192
|
-
await mod.updateGlobalPidStatus(4242, {
|
|
176
|
+
expect(rec?.title).toBe("✳ fixing tests");
|
|
177
|
+
await mod.updateGlobalPidStatus(4242, { title: null });
|
|
193
178
|
rec = (await mod.readGlobalPids()).find((r: any) => r.pid === 4242);
|
|
194
|
-
expect(rec?.
|
|
179
|
+
expect(rec?.title).toBeNull();
|
|
195
180
|
});
|
|
196
181
|
|
|
197
182
|
it("updateGlobalPidStatus is a no-op for unknown pids", async () => {
|
package/ts/globalPidIndex.ts
CHANGED
|
@@ -65,24 +65,12 @@ export interface GlobalPidRecord {
|
|
|
65
65
|
// permission checks off?" is unanswerable after the fact. See
|
|
66
66
|
// ts/agentPermissions.ts (mirrored in rs/src/agent_permissions.rs).
|
|
67
67
|
permissions?: AgentPermissions | null;
|
|
68
|
-
//
|
|
69
|
-
//
|
|
70
|
-
//
|
|
71
|
-
//
|
|
72
|
-
//
|
|
73
|
-
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
// Roles render verbatim into the `<ay-msg …>` header line, whose OPEN tag is not
|
|
77
|
-
// nonce-protected (only the close tag is) — whitespace, ">", or control chars in
|
|
78
|
-
// a role could forge header structure. Clamp at every entry point (env pickup,
|
|
79
|
-
// `ay role`), not at render time, so stored records stay clean.
|
|
80
|
-
const ROLE_RE = /^[A-Za-z0-9._-]{1,32}$/;
|
|
81
|
-
|
|
82
|
-
/** Validate a role name; returns the trimmed role or null when unusable. */
|
|
83
|
-
export function sanitizeRole(raw: string | undefined | null): string | null {
|
|
84
|
-
const trimmed = raw?.trim();
|
|
85
|
-
return trimmed && ROLE_RE.test(trimmed) ? trimmed : null;
|
|
68
|
+
// The child CLI's most recent terminal title (OSC 0/2 from its PTY stream —
|
|
69
|
+
// claude/opencode continuously set it to a task summary). The wrapper's
|
|
70
|
+
// title scanner keeps this fresh so `ay whoami` / `ay ls --json` can answer
|
|
71
|
+
// "what is this agent doing" without reading its screen. Mirrors Rust's
|
|
72
|
+
// `title`.
|
|
73
|
+
title?: string | null;
|
|
86
74
|
}
|
|
87
75
|
|
|
88
76
|
/**
|
|
@@ -136,7 +124,9 @@ export async function appendGlobalPid(record: GlobalPidRecord): Promise<void> {
|
|
|
136
124
|
/** Append a partial update by pid (status, exit_code, exit_reason, log_file). */
|
|
137
125
|
export async function updateGlobalPidStatus(
|
|
138
126
|
pid: number,
|
|
139
|
-
patch: Partial<
|
|
127
|
+
patch: Partial<
|
|
128
|
+
Pick<GlobalPidRecord, "status" | "exit_code" | "exit_reason" | "log_file" | "title">
|
|
129
|
+
>,
|
|
140
130
|
): Promise<void> {
|
|
141
131
|
const file = resolveGlobalFile(); // capture at call time (see withLock)
|
|
142
132
|
try {
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
2
|
+
import { mkdir, mkdtemp, rm, writeFile } from "fs/promises";
|
|
3
|
+
import { homedir, tmpdir } from "os";
|
|
4
|
+
import path from "path";
|
|
5
|
+
import { formatIdentity, localHost, localUser, readGitBranch, tildify } from "./identity.ts";
|
|
6
|
+
|
|
7
|
+
let root: string;
|
|
8
|
+
|
|
9
|
+
beforeEach(async () => {
|
|
10
|
+
root = await mkdtemp(path.join(tmpdir(), "ay-ident-"));
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
afterEach(async () => {
|
|
14
|
+
await rm(root, { recursive: true, force: true }).catch(() => null);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
async function gitFixture(rel: string, head: string): Promise<string> {
|
|
18
|
+
const repo = path.join(root, rel);
|
|
19
|
+
await mkdir(path.join(repo, ".git"), { recursive: true });
|
|
20
|
+
await writeFile(path.join(repo, ".git", "HEAD"), head);
|
|
21
|
+
return repo;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
describe("readGitBranch", () => {
|
|
25
|
+
it("reads the branch from a normal checkout", async () => {
|
|
26
|
+
const repo = await gitFixture("repo", "ref: refs/heads/main\n");
|
|
27
|
+
expect(readGitBranch(repo)).toBe("main");
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("walks up from a subdirectory", async () => {
|
|
31
|
+
const repo = await gitFixture("repo2", "ref: refs/heads/feat/x-1.2\n");
|
|
32
|
+
const sub = path.join(repo, "a", "b");
|
|
33
|
+
await mkdir(sub, { recursive: true });
|
|
34
|
+
expect(readGitBranch(sub)).toBe("feat/x-1.2");
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("follows a worktree's gitdir indirection file", async () => {
|
|
38
|
+
const gitdir = path.join(root, "main-repo", ".git", "worktrees", "wt1");
|
|
39
|
+
await mkdir(gitdir, { recursive: true });
|
|
40
|
+
await writeFile(path.join(gitdir, "HEAD"), "ref: refs/heads/crm-yamamoto-wifi\n");
|
|
41
|
+
const wt = path.join(root, "wt-checkout");
|
|
42
|
+
await mkdir(wt, { recursive: true });
|
|
43
|
+
await writeFile(path.join(wt, ".git"), `gitdir: ${gitdir}\n`);
|
|
44
|
+
expect(readGitBranch(wt)).toBe("crm-yamamoto-wifi");
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("resolves a RELATIVE gitdir against the checkout", async () => {
|
|
48
|
+
const gitdir = path.join(root, "gd");
|
|
49
|
+
await mkdir(gitdir, { recursive: true });
|
|
50
|
+
await writeFile(path.join(gitdir, "HEAD"), "ref: refs/heads/rel\n");
|
|
51
|
+
const wt = path.join(root, "rel-checkout");
|
|
52
|
+
await mkdir(wt, { recursive: true });
|
|
53
|
+
await writeFile(path.join(wt, ".git"), "gitdir: ../gd\n");
|
|
54
|
+
expect(readGitBranch(wt)).toBe("rel");
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("returns a short commit id for a detached HEAD", async () => {
|
|
58
|
+
const sha = "0123456789abcdef0123456789abcdef01234567";
|
|
59
|
+
const repo = await gitFixture("det", sha + "\n");
|
|
60
|
+
expect(readGitBranch(repo)).toBe(sha.slice(0, 12));
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("returns null outside any git checkout", async () => {
|
|
64
|
+
const plain = path.join(root, "plain");
|
|
65
|
+
await mkdir(plain, { recursive: true });
|
|
66
|
+
expect(readGitBranch(plain)).toBeNull();
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("returns null on an unreadable/garbled HEAD", async () => {
|
|
70
|
+
const repo = await gitFixture("bad", "something else entirely\n");
|
|
71
|
+
expect(readGitBranch(repo)).toBeNull();
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
describe("formatIdentity", () => {
|
|
76
|
+
it("renders user@host:path:branch#pid", async () => {
|
|
77
|
+
const repo = await gitFixture("fmt", "ref: refs/heads/main\n");
|
|
78
|
+
const id = formatIdentity({ user: "sno", host: "Mac", cwd: repo, pid: 30402 });
|
|
79
|
+
expect(id).toBe(`sno@Mac:${repo}:main#30402`);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("omits the branch segment outside a repo", async () => {
|
|
83
|
+
const plain = path.join(root, "nofmt");
|
|
84
|
+
await mkdir(plain, { recursive: true });
|
|
85
|
+
const id = formatIdentity({ user: "sno", host: "Mac", cwd: plain, pid: 7 });
|
|
86
|
+
expect(id).toBe(`sno@Mac:${plain}#7`);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("accepts an explicit branch (and null to suppress detection)", () => {
|
|
90
|
+
expect(formatIdentity({ user: "u", host: "h", cwd: "/x", branch: "b", pid: 1 })).toBe(
|
|
91
|
+
"u@h:/x:b#1",
|
|
92
|
+
);
|
|
93
|
+
expect(formatIdentity({ user: "u", host: "h", cwd: "/x", branch: null, pid: 1 })).toBe(
|
|
94
|
+
"u@h:/x#1",
|
|
95
|
+
);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("defaults user/host to header-safe local values", () => {
|
|
99
|
+
const id = formatIdentity({ cwd: "/x", branch: null, pid: 1 });
|
|
100
|
+
expect(id).toMatch(/^[A-Za-z0-9._-]+@[A-Za-z0-9._-]+:\/x#1$/);
|
|
101
|
+
expect(id).toContain(`${localUser()}@${localHost()}`);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("tildifies the home directory like ay ls does", () => {
|
|
105
|
+
expect(tildify(path.join(homedir(), "ws"))).toBe(`~${path.sep}ws`);
|
|
106
|
+
expect(tildify("/opt/x")).toBe("/opt/x");
|
|
107
|
+
});
|
|
108
|
+
});
|
package/ts/identity.ts
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The standardized agent identity string:
|
|
3
|
+
*
|
|
4
|
+
* <username>@<hostname>:<path>:<branch>#<pid>
|
|
5
|
+
* e.g. sno@Mac:~/ws/symval/symval/tree/crm:main#30402
|
|
6
|
+
*
|
|
7
|
+
* One line answers "who is this agent" across every surface that names one —
|
|
8
|
+
* the `ay send` envelope header, `ay whoami`, logs. The branch segment usually
|
|
9
|
+
* carries the lane name for free (worktree checkouts like `crm-yamamoto-wifi`
|
|
10
|
+
* or `fix/t173-tone-core` name their purpose), which is why there is no
|
|
11
|
+
* separate role concept.
|
|
12
|
+
*
|
|
13
|
+
* Parsing (right-to-left, for consumers that need it): `#<pid>` from the end;
|
|
14
|
+
* the branch is after the last `:` (git forbids `:` in ref names); the
|
|
15
|
+
* username is before the first `@`; the hostname is between that `@` and the
|
|
16
|
+
* next `:` (hostnames contain no `:`). The path in the middle may itself
|
|
17
|
+
* contain `@`/`:` (Windows drives), which is why parsing anchors on the ends.
|
|
18
|
+
*
|
|
19
|
+
* Every token is clamped before rendering: the identity is embedded in the
|
|
20
|
+
* `<ay-msg …>` envelope's OPEN tag, which is not nonce-protected — whitespace
|
|
21
|
+
* or `>` in a token could forge header structure.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { readFileSync } from "node:fs";
|
|
25
|
+
import { homedir, hostname, userInfo } from "node:os";
|
|
26
|
+
import path from "node:path";
|
|
27
|
+
|
|
28
|
+
/** Replace anything outside a conservative charset so a token can never break
|
|
29
|
+
* out of the envelope header line. Collapses runs to a single `-`. */
|
|
30
|
+
function safeToken(raw: string, max: number): string {
|
|
31
|
+
const cleaned = raw.replace(/[^A-Za-z0-9._-]+/g, "-").replace(/^-+|-+$/g, "");
|
|
32
|
+
return cleaned.slice(0, max);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** The local username, header-safe ("John Smith" → "John-Smith"). */
|
|
36
|
+
export function localUser(): string {
|
|
37
|
+
try {
|
|
38
|
+
return safeToken(userInfo().username, 32) || "unknown";
|
|
39
|
+
} catch {
|
|
40
|
+
return "unknown";
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** The local hostname's first label ("Macs-MBP.local" → "Macs-MBP"). */
|
|
45
|
+
export function localHost(): string {
|
|
46
|
+
const first = hostname().split(".")[0] ?? "";
|
|
47
|
+
return safeToken(first, 32) || "unknown";
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* The checked-out branch of the git repo containing `cwd`, or a short commit
|
|
52
|
+
* id when detached, or null when `cwd` is not inside a git checkout. Pure
|
|
53
|
+
* file reads — never spawns git — so it is cheap enough to run on every send:
|
|
54
|
+
* walk up to the nearest `.git`, follow a worktree's `gitdir:` indirection
|
|
55
|
+
* file, then parse `HEAD`.
|
|
56
|
+
*/
|
|
57
|
+
export function readGitBranch(cwd: string): string | null {
|
|
58
|
+
let dir = path.resolve(cwd);
|
|
59
|
+
for (let depth = 0; depth < 32; depth++) {
|
|
60
|
+
const dotGit = path.join(dir, ".git");
|
|
61
|
+
let gitDir: string | null = null;
|
|
62
|
+
try {
|
|
63
|
+
const content = readFileSync(dotGit, "utf8");
|
|
64
|
+
// A worktree/submodule checkout: `.git` is a FILE containing
|
|
65
|
+
// `gitdir: <path>` (absolute, or relative to the checkout).
|
|
66
|
+
const m = /^gitdir:\s*(.+)\s*$/m.exec(content);
|
|
67
|
+
gitDir = m ? path.resolve(dir, m[1]!.trim()) : null;
|
|
68
|
+
} catch (err: any) {
|
|
69
|
+
if (err?.code === "EISDIR") gitDir = dotGit;
|
|
70
|
+
// ENOENT/EACCES: no .git here — walk up.
|
|
71
|
+
}
|
|
72
|
+
if (gitDir) {
|
|
73
|
+
try {
|
|
74
|
+
const head = readFileSync(path.join(gitDir, "HEAD"), "utf8").trim();
|
|
75
|
+
const ref = /^ref:\s*refs\/heads\/(.+)$/.exec(head);
|
|
76
|
+
if (ref) return safeBranch(ref[1]!);
|
|
77
|
+
if (/^[0-9a-f]{40}$/i.test(head)) return head.slice(0, 12); // detached
|
|
78
|
+
return null; // unrecognized HEAD — don't guess
|
|
79
|
+
} catch {
|
|
80
|
+
return null; // .git exists but HEAD unreadable
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
const parent = path.dirname(dir);
|
|
84
|
+
if (parent === dir) return null;
|
|
85
|
+
dir = parent;
|
|
86
|
+
}
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Branch names never contain `:`/space/`~`/`^` (git forbids them), but they
|
|
91
|
+
* DO allow `/` and `#` — keep `/` (readable, unambiguous: parsing anchors on
|
|
92
|
+
* `#<pid>` at the end) and clamp everything else defensively. */
|
|
93
|
+
function safeBranch(raw: string): string {
|
|
94
|
+
const cleaned = raw.replace(/[^A-Za-z0-9._/#-]+/g, "-").replace(/^-+|-+$/g, "");
|
|
95
|
+
return cleaned.slice(0, 64);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Abbreviate the home directory to `~`, mirroring how `ay ls` prints cwds. */
|
|
99
|
+
export function tildify(p: string): string {
|
|
100
|
+
const home = homedir();
|
|
101
|
+
return p.startsWith(home) ? "~" + p.slice(home.length) : p;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export interface IdentityParts {
|
|
105
|
+
user?: string;
|
|
106
|
+
host?: string;
|
|
107
|
+
cwd: string;
|
|
108
|
+
/** Pass null to omit the branch segment; omit the key to auto-detect. */
|
|
109
|
+
branch?: string | null;
|
|
110
|
+
pid: number;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Render the standardized identity for an agent. `user`/`host` default to the
|
|
115
|
+
* local machine (correct for the envelope: the sending wrapper runs on the
|
|
116
|
+
* agent's own host), `branch` auto-detects from `cwd` unless given.
|
|
117
|
+
*/
|
|
118
|
+
export function formatIdentity(parts: IdentityParts): string {
|
|
119
|
+
const user = parts.user ?? localUser();
|
|
120
|
+
const host = parts.host ?? localHost();
|
|
121
|
+
const branch = parts.branch === undefined ? readGitBranch(parts.cwd) : parts.branch;
|
|
122
|
+
const where = tildify(parts.cwd);
|
|
123
|
+
return `${user}@${host}:${where}${branch ? `:${branch}` : ""}#${parts.pid}`;
|
|
124
|
+
}
|
package/ts/index.ts
CHANGED
|
@@ -18,6 +18,7 @@ import { acquireLock, releaseLock, shouldUseLock } from "./runningLock.ts";
|
|
|
18
18
|
import { logger } from "./logger.ts";
|
|
19
19
|
import { createFifoStream } from "./beta/fifo.ts";
|
|
20
20
|
import { PidStore } from "./pidStore.ts";
|
|
21
|
+
import { TitlePublisher, TitleScanner } from "./titleScanner.ts";
|
|
21
22
|
import { sendEnter, sendMessage } from "./core/messaging.ts";
|
|
22
23
|
import {
|
|
23
24
|
AUTO_RETRY_GIVE_UP_MS,
|
|
@@ -460,10 +461,6 @@ export default async function agentYes({
|
|
|
460
461
|
// `ay`) don't inherit it and register under the same id (which would make that
|
|
461
462
|
// id ambiguous). The wrapper's own process.env still carries it for pidStore.
|
|
462
463
|
delete ptyEnv.AGENT_YES_AGENT_ID;
|
|
463
|
-
// Same reasoning for AGENT_YES_ROLE: it names THIS agent's lane (pidStore
|
|
464
|
-
// reads it from our own env at registration). A subagent is its own lane, so
|
|
465
|
-
// don't let it inherit the parent's role.
|
|
466
|
-
delete ptyEnv.AGENT_YES_ROLE;
|
|
467
464
|
// Strip the parent Claude Code session markers so the wrapped CLI is a CLEAN
|
|
468
465
|
// top-level session. Without this, an `ay claude` launched from inside another
|
|
469
466
|
// Claude Code session (or this claude's Bash tool) inherits
|
|
@@ -524,12 +521,23 @@ export default async function agentYes({
|
|
|
524
521
|
// Wire up the xterm proxy to write back to the PTY
|
|
525
522
|
shellWrite = (data: string) => shell.write(data);
|
|
526
523
|
|
|
524
|
+
// Capture the child CLI's terminal title (OSC 0/2) into the registry so
|
|
525
|
+
// `ay whoami` / `ay ls --json` can answer "what is this agent doing".
|
|
526
|
+
// TitlePublisher change-gates + rate-limits the registry writes.
|
|
527
|
+
const titleScanner = new TitleScanner();
|
|
528
|
+
const titlePublisher = new TitlePublisher((title) => {
|
|
529
|
+
pidStore.updateTitle(shell.pid, title).catch(() => null);
|
|
530
|
+
});
|
|
531
|
+
|
|
527
532
|
// Attach data handler IMMEDIATELY after spawn to avoid losing early PTY output.
|
|
528
533
|
// node-pty emits 'data' events eagerly — if no listener is attached, events are lost.
|
|
529
534
|
function onData(data: string) {
|
|
530
535
|
const currentPid = shell.pid;
|
|
531
536
|
xtermProxy.write(data);
|
|
532
537
|
globalAgentRegistry.appendStdout(currentPid, data);
|
|
538
|
+
const title = titleScanner.feed(data);
|
|
539
|
+
if (title) titlePublisher.observe(title);
|
|
540
|
+
else titlePublisher.poll();
|
|
533
541
|
}
|
|
534
542
|
shell.onData(onData);
|
|
535
543
|
|
package/ts/messageLog.ts
CHANGED
|
@@ -25,8 +25,6 @@ export interface MailParty {
|
|
|
25
25
|
cli: string;
|
|
26
26
|
cwd: string;
|
|
27
27
|
agent_id?: string | null;
|
|
28
|
-
/** Self-declared lane/role name at send time, when the party had one set. */
|
|
29
|
-
role?: string;
|
|
30
28
|
}
|
|
31
29
|
|
|
32
30
|
/** A single delivered inter-agent message, stored verbatim in both mailboxes. */
|
package/ts/pidStore.spec.ts
CHANGED
|
@@ -63,30 +63,20 @@ describe("PidStore", () => {
|
|
|
63
63
|
expect(rec._id).toBeTypeOf("string");
|
|
64
64
|
});
|
|
65
65
|
|
|
66
|
-
it("
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
pid: 12347,
|
|
81
|
-
cli: "claude",
|
|
82
|
-
args: [],
|
|
83
|
-
cwd: "/tmp",
|
|
84
|
-
});
|
|
85
|
-
expect(rec2.role).toBeUndefined();
|
|
86
|
-
} finally {
|
|
87
|
-
if (saved === undefined) delete process.env.AGENT_YES_ROLE;
|
|
88
|
-
else process.env.AGENT_YES_ROLE = saved;
|
|
89
|
-
}
|
|
66
|
+
it("updateTitle stores the latest terminal title and dedupes", async () => {
|
|
67
|
+
await store.registerProcess({
|
|
68
|
+
pid: 12346,
|
|
69
|
+
cli: "claude",
|
|
70
|
+
args: [],
|
|
71
|
+
cwd: "/tmp",
|
|
72
|
+
});
|
|
73
|
+
await store.updateTitle(12346, "✳ fixing tests");
|
|
74
|
+
let rec = store.getAllRecords().find((r) => r.pid === 12346);
|
|
75
|
+
expect(rec?.title).toBe("✳ fixing tests");
|
|
76
|
+
await store.updateTitle(12346, "✳ fixing tests"); // unchanged — no-op
|
|
77
|
+
await store.updateTitle(12346, "done");
|
|
78
|
+
rec = store.getAllRecords().find((r) => r.pid === 12346);
|
|
79
|
+
expect(rec?.title).toBe("done");
|
|
90
80
|
});
|
|
91
81
|
|
|
92
82
|
it("should upsert when registering same pid", async () => {
|
package/ts/pidStore.ts
CHANGED
|
@@ -9,7 +9,6 @@ import {
|
|
|
9
9
|
appendGlobalPid,
|
|
10
10
|
maybeCompactGlobalPids,
|
|
11
11
|
pruneOldLogs,
|
|
12
|
-
sanitizeRole,
|
|
13
12
|
updateGlobalPidStatus,
|
|
14
13
|
} from "./globalPidIndex.ts";
|
|
15
14
|
|
|
@@ -30,8 +29,8 @@ export interface PidRecord {
|
|
|
30
29
|
agentId?: string;
|
|
31
30
|
/** Permission posture at spawn time; mirrored to the global index. */
|
|
32
31
|
permissions?: AgentPermissions;
|
|
33
|
-
/**
|
|
34
|
-
|
|
32
|
+
/** The child CLI's latest terminal title (OSC 0/2); mirrored as `title`. */
|
|
33
|
+
title?: string;
|
|
35
34
|
}
|
|
36
35
|
|
|
37
36
|
export class PidStore {
|
|
@@ -95,11 +94,6 @@ export class PidStore {
|
|
|
95
94
|
existing?.agentId ??
|
|
96
95
|
(injected && /^[0-9a-f]{6,32}$/i.test(injected) ? injected : randomBytes(6).toString("hex"));
|
|
97
96
|
|
|
98
|
-
// A caller-set AGENT_YES_ROLE names the lane this agent plays ("pm", "crm",
|
|
99
|
-
// …) for the `ay send` envelope. Picked up once at registration; index.ts
|
|
100
|
-
// strips it from the wrapped CLI's env so subagents don't inherit it.
|
|
101
|
-
const role = sanitizeRole(process.env.AGENT_YES_ROLE) ?? existing?.role;
|
|
102
|
-
|
|
103
97
|
const record: Omit<PidRecord, "_id"> = {
|
|
104
98
|
pid,
|
|
105
99
|
cli,
|
|
@@ -113,7 +107,6 @@ export class PidStore {
|
|
|
113
107
|
exitReason: "",
|
|
114
108
|
startedAt: now,
|
|
115
109
|
agentId,
|
|
116
|
-
...(role ? { role } : {}),
|
|
117
110
|
};
|
|
118
111
|
|
|
119
112
|
if (existing) {
|
|
@@ -149,7 +142,6 @@ export class PidStore {
|
|
|
149
142
|
parent_pid: parentPid ?? null,
|
|
150
143
|
agent_id: agentId,
|
|
151
144
|
permissions: permissions ?? null,
|
|
152
|
-
role: role ?? null,
|
|
153
145
|
})
|
|
154
146
|
.then(() => maybeCompactGlobalPids())
|
|
155
147
|
.catch(() => null);
|
|
@@ -180,6 +172,17 @@ export class PidStore {
|
|
|
180
172
|
}).catch(() => null);
|
|
181
173
|
}
|
|
182
174
|
|
|
175
|
+
/** Record the child CLI's latest terminal title (see ts/titleScanner.ts —
|
|
176
|
+
* callers are change-gated + rate-limited, so each call may write). */
|
|
177
|
+
async updateTitle(pid: number, title: string): Promise<void> {
|
|
178
|
+
const existing = this.store.findOne((doc) => doc.pid === pid);
|
|
179
|
+
if (existing && existing.title !== title) {
|
|
180
|
+
await this.store.updateById(existing._id!, { title });
|
|
181
|
+
}
|
|
182
|
+
// Mirror to global index. Same fire-and-forget policy as updateStatus.
|
|
183
|
+
updateGlobalPidStatus(pid, { title }).catch(() => null);
|
|
184
|
+
}
|
|
185
|
+
|
|
183
186
|
getAllRecords(): PidRecord[] {
|
|
184
187
|
return this.store.getAll();
|
|
185
188
|
}
|