@retasc/cli 1.49.1 → 1.51.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/CHANGELOG.md +21 -0
- package/dist/api.js +9 -0
- package/dist/commands/bind.js +39 -0
- package/dist/commands/plan.js +205 -0
- package/dist/index.js +23 -1
- package/dist/lib/prompt.js +40 -0
- package/dist/lib/worktreeReport.js +172 -0
- package/dist/proxy.js +190 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,27 @@ release commits and the issues they reference.
|
|
|
6
6
|
|
|
7
7
|
Dates are the npm publish date. Each entry names the RTSC issue behind it.
|
|
8
8
|
|
|
9
|
+
## 1.51.0 (2026-09-13)
|
|
10
|
+
|
|
11
|
+
- TODO: describe this release, and name the RTSC issue behind it.
|
|
12
|
+
|
|
13
|
+
## 1.50.0 (2026-09-11)
|
|
14
|
+
|
|
15
|
+
- **RTSC-901** — `retasc plan`: which subscription pays for each of your agents, listed
|
|
16
|
+
and set one line per (agent, harness). Retasc cannot read this anywhere — no transcript
|
|
17
|
+
field, telemetry attribute or environment variable carries it, and the only machine
|
|
18
|
+
source sits behind the human's own keychain — so it is asked for once and kept.
|
|
19
|
+
The options are READ FROM THE SERVER on every run, never compiled in: a tier a vendor
|
|
20
|
+
ships this morning is on the list this afternoon, on the build you already have.
|
|
21
|
+
- **RTSC-901** — `bind` asks the same question at setup, but only when `--runtime` was
|
|
22
|
+
STATED. That flag defaults to `claude-code`, which is a fine label for a row and the
|
|
23
|
+
wrong subject for this question: `retasc setup` wires every harness on the machine, so
|
|
24
|
+
asking "which Claude plan?" of someone setting up Codex records a fact about the wrong
|
|
25
|
+
product. Left unstated, the question falls to the first MCP handshake, which knows
|
|
26
|
+
which harness actually connected.
|
|
27
|
+
- A non-interactive run answers nothing rather than defaulting. A plan nobody chose reads
|
|
28
|
+
on the Agents page exactly like one they did.
|
|
29
|
+
|
|
9
30
|
## 1.49.1 (2026-09-09)
|
|
10
31
|
|
|
11
32
|
- **RTSC-879** — the proxy sends NO `Authorization` header when it has no key, instead of
|
package/dist/api.js
CHANGED
|
@@ -16,6 +16,12 @@ const fns = {
|
|
|
16
16
|
createProject: makeFunctionReference("manage:createProject"),
|
|
17
17
|
renameProjectPrefix: makeFunctionReference("manage:renameProjectPrefix"),
|
|
18
18
|
listKeys: makeFunctionReference("manage:listKeys"),
|
|
19
|
+
// RTSC-901 — the roster (for the agent rows and their plans) and the plan catalogue.
|
|
20
|
+
// The catalogue is fetched on every run and never cached to disk: a list cached in
|
|
21
|
+
// the client is the release-shaped list this issue exists to avoid.
|
|
22
|
+
listOrgMembers: makeFunctionReference("manage:listOrgMembers"),
|
|
23
|
+
listPlanCatalogue: makeFunctionReference("plans:listCatalogue"),
|
|
24
|
+
setMemberPlan: makeFunctionReference("plans:setMemberPlan"),
|
|
19
25
|
mintKey: makeFunctionReference("manage:mintKey"),
|
|
20
26
|
rotateKey: makeFunctionReference("manage:rotateKey"),
|
|
21
27
|
revokeKey: makeFunctionReference("manage:revokeKey"),
|
|
@@ -210,6 +216,9 @@ export const api = {
|
|
|
210
216
|
createProject: (args) => withAuth(() => client().mutation(fns.createProject, args)),
|
|
211
217
|
renameProjectPrefix: (args) => withAuth(() => client().mutation(fns.renameProjectPrefix, args)),
|
|
212
218
|
listKeys: (args) => withAuth(() => client().query(fns.listKeys, args)),
|
|
219
|
+
listOrgMembers: (args) => withAuth(() => client().query(fns.listOrgMembers, args)),
|
|
220
|
+
listPlanCatalogue: (args) => withAuth(() => client().query(fns.listPlanCatalogue, args)),
|
|
221
|
+
setMemberPlan: (args) => withAuth(() => client().mutation(fns.setMemberPlan, args)),
|
|
213
222
|
mintKey: (args) => withAuth(() => client().action(fns.mintKey, args)),
|
|
214
223
|
rotateKey: (args) => withAuth(() => client().action(fns.rotateKey, args)),
|
|
215
224
|
revokeKey: (args) => withAuth(() => client().mutation(fns.revokeKey, args)),
|
package/dist/commands/bind.js
CHANGED
|
@@ -604,6 +604,45 @@ export async function completeWorkspaceSetup(args) {
|
|
|
604
604
|
// the only reader of this output, and it is the one that has to tell the human to
|
|
605
605
|
// restart. A setup that finished but never says so is indistinguishable from one that
|
|
606
606
|
// failed. The "what now" suggestions stay TTY-only — those are for a person browsing.
|
|
607
|
+
// RTSC-901 — which plan pays for this agent. Asked ONCE, here, because this is the
|
|
608
|
+
// moment the agent row exists and a person is already at the keyboard.
|
|
609
|
+
//
|
|
610
|
+
// Three things this must not do, all of them worse than not knowing: block a
|
|
611
|
+
// non-interactive run (`select` returns null and we move on), ask again when this
|
|
612
|
+
// human already answered for this runtime (the roster hands back an inherited plan,
|
|
613
|
+
// so `plan` is set and the row is skipped), or fail the bind. The bind is the
|
|
614
|
+
// critical path and the plan is a nicety on top of it, so ANY failure here is
|
|
615
|
+
// swallowed — a missing plan costs a line on the Agents page, a failed bind costs
|
|
616
|
+
// the whole setup.
|
|
617
|
+
//
|
|
618
|
+
// Placed before the receipt so the last thing on screen is still the next step.
|
|
619
|
+
//
|
|
620
|
+
// ONLY WHEN THE RUNTIME WAS STATED. `--runtime` defaults to claude-code, which is a
|
|
621
|
+
// fine LABEL for a row and a bad subject for this question: `retasc setup` wires
|
|
622
|
+
// every harness on the machine, so asking "which Claude plan?" of someone setting up
|
|
623
|
+
// Codex records a fact about the wrong product, and a wrong plan is worse than none.
|
|
624
|
+
// Unstated, the question goes to the first handshake instead — `setup_status` reaches
|
|
625
|
+
// the same `askHuman` payload with the harness that actually connected.
|
|
626
|
+
if (isInteractive() && opts.runtimeStated) {
|
|
627
|
+
try {
|
|
628
|
+
const [roster, catalogue] = await Promise.all([
|
|
629
|
+
api.listOrgMembers({ orgId }),
|
|
630
|
+
api.listPlanCatalogue({ orgId }),
|
|
631
|
+
]);
|
|
632
|
+
const runtime = opts.runtime ?? "claude-code";
|
|
633
|
+
const unanswered = roster.filter((m) => m.isAgent && m.mine && m.canSetPlan && m.runtime === runtime && !m.plansByClass?.[m.planBucket]);
|
|
634
|
+
// Exactly one, or we do not know which this bind created — and asking about the
|
|
635
|
+
// wrong agent records a fact under the wrong name.
|
|
636
|
+
if (unanswered.length === 1) {
|
|
637
|
+
const { askAndSet } = await import("./plan.js");
|
|
638
|
+
const cat = catalogue;
|
|
639
|
+
await askAndSet(orgId, unanswered[0], cat.byClass, { otherId: cat.otherId, declinedId: cat.declinedId });
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
catch {
|
|
643
|
+
// Never let this cost the bind. `retasc plan` asks the same question later.
|
|
644
|
+
}
|
|
645
|
+
}
|
|
607
646
|
console.log(`\n${NEXT_STEP}\n`);
|
|
608
647
|
if (isInteractive())
|
|
609
648
|
console.log(`${whatNow(pfx, emptyProject)}\n`);
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import { api } from "../api.js";
|
|
2
|
+
import { ask, isInteractive, select } from "../lib/prompt.js";
|
|
3
|
+
// Every value below came off the wire, and `cli/src/lib/text.ts` is the repo's rule
|
|
4
|
+
// for that: an agent name or a plan label carrying \x1b[ can move the cursor, clear
|
|
5
|
+
// the line and rewrite the rows above it. That matters most HERE, because this
|
|
6
|
+
// command prints a list and then asks a question the reader answers by number, and
|
|
7
|
+
// `mintKey` stores `agentName` and `runtime` verbatim — so any member of the org can
|
|
8
|
+
// choose what lands on an owner's terminal.
|
|
9
|
+
import { clean } from "../lib/text.js";
|
|
10
|
+
// Nothing about plans is compiled in here — not the list, and not the two terminal
|
|
11
|
+
// answers either. `cli/src/lib/outcome.ts` records why the CLI cannot import
|
|
12
|
+
// `convex/lib`, and a mirrored copy of the vocabulary would be a second thing to keep
|
|
13
|
+
// in step. So the ids come from the catalogue payload and the runtime class comes off
|
|
14
|
+
// the roster row, both resolved server-side.
|
|
15
|
+
/** What an agent nobody has answered for prints. Not a plan, so not from the server. */
|
|
16
|
+
const NOT_SET = "Not set";
|
|
17
|
+
// RTSC-901 — `retasc plan`: which plan pays for each of your agents.
|
|
18
|
+
//
|
|
19
|
+
// Retasc cannot see this. No hook payload, transcript field or telemetry attribute
|
|
20
|
+
// carries the plan or the usage gauge, and the only machine source is an undocumented
|
|
21
|
+
// per-account endpoint that needs the human's own credential. Retasc does not ask an
|
|
22
|
+
// agent to read a credential store, so the fact is asked for and a person answers it.
|
|
23
|
+
//
|
|
24
|
+
// The options are READ FROM THE SERVER on every run, never compiled in. That is the
|
|
25
|
+
// whole design: a tier a vendor shipped this morning is on this list this afternoon,
|
|
26
|
+
// on the build you already have.
|
|
27
|
+
/** The `retasc plan` list, and the picker when an agent is named. */
|
|
28
|
+
export async function planAction(opts) {
|
|
29
|
+
const me = await api.me();
|
|
30
|
+
let orgId = opts.orgId;
|
|
31
|
+
if (!orgId) {
|
|
32
|
+
if (me.orgs.length === 0)
|
|
33
|
+
throw new Error("You're not a member of any org yet.");
|
|
34
|
+
if (me.orgs.length > 1) {
|
|
35
|
+
const list = me.orgs.map((o) => ` ${o.id} ${o.name}${o.slug ? ` (${o.slug})` : ""}`);
|
|
36
|
+
throw new Error(`Several orgs — pass --org-id <id>:\n${list.join("\n")}`);
|
|
37
|
+
}
|
|
38
|
+
orgId = me.orgs[0].id;
|
|
39
|
+
}
|
|
40
|
+
const [roster, catalogue] = await Promise.all([
|
|
41
|
+
api.listOrgMembers({ orgId: orgId }),
|
|
42
|
+
api.listPlanCatalogue({ orgId: orgId }),
|
|
43
|
+
]);
|
|
44
|
+
// Yours first. An owner can set anyone's, but the question this command answers is
|
|
45
|
+
// almost always about the machine it is being run on.
|
|
46
|
+
const agents = roster
|
|
47
|
+
.filter((m) => m.isAgent)
|
|
48
|
+
.sort((a, b) => Number(b.mine) - Number(a.mine) || a.name.localeCompare(b.name));
|
|
49
|
+
if (agents.length === 0) {
|
|
50
|
+
console.log("No agents in this org yet. Run `retasc setup` to wire one up.");
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
if (opts.json) {
|
|
54
|
+
console.log(JSON.stringify(agents.flatMap((m) => classesOf(m).map((cls) => ({
|
|
55
|
+
id: m.id,
|
|
56
|
+
name: m.name,
|
|
57
|
+
runtime: m.runtime,
|
|
58
|
+
runtimeClass: cls,
|
|
59
|
+
plan: m.plansByClass?.[cls] ?? null,
|
|
60
|
+
canSet: m.canSetPlan,
|
|
61
|
+
}))), null, 2));
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
const chosen = opts.agent
|
|
65
|
+
? agents.find((m) => m.id === opts.agent || m.name.toLowerCase().includes(opts.agent.toLowerCase()))
|
|
66
|
+
: null;
|
|
67
|
+
if (opts.agent && !chosen) {
|
|
68
|
+
throw new Error(`No agent matching "${clean(opts.agent)}". Run \`retasc plan\` to see them.`);
|
|
69
|
+
}
|
|
70
|
+
if (!chosen) {
|
|
71
|
+
// ONE LINE PER (agent, harness), which is the unit an answer fills and the unit
|
|
72
|
+
// the Dash's Agents page draws. Listing one line per MEMBER read the plan for the
|
|
73
|
+
// runtime the member was BOUND as and nothing else, so an agent running both
|
|
74
|
+
// Claude Code and Codex showed one of them here and the other only in the Dash —
|
|
75
|
+
// and the terminal could not set the one it did not show.
|
|
76
|
+
console.log("");
|
|
77
|
+
for (const m of agents) {
|
|
78
|
+
for (const cls of classesOf(m)) {
|
|
79
|
+
const own = m.plansByClass?.[cls] ?? null;
|
|
80
|
+
const plan = own?.label ?? NOT_SET;
|
|
81
|
+
const tag = own?.inherited ? " (from your other agent)" : "";
|
|
82
|
+
// Inlined rather than hoisted to a local: `plan901.test.ts` reads THIS file and
|
|
83
|
+
// requires every wire value to be cleaned at its print site, and a local hides
|
|
84
|
+
// which wire value the interpolation is carrying.
|
|
85
|
+
console.log(` ${clean(m.name).padEnd(28)} ${clean(classLabel(m, cls)).padEnd(14)} ${clean(plan)}${tag}`);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
const unset = agents.reduce((n, m) => n + (m.canSetPlan ? classesOf(m).filter((c) => !m.plansByClass?.[c]).length : 0), 0);
|
|
89
|
+
console.log("");
|
|
90
|
+
if (unset > 0)
|
|
91
|
+
console.log(` ${unset} without a plan. Set one with \`retasc plan --agent <name>\`.`);
|
|
92
|
+
console.log(" You can also set these in the Dash under Agents.");
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
if (!chosen.canSetPlan) {
|
|
96
|
+
throw new Error(`"${clean(chosen.name)}" is not yours to set. An owner or admin can set anyone's.`);
|
|
97
|
+
}
|
|
98
|
+
const cat = catalogue;
|
|
99
|
+
// A member can host several harnesses, and they are separate accounts with separate
|
|
100
|
+
// plans. Picking the agent is therefore not enough to know what is being answered —
|
|
101
|
+
// so when there is more than one, that is its own question, asked first.
|
|
102
|
+
const classes = classesOf(chosen);
|
|
103
|
+
let cls = classes[0];
|
|
104
|
+
if (classes.length > 1) {
|
|
105
|
+
const picked = await select(`\n${clean(chosen.name)} runs ${classes.length} harnesses. Which one?`, classes.map((c) => ({
|
|
106
|
+
id: c,
|
|
107
|
+
label: clean(classLabel(chosen, c)),
|
|
108
|
+
note: chosen.plansByClass?.[c] ? `Currently ${clean(chosen.plansByClass[c].label)}` : "No plan recorded",
|
|
109
|
+
})));
|
|
110
|
+
if (picked === null)
|
|
111
|
+
return;
|
|
112
|
+
cls = picked;
|
|
113
|
+
}
|
|
114
|
+
await askAndSet(orgId, {
|
|
115
|
+
...chosen,
|
|
116
|
+
// The catalogue to offer. `unknown` is the bucket for a harness we cannot place,
|
|
117
|
+
// and it has no catalogue — so the picker is `other` and `declined`, which is
|
|
118
|
+
// the same answer the server gives for a null class.
|
|
119
|
+
planClass: cls === "unknown" ? null : cls,
|
|
120
|
+
runtimeName: classLabel(chosen, cls),
|
|
121
|
+
// Send the WIRE runtime for the member's own harness and the CLASS for a
|
|
122
|
+
// sibling. Both file under the same key, but the wire value is also recorded on
|
|
123
|
+
// an `other` answer as the restock signal's record of which harness said it, and
|
|
124
|
+
// "codex-mcp-client" teaches more there than "codex". For a sibling harness the
|
|
125
|
+
// roster does not tell us the wire value, so the class is the honest best.
|
|
126
|
+
runtime: cls === chosen.planBucket ? chosen.runtime : cls,
|
|
127
|
+
}, cat.byClass, { otherId: cat.otherId, declinedId: cat.declinedId });
|
|
128
|
+
}
|
|
129
|
+
/** The harness classes this agent should be answered for. `planClasses` is the
|
|
130
|
+
* server's set, built from the member's own session keys; the bound runtime is the
|
|
131
|
+
* fallback for a Dash loaded against an older backend. */
|
|
132
|
+
function classesOf(m) {
|
|
133
|
+
const list = m.planClasses ?? [];
|
|
134
|
+
return list.length > 0 ? list : [m.planBucket];
|
|
135
|
+
}
|
|
136
|
+
/** What to call one of an agent's harnesses. The member's own runtime keeps its full
|
|
137
|
+
* name ("Claude Code"); a sibling harness is named by its class, which is all the
|
|
138
|
+
* roster knows about it. */
|
|
139
|
+
function classLabel(m, cls) {
|
|
140
|
+
if (cls === m.planBucket && m.runtimeName)
|
|
141
|
+
return m.runtimeName;
|
|
142
|
+
return CLASS_NAMES[cls] ?? cls;
|
|
143
|
+
}
|
|
144
|
+
/** Class -> display name. Small and local: these are the eight buckets in
|
|
145
|
+
* `convex/lib/runtime.ts`, and the CLI cannot import it (see `lib/outcome.ts`). A
|
|
146
|
+
* class not here prints as itself, which is the same rule `runtimeLabel` follows. */
|
|
147
|
+
const CLASS_NAMES = {
|
|
148
|
+
claude: "Claude Code",
|
|
149
|
+
codex: "Codex",
|
|
150
|
+
opencode: "OpenCode",
|
|
151
|
+
gemini: "Gemini",
|
|
152
|
+
cursor: "Cursor",
|
|
153
|
+
grok: "Grok",
|
|
154
|
+
ci: "CI",
|
|
155
|
+
unknown: "Unreported",
|
|
156
|
+
};
|
|
157
|
+
/**
|
|
158
|
+
* Put the list to the human and record what they pick.
|
|
159
|
+
*
|
|
160
|
+
* Exported so `retasc setup` asks the identical question through the identical code.
|
|
161
|
+
* Two copies of a question drift into two questions.
|
|
162
|
+
*/
|
|
163
|
+
export async function askAndSet(orgId, agent, byClass,
|
|
164
|
+
/** `otherId` / `declinedId`, straight from the catalogue payload. */
|
|
165
|
+
ids) {
|
|
166
|
+
if (!isInteractive())
|
|
167
|
+
return false;
|
|
168
|
+
const options = (agent.planClass && byClass[agent.planClass]) || [];
|
|
169
|
+
const name = clean(agent.runtimeName || "this runtime");
|
|
170
|
+
const who = clean(agent.name);
|
|
171
|
+
const choices = [
|
|
172
|
+
// The catalogue is ours, but it is still served: clean it like anything else.
|
|
173
|
+
...options.map((o) => ({ id: o.planId, label: clean(o.label), note: o.note ? clean(o.note) : null })),
|
|
174
|
+
// Always last, always present. The catalogue is knowingly incomplete, so there has
|
|
175
|
+
// to be somewhere for the plan we have not heard of to go — and on a runtime we do
|
|
176
|
+
// not know at all, these two ARE the list.
|
|
177
|
+
{ id: ids.otherId, label: "Something else", note: "A plan that is not listed" },
|
|
178
|
+
{ id: ids.declinedId, label: "Not shared", note: "Nothing is recorded, and you will not be asked again" },
|
|
179
|
+
];
|
|
180
|
+
const heading = options.length > 0
|
|
181
|
+
? `\nWhich plan pays for ${who}'s ${name} usage?`
|
|
182
|
+
: `\nRetasc has no plan list for ${name} yet. Which plan pays for ${who}?`;
|
|
183
|
+
const picked = await select(heading, choices);
|
|
184
|
+
if (picked === null)
|
|
185
|
+
return false;
|
|
186
|
+
let label;
|
|
187
|
+
if (picked === ids.otherId) {
|
|
188
|
+
label = await ask("Plan name, as it is billed: ");
|
|
189
|
+
if (!label.trim()) {
|
|
190
|
+
console.log("Nothing typed, so nothing recorded. Run `retasc plan` when you know it.");
|
|
191
|
+
return false;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
// `agent.runtime` is what the CALLER decided this answer is about: the member's wire
|
|
195
|
+
// runtime for its own harness, the class for a sibling. It used to be the member's
|
|
196
|
+
// bound runtime unconditionally, so choosing the Codex line wrote the Claude key —
|
|
197
|
+
// the answer vanished from the row that asked for it and appeared on one that had
|
|
198
|
+
// not. `planStorageClassOf` maps a class to itself, so both spellings file alike.
|
|
199
|
+
const res = await api.setMemberPlan({ orgId, memberId: agent.id, runtime: agent.runtime ?? "", planId: picked, label });
|
|
200
|
+
console.log(`\n ${who}: ${clean(res.label)}`);
|
|
201
|
+
// Both doors, every time. A person told once where to change something will not
|
|
202
|
+
// remember; a person told at the moment they answer knows they are not locked in.
|
|
203
|
+
console.log(" Change it any time with `retasc plan`, or in the Dash under Agents.\n");
|
|
204
|
+
return true;
|
|
205
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -231,7 +231,16 @@ program
|
|
|
231
231
|
// at exit 0 instead of thrown. Structured output an agent cannot act on, or a door
|
|
232
232
|
// that leads to a thrown Error, is each worse than neither.
|
|
233
233
|
.option("--json", "Report machine-readable outcomes (for an agent driving setup)")
|
|
234
|
-
.action(async (opts) => {
|
|
234
|
+
.action(async (opts, cmd) => {
|
|
235
|
+
// RTSC-901 — was the runtime STATED, or is it the default sitting there?
|
|
236
|
+
//
|
|
237
|
+
// `--runtime` labels the agent row and defaults to claude-code, which is right for
|
|
238
|
+
// the label: most binds are Claude Code and a row needs a name. It is NOT right as
|
|
239
|
+
// the subject of the plan question. `retasc setup` wires every harness on the
|
|
240
|
+
// machine, so the folder rarely has one provider, and asking "which Claude plan?"
|
|
241
|
+
// of someone setting up Codex records a fact about the wrong product. When the
|
|
242
|
+
// flag was not stated, the plan question is left to the handshake, which knows.
|
|
243
|
+
opts.runtimeStated = cmd.getOptionValueSource("runtime") === "cli";
|
|
235
244
|
// BEFORE requireLogin: the whole point is a machine that has never signed in. The
|
|
236
245
|
// token is the authorization, and asking for a session here would refuse every
|
|
237
246
|
// caller this flag exists for.
|
|
@@ -596,6 +605,19 @@ program
|
|
|
596
605
|
// RTSC-780 — the once-per-machine half of setup. `bind` runs this itself the first
|
|
597
606
|
// time, so most people never type it; it exists on its own for the second harness
|
|
598
607
|
// installed after a bind, and for re-running after an upgrade moved the launcher.
|
|
608
|
+
// RTSC-901 — which plan pays for each agent. Asked once at setup and changeable here
|
|
609
|
+
// forever after, which is the other half of the promise the setup receipt makes.
|
|
610
|
+
program
|
|
611
|
+
.command("plan")
|
|
612
|
+
.description("Show or set which plan pays for each of your agents.")
|
|
613
|
+
.option("--org-id <id>", "Org, when you're in more than one")
|
|
614
|
+
.option("--agent <nameOrId>", "Set the plan for one agent")
|
|
615
|
+
.option("--json", "Machine-readable")
|
|
616
|
+
.allowExcessArguments(false)
|
|
617
|
+
.action(async (opts) => {
|
|
618
|
+
const { planAction } = await import("./commands/plan.js");
|
|
619
|
+
await planAction(opts);
|
|
620
|
+
});
|
|
599
621
|
program
|
|
600
622
|
.command("setup")
|
|
601
623
|
.description("Detect the MCP harnesses on this machine and wire Retasc into each, once.")
|
package/dist/lib/prompt.js
CHANGED
|
@@ -36,3 +36,43 @@ export async function confirm(question, assumeYes) {
|
|
|
36
36
|
const a = (await ask(`${question} [y/N] `)).toLowerCase();
|
|
37
37
|
return a === "y" || a === "yes";
|
|
38
38
|
}
|
|
39
|
+
/**
|
|
40
|
+
* Ask a human to PICK, by number, never to type a name (RTSC-901).
|
|
41
|
+
*
|
|
42
|
+
* Typing is how a plan called "Max 20x" gets recorded as "max20", "Max20x" and
|
|
43
|
+
* "MAX 20X" across three machines and then fails to group. Numbers make the common
|
|
44
|
+
* answer mechanical and leave typing for the case that genuinely needs it, which is
|
|
45
|
+
* an option this list does not have.
|
|
46
|
+
*
|
|
47
|
+
* Returns the chosen `id`, or `null` when there is nobody to ask: a non-interactive
|
|
48
|
+
* run SKIPS the question rather than blocking a script or guessing an answer. A
|
|
49
|
+
* question nobody answered has to stay unanswered — that is the difference between
|
|
50
|
+
* "not asked" and a wrong fact recorded forever.
|
|
51
|
+
*
|
|
52
|
+
* Enter alone takes `defaultId` when one is given, so the common path is one keypress.
|
|
53
|
+
*/
|
|
54
|
+
export async function select(question, choices, defaultId) {
|
|
55
|
+
if (!isInteractive() || choices.length === 0)
|
|
56
|
+
return null;
|
|
57
|
+
stdout.write(`${question}\n`);
|
|
58
|
+
for (let i = 0; i < choices.length; i++) {
|
|
59
|
+
const c = choices[i];
|
|
60
|
+
const mark = c.id === defaultId ? " (default)" : "";
|
|
61
|
+
stdout.write(` ${String(i + 1).padStart(2)}. ${c.label}${mark}\n`);
|
|
62
|
+
if (c.note)
|
|
63
|
+
stdout.write(` ${c.note}\n`);
|
|
64
|
+
}
|
|
65
|
+
// Three tries, then give up rather than loop at someone. A person who has typed
|
|
66
|
+
// nonsense three times is not going to type a number on the fourth, and a CLI that
|
|
67
|
+
// will not let go of the terminal is worse than an unanswered question.
|
|
68
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
69
|
+
const raw = await ask(defaultId ? `Number [${choices.findIndex((c) => c.id === defaultId) + 1}]: ` : "Number: ");
|
|
70
|
+
if (!raw && defaultId)
|
|
71
|
+
return defaultId;
|
|
72
|
+
const n = Number(raw);
|
|
73
|
+
if (Number.isInteger(n) && n >= 1 && n <= choices.length)
|
|
74
|
+
return choices[n - 1].id;
|
|
75
|
+
stdout.write(` Pick a number between 1 and ${choices.length}.\n`);
|
|
76
|
+
}
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
// RTSC-962 — the worktree census, as a PURE decision.
|
|
2
|
+
//
|
|
3
|
+
// The proxy watches for git worktrees on `rtsc-NN/<slug>` branches that no claim this
|
|
4
|
+
// session took covers, and reports them so the server can record a provisional hold.
|
|
5
|
+
// That closes the one transition Retasc could never see: an agent starting work. (The
|
|
6
|
+
// end of work is fenced — `done` rejects on CLAIM_MISMATCH — and the start was not, so
|
|
7
|
+
// an agent could build ~2,000 lines against an issue the queue showed as free. RTSC-910.)
|
|
8
|
+
//
|
|
9
|
+
// Everything decidable without I/O lives here, so every branch is unit-testable: which
|
|
10
|
+
// trees count, which are ignored, and what the payload looks like. The proxy supplies
|
|
11
|
+
// the three facts only the filesystem knows (the porcelain output, whether a tree is
|
|
12
|
+
// dirty, its last commit time) and does nothing else.
|
|
13
|
+
//
|
|
14
|
+
// REPORT-ONLY, in both directions. Nothing here claims, blocks, or rewrites an agent's
|
|
15
|
+
// request; and a report that fails is swallowed by the caller, never surfaced as a
|
|
16
|
+
// failure of the tool call it rode alongside.
|
|
17
|
+
import { issueIdFromBranch, parseWorktreePorcelain } from "./tidy.js";
|
|
18
|
+
/**
|
|
19
|
+
* How recently a worktree must have been touched to count as ACTIVE.
|
|
20
|
+
*
|
|
21
|
+
* This is what stops an abandoned worktree parking an issue. A tree nobody has
|
|
22
|
+
* committed in for a day and that has no uncommitted work in it is finished business —
|
|
23
|
+
* somebody forgot to run `retasc tidy` — and reporting it would withhold an issue from
|
|
24
|
+
* dispatch on the strength of a directory. A day is deliberately generous: a real agent
|
|
25
|
+
* working across a weekend has uncommitted changes, which satisfies the other arm.
|
|
26
|
+
*/
|
|
27
|
+
export const ACTIVE_WINDOW_MS = 24 * 60 * 60 * 1000;
|
|
28
|
+
/**
|
|
29
|
+
* Is this tree worth reporting? Dirty OR committed within the window.
|
|
30
|
+
*
|
|
31
|
+
* The two arms cover the two shapes real in-flight work takes: edits not yet committed
|
|
32
|
+
* (the RTSC-910 case, for most of its life) and commits not yet merged.
|
|
33
|
+
*/
|
|
34
|
+
export function isActive(state, now) {
|
|
35
|
+
if (state.dirty)
|
|
36
|
+
return true;
|
|
37
|
+
return state.lastCommitAt !== undefined && now - state.lastCommitAt < ACTIVE_WINDOW_MS;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* The `rtsc-NN/<slug>` worktrees in this porcelain output, as {path, branch} pairs.
|
|
41
|
+
*
|
|
42
|
+
* Detached trees are dropped (`branch` is null — there is no issue to name) and so is
|
|
43
|
+
* any branch off the convention. The prefix match is `issueIdFromBranch`, the SAME
|
|
44
|
+
* parser `retasc tidy` uses to decide what it may delete and the mirror of the server's
|
|
45
|
+
* `branchForIssue` — so a branch this reports a hold for is exactly a branch a claim
|
|
46
|
+
* would have handed out.
|
|
47
|
+
*
|
|
48
|
+
* The MAIN checkout is included when it is itself on such a branch: an agent working in
|
|
49
|
+
* the shared checkout (the thing the claim contract tells it not to do) still leaves a
|
|
50
|
+
* branch to find, and that is the case most worth catching.
|
|
51
|
+
*/
|
|
52
|
+
export function candidateTrees(porcelain) {
|
|
53
|
+
const out = [];
|
|
54
|
+
for (const w of parseWorktreePorcelain(porcelain)) {
|
|
55
|
+
if (!w.branch)
|
|
56
|
+
continue; // detached, or bare
|
|
57
|
+
const identifier = issueIdFromBranch(w.branch);
|
|
58
|
+
if (!identifier)
|
|
59
|
+
continue;
|
|
60
|
+
out.push({ path: w.path, branch: w.branch, identifier });
|
|
61
|
+
}
|
|
62
|
+
return out;
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* The payload to send, from the candidate trees plus what git said about each.
|
|
66
|
+
*
|
|
67
|
+
* Two exclusions, both deliberate:
|
|
68
|
+
* - INACTIVE trees (see `isActive`) — an abandoned directory must not park an issue.
|
|
69
|
+
* - Issues THIS session already holds a lease on. It claimed them; the server knows;
|
|
70
|
+
* a hold would be a worse copy of a fact it already has, and `report_worktrees`
|
|
71
|
+
* would ignore it anyway. Skipping here keeps the payload honest rather than
|
|
72
|
+
* relying on the server to discard most of it.
|
|
73
|
+
*
|
|
74
|
+
* Returns [] when there is nothing to say, which the caller uses to skip the call
|
|
75
|
+
* entirely — a proxy in a repo with no agent worktrees makes no requests at all.
|
|
76
|
+
*/
|
|
77
|
+
export function buildReport(opts) {
|
|
78
|
+
const heldSet = new Set(opts.held);
|
|
79
|
+
const reports = [];
|
|
80
|
+
const seen = new Set();
|
|
81
|
+
for (const t of opts.trees) {
|
|
82
|
+
if (heldSet.has(t.identifier))
|
|
83
|
+
continue; // we claimed it — nothing to report
|
|
84
|
+
if (seen.has(t.identifier))
|
|
85
|
+
continue; // one hold per issue, whatever the tree count
|
|
86
|
+
const st = opts.state.get(t.path);
|
|
87
|
+
if (!st)
|
|
88
|
+
continue; // git couldn't read the tree — say nothing rather than guess
|
|
89
|
+
if (!isActive(st, opts.now))
|
|
90
|
+
continue;
|
|
91
|
+
seen.add(t.identifier);
|
|
92
|
+
reports.push({
|
|
93
|
+
identifier: t.identifier,
|
|
94
|
+
branch: t.branch,
|
|
95
|
+
dirty: st.dirty,
|
|
96
|
+
...(st.lastCommitAt !== undefined ? { lastCommitAt: st.lastCommitAt } : {}),
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
return reports;
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Which holds are worth saying out loud right now, and the bookkeeping that keeps it to
|
|
103
|
+
* ONCE PER HOLD — mutating `announced` in place.
|
|
104
|
+
*
|
|
105
|
+
* Two rules, and the first one is the whole point:
|
|
106
|
+
*
|
|
107
|
+
* - ANNOUNCE EACH HOLD ONCE. The instinct is to repeat the line until the agent acts,
|
|
108
|
+
* and it is wrong: it is the failure the server-side riders are written against
|
|
109
|
+
* (RTSC-463 — "a paragraph repeated on every claim becomes wallpaper"). A hold stands
|
|
110
|
+
* for an hour, in which an agent can make a hundred tool calls. A line on all of them
|
|
111
|
+
* is not obeyed more, it is skipped — and it teaches the agent to skip the next one.
|
|
112
|
+
* The case that actually matters, a reader about to act on the issue, is caught again
|
|
113
|
+
* server-side by the `get_issue` rider, which has the context to say something useful.
|
|
114
|
+
* - FORGET a hold that has gone (claimed, closed, lapsed), so a genuinely new sighting
|
|
115
|
+
* of the same issue later speaks up again.
|
|
116
|
+
*
|
|
117
|
+
* Pure but for `announced`, which is the caller's own set — so the ordering rule that
|
|
118
|
+
* matters (never mark a hold announced unless the line actually reached the response)
|
|
119
|
+
* stays enforceable by the caller, and testable here.
|
|
120
|
+
*/
|
|
121
|
+
export function holdsToAnnounce(active, announced, held = []) {
|
|
122
|
+
for (const id of [...announced])
|
|
123
|
+
if (!active.has(id))
|
|
124
|
+
announced.delete(id);
|
|
125
|
+
// Never announce a hold for an issue this session now HOLDS. The scan that confirmed
|
|
126
|
+
// the hold runs before the claim, so without this the very response to
|
|
127
|
+
// `claim_issue RTSC-N` carries "nobody holds RTSC-N, run `claim_issue RTSC-N`" — a
|
|
128
|
+
// line that contradicts the result it is stapled to, and which burns the single
|
|
129
|
+
// announcement this hold ever gets.
|
|
130
|
+
const heldSet = new Set(held);
|
|
131
|
+
return [...active.keys()].filter((id) => !announced.has(id) && !heldSet.has(id));
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* The line appended to the agent's own response when the server confirms a hold this
|
|
135
|
+
* proxy reported (RTSC-962).
|
|
136
|
+
*
|
|
137
|
+
* It rides the RESPONSE rather than a tool description for the reason RTSC-463 gives: a
|
|
138
|
+
* description is fetched once at `tools/list`, so it never reaches a session already
|
|
139
|
+
* running, and this has to land adjacent to the moment it is about.
|
|
140
|
+
*
|
|
141
|
+
* SHORT, and it names the branch, because the reader's next action is to look at that
|
|
142
|
+
* branch. Fires only while the hold stands — it clears the moment somebody claims.
|
|
143
|
+
*/
|
|
144
|
+
export function unclaimedNotice(holds, byIssue) {
|
|
145
|
+
if (holds.length === 0)
|
|
146
|
+
return "";
|
|
147
|
+
const parts = holds.map((id) => `\`${byIssue.get(id) ?? id}\` (${id})`);
|
|
148
|
+
const which = parts.length === 1 ? parts[0] : parts.join(", ");
|
|
149
|
+
const verb = parts.length === 1 ? "exists" : "exist";
|
|
150
|
+
const ids = holds.join(", ");
|
|
151
|
+
return (`⚑ Unclaimed work: a worktree on ${which} ${verb} on this machine and nobody holds ` +
|
|
152
|
+
`${ids}. If it is yours, run \`claim_issue ${holds[0]}\` now; if not, leave it — do not ` +
|
|
153
|
+
`start a second branch for it.`);
|
|
154
|
+
}
|
|
155
|
+
/** The JSON-RPC `report_worktrees` call the proxy sends out-of-band. */
|
|
156
|
+
export function reportRequest(rpcId, worktrees) {
|
|
157
|
+
return {
|
|
158
|
+
jsonrpc: "2.0",
|
|
159
|
+
id: rpcId,
|
|
160
|
+
method: "tools/call",
|
|
161
|
+
params: { name: "report_worktrees", arguments: { worktrees } },
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
/** The issue ids the server confirmed it holds, from a `report_worktrees` result. */
|
|
165
|
+
export function holdsFrom(result) {
|
|
166
|
+
if (!result || typeof result !== "object")
|
|
167
|
+
return [];
|
|
168
|
+
const holds = result.holds;
|
|
169
|
+
if (!Array.isArray(holds))
|
|
170
|
+
return [];
|
|
171
|
+
return holds.filter((h) => typeof h === "string");
|
|
172
|
+
}
|
package/dist/proxy.js
CHANGED
|
@@ -17,6 +17,7 @@ import { mintSessionKey, appendFallbackNotice, recordSession, nameWorkspace, RPC
|
|
|
17
17
|
import { readHookRecord, clearHookRecord, modelFromTranscript } from "./lib/sessionHook.js";
|
|
18
18
|
import { VERSION } from "./version.js";
|
|
19
19
|
import { attachRoot, isLocalAttachCall, mergeAttachTool, readAttachFile, resolveAttachPath, uploadFailureMessage, uploadUrlWith, } from "./lib/attachFile.js";
|
|
20
|
+
import { buildReport, candidateTrees, holdsFrom, holdsToAnnounce, reportRequest, unclaimedNotice, } from "./lib/worktreeReport.js";
|
|
20
21
|
import { MAX_FETCH_BYTES, downloadFailureMessage, existingDownload, isLocalFetchCall, mergeFetchTool, resolveDownloadTarget, writeDownloadedFile, } from "./lib/fetchFile.js";
|
|
21
22
|
// RTSC-92/98: resolve the workspace key via the SHARED resolver, so the proxy and
|
|
22
23
|
// the direct commands (claim/tidy/done) can never diverge. The proxy carries its
|
|
@@ -42,6 +43,74 @@ const UNBOUND_MESSAGE = `This folder (${UNBOUND ?? process.cwd()}) is not bound
|
|
|
42
43
|
const HEARTBEAT_MS = Number(process.env.RETASC_HEARTBEAT_MS) || 10 * 60 * 1000;
|
|
43
44
|
const leases = new Map();
|
|
44
45
|
let hbSeq = -1; // out-of-band heartbeat ids are negative — never collide with the harness's
|
|
46
|
+
// RTSC-962 — the worktree census. The server cannot read git and never will; this
|
|
47
|
+
// process can, and a `rtsc-NN/<slug>` worktree is an agent saying in the filesystem
|
|
48
|
+
// "I am working on RTSC-NN". Reporting it lets the queue SEE work somebody started
|
|
49
|
+
// without claiming, which is the one transition Retasc had no gate for.
|
|
50
|
+
//
|
|
51
|
+
// Throttled: `git worktree list` is milliseconds, but a `git status` per tree is not,
|
|
52
|
+
// on a large repo — and this is triggered by the agent's tool calls, which can arrive
|
|
53
|
+
// several a second.
|
|
54
|
+
const WORKTREE_SCAN_MS = Number(process.env.RETASC_WORKTREE_SCAN_MS) || 30_000;
|
|
55
|
+
let lastWorktreeScan = 0;
|
|
56
|
+
// Issue → branch for the holds the server last confirmed, so the notice can name the
|
|
57
|
+
// branch. Rebuilt from each scan: a hold that has been claimed (or has lapsed) drops
|
|
58
|
+
// off the server's list and must stop being announced.
|
|
59
|
+
let activeHolds = new Map();
|
|
60
|
+
// Holds already announced to the agent. ONCE PER HOLD, not once per call.
|
|
61
|
+
//
|
|
62
|
+
// The temptation is to repeat the line until the agent acts, and it is the wrong
|
|
63
|
+
// instinct — it is the exact failure the server-side riders are written against
|
|
64
|
+
// (RTSC-463: "a paragraph repeated on every claim becomes wallpaper"). A hold stands for
|
|
65
|
+
// an hour, during which an agent can easily make a hundred tool calls; a line on every
|
|
66
|
+
// one of them does not get more obeyed, it gets skipped, and it teaches the agent to
|
|
67
|
+
// skip the NEXT one too. Saying it once is what keeps it readable — and the case that
|
|
68
|
+
// actually matters, a reader about to act on the issue, is caught again server-side by
|
|
69
|
+
// the `get_issue` rider, which has the full context to say something useful.
|
|
70
|
+
//
|
|
71
|
+
// Cleared when a hold goes away, so a genuinely new sighting of the same issue later
|
|
72
|
+
// speaks up again.
|
|
73
|
+
const announcedHolds = new Set();
|
|
74
|
+
// One scan at a time. The throttle alone is not enough: a scan that outruns the window
|
|
75
|
+
// (a slow disk, a huge repo) would otherwise overlap with the next one and multiply the
|
|
76
|
+
// git processes it was meant to bound.
|
|
77
|
+
let scanning = false;
|
|
78
|
+
/**
|
|
79
|
+
* One git command, ASYNC, returning stdout — or null if git failed or could not be run.
|
|
80
|
+
*
|
|
81
|
+
* Deliberately not `spawnSync`, which the rest of this file uses for its one-shot
|
|
82
|
+
* startup probe. This runs every 30 seconds for the life of the session, and the proxy
|
|
83
|
+
* is a stdio relay: a synchronous child blocks the event loop, so every message in
|
|
84
|
+
* flight — the agent's own tool calls included — waits for git. Never rejects; a census
|
|
85
|
+
* that cannot read the disk says nothing rather than failing anything.
|
|
86
|
+
*/
|
|
87
|
+
function git(args, cwd) {
|
|
88
|
+
return new Promise((resolveOut) => {
|
|
89
|
+
let out = "";
|
|
90
|
+
let settled = false;
|
|
91
|
+
const done = (v) => {
|
|
92
|
+
if (settled)
|
|
93
|
+
return;
|
|
94
|
+
settled = true;
|
|
95
|
+
clearTimeout(timer);
|
|
96
|
+
resolveOut(v);
|
|
97
|
+
};
|
|
98
|
+
const child = spawn("git", args, { cwd, stdio: ["ignore", "pipe", "ignore"] });
|
|
99
|
+
// A git that hangs (a credential prompt, a network remote, a stuck lock) must not
|
|
100
|
+
// wedge the census — same reasoning as REAP_TIMEOUT_MS, shorter because these are
|
|
101
|
+
// local, read-only commands that should take milliseconds.
|
|
102
|
+
const timer = setTimeout(() => {
|
|
103
|
+
child.kill("SIGKILL");
|
|
104
|
+
done(null);
|
|
105
|
+
}, 5_000);
|
|
106
|
+
timer.unref?.();
|
|
107
|
+
child.stdout?.on("data", (d) => {
|
|
108
|
+
out += String(d);
|
|
109
|
+
});
|
|
110
|
+
child.on("error", () => done(null));
|
|
111
|
+
child.on("close", (code) => done(code === 0 ? out : null));
|
|
112
|
+
});
|
|
113
|
+
}
|
|
45
114
|
// RTSC-646: the credential-death warning fires ONCE with its full explanation. A
|
|
46
115
|
// revoked/rotated key fails every heartbeat of every lease, so repeating the whole
|
|
47
116
|
// paragraph on each tick (every 10 min, per lease) would bury the first one.
|
|
@@ -716,6 +785,24 @@ async function handleLine(line) {
|
|
|
716
785
|
log(`warning: ${warning}`);
|
|
717
786
|
applyObservation(leases, obs);
|
|
718
787
|
appendFallbackNotice(msg.params?.name, resp, sessionKeyFallback);
|
|
788
|
+
// RTSC-962 — attach what the LAST scan found, then start the next one WITHOUT
|
|
789
|
+
// awaiting it.
|
|
790
|
+
//
|
|
791
|
+
// The order matters and it is the same rule RTSC-181 states for the reap: git must
|
|
792
|
+
// never delay the response the agent is blocked on. Awaiting the scan would put two
|
|
793
|
+
// `git` invocations per worktree AND a full network round-trip on the critical path
|
|
794
|
+
// of one tool call in every thirty seconds — hundreds of milliseconds on a large
|
|
795
|
+
// repo, paid by the agent, forever. So the notice is attached from the previous
|
|
796
|
+
// scan's result and the fresh scan runs in the background.
|
|
797
|
+
//
|
|
798
|
+
// The cost of that is one call of latency on FIRST detection — a few seconds — and
|
|
799
|
+
// it buys a proxy that is never in the way. A hold lasts an hour; being told about
|
|
800
|
+
// it one call later changes nothing.
|
|
801
|
+
//
|
|
802
|
+
// `scanWorktrees` is started after `applyObservation` so a claim made by this very
|
|
803
|
+
// call is already in `leases` and its own worktree is not reported as unclaimed.
|
|
804
|
+
appendUnclaimedNotice(resp);
|
|
805
|
+
void scanWorktrees().catch((e) => log(`worktree scan: ${String(e?.message ?? e)}`));
|
|
719
806
|
}
|
|
720
807
|
// Relay the response (requests have an id; notifications don't).
|
|
721
808
|
if (resp != null && msg.id !== undefined) {
|
|
@@ -726,6 +813,109 @@ async function handleLine(line) {
|
|
|
726
813
|
if (reapId)
|
|
727
814
|
reapClosedIssue(reapId);
|
|
728
815
|
}
|
|
816
|
+
/**
|
|
817
|
+
* RTSC-962 — look for unclaimed `rtsc-NN/<slug>` worktrees on this machine and report
|
|
818
|
+
* them, so the server can record a provisional hold.
|
|
819
|
+
*
|
|
820
|
+
* Every decision here is in `lib/worktreeReport.ts` and unit-tested; this function is
|
|
821
|
+
* only the git I/O and the network call. It is a NO-OP outside a git repo, and it never
|
|
822
|
+
* throws: a census that fails must not fail — or even alter — the agent's tool call.
|
|
823
|
+
*
|
|
824
|
+
* Report-only. It never claims (client-side lease bookkeeping would lock the real worker
|
|
825
|
+
* out of `checkpoint`/`done`), never blocks, and never rewrites the request.
|
|
826
|
+
*/
|
|
827
|
+
async function scanWorktrees() {
|
|
828
|
+
if (!MAIN_CHECKOUT)
|
|
829
|
+
return; // not in a git repo — nothing local to see
|
|
830
|
+
if (scanning)
|
|
831
|
+
return; // a scan is already in flight — never pile them up
|
|
832
|
+
const now = Date.now();
|
|
833
|
+
if (now - lastWorktreeScan < WORKTREE_SCAN_MS)
|
|
834
|
+
return;
|
|
835
|
+
lastWorktreeScan = now;
|
|
836
|
+
scanning = true;
|
|
837
|
+
try {
|
|
838
|
+
await runScan(now);
|
|
839
|
+
}
|
|
840
|
+
finally {
|
|
841
|
+
scanning = false;
|
|
842
|
+
}
|
|
843
|
+
}
|
|
844
|
+
async function runScan(now) {
|
|
845
|
+
const list = await git(["worktree", "list", "--porcelain"], MAIN_CHECKOUT);
|
|
846
|
+
if (list === null)
|
|
847
|
+
return;
|
|
848
|
+
const trees = candidateTrees(list);
|
|
849
|
+
if (trees.length === 0) {
|
|
850
|
+
activeHolds = new Map();
|
|
851
|
+
return;
|
|
852
|
+
}
|
|
853
|
+
// One `status` + one `log` per candidate. Bounded by the number of agent worktrees on
|
|
854
|
+
// the machine (a handful), and only for branches that already match the convention —
|
|
855
|
+
// never a walk of the repo.
|
|
856
|
+
const state = new Map();
|
|
857
|
+
for (const t of trees) {
|
|
858
|
+
const st = await git(["status", "--porcelain"], t.path);
|
|
859
|
+
if (st === null)
|
|
860
|
+
continue; // the tree is gone or unreadable — say nothing
|
|
861
|
+
const log = await git(["log", "-1", "--format=%ct"], t.path);
|
|
862
|
+
const secs = log === null ? NaN : Number(log.trim());
|
|
863
|
+
state.set(t.path, {
|
|
864
|
+
dirty: st.trim().length > 0,
|
|
865
|
+
...(Number.isFinite(secs) && secs > 0 ? { lastCommitAt: secs * 1000 } : {}),
|
|
866
|
+
});
|
|
867
|
+
}
|
|
868
|
+
const worktrees = buildReport({ trees, state, held: leases.keys(), now });
|
|
869
|
+
if (worktrees.length === 0) {
|
|
870
|
+
activeHolds = new Map();
|
|
871
|
+
return;
|
|
872
|
+
}
|
|
873
|
+
try {
|
|
874
|
+
const result = toolResult(await postRemote(reportRequest(hbSeq--, worktrees)), "report_worktrees");
|
|
875
|
+
const holds = holdsFrom(result);
|
|
876
|
+
// Rebuilt from THIS scan, never merged into the last one: an issue that dropped off
|
|
877
|
+
// the server's list was claimed, closed, or lapsed, and must stop being announced.
|
|
878
|
+
const byIssue = new Map(worktrees.map((w) => [w.identifier, w.branch]));
|
|
879
|
+
activeHolds = new Map(holds.map((id) => [id, byIssue.get(id) ?? id]));
|
|
880
|
+
}
|
|
881
|
+
catch (e) {
|
|
882
|
+
// An older deployment has no such tool, and a network blip is a network blip.
|
|
883
|
+
// Either way the agent's call is unaffected — that is the whole contract.
|
|
884
|
+
log(`report_worktrees: ${String(e?.message ?? e)}`);
|
|
885
|
+
}
|
|
886
|
+
}
|
|
887
|
+
/**
|
|
888
|
+
* RTSC-962 — attach the ⚑ line to the response the agent is about to read, in its OWN
|
|
889
|
+
* content block.
|
|
890
|
+
*
|
|
891
|
+
* Same mechanics and the same reason as `appendFallbackNotice`: content[0].text must
|
|
892
|
+
* stay machine-parseable JSON (RTSC-142), and anything unexpected about the shape leaves
|
|
893
|
+
* the response untouched — a malformed notice must never break the protocol stream it
|
|
894
|
+
* rides on. Silent on an error response: an agent reading a refusal has a more urgent
|
|
895
|
+
* problem than an unclaimed worktree.
|
|
896
|
+
*/
|
|
897
|
+
function appendUnclaimedNotice(resp) {
|
|
898
|
+
// Prunes holds that have gone AND returns only the ones not yet announced. Run
|
|
899
|
+
// unconditionally (not behind an `activeHolds.size` guard) so the pruning happens even
|
|
900
|
+
// when the last scan came back empty — otherwise a hold that lapsed and was later seen
|
|
901
|
+
// again would stay marked as announced and never be mentioned a second time.
|
|
902
|
+
const fresh = holdsToAnnounce(activeHolds, announcedHolds, leases.keys());
|
|
903
|
+
if (fresh.length === 0)
|
|
904
|
+
return;
|
|
905
|
+
const result = resp?.result;
|
|
906
|
+
if (result?.isError)
|
|
907
|
+
return;
|
|
908
|
+
if (!Array.isArray(result?.content))
|
|
909
|
+
return;
|
|
910
|
+
const text = unclaimedNotice(fresh, activeHolds);
|
|
911
|
+
if (!text)
|
|
912
|
+
return;
|
|
913
|
+
result.content.push({ type: "text", text });
|
|
914
|
+
// Marked only once the line is actually ON the response — an early return above must
|
|
915
|
+
// not burn the one announcement this hold gets.
|
|
916
|
+
for (const id of fresh)
|
|
917
|
+
announcedHolds.add(id);
|
|
918
|
+
}
|
|
729
919
|
async function heartbeatAll() {
|
|
730
920
|
for (const [issueId, token] of [...leases]) {
|
|
731
921
|
try {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@retasc/cli",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.51.0",
|
|
4
4
|
"description": "Retasc CLI — the issue tracker AI agents pull work from. Sign in with GitHub or Google, create projects, mint agent API keys, and wire your agent to the Retasc MCP server in one command.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|