@alphacoder-v0/pi-loops 0.22.8 → 0.24.1
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 +717 -0
- package/CONTRACT.md +7 -7
- package/README.md +114 -78
- package/README.zh-CN.md +62 -41
- package/docs/cli.md +352 -91
- package/docs/configuration.md +86 -24
- package/docs/design.md +404 -61
- package/docs/downstream.md +2 -2
- package/docs/goal.md +12 -3
- package/docs/hooks.md +6 -7
- package/docs/loops.md +288 -59
- package/docs/mcp.md +22 -14
- package/docs/recipes.md +6 -0
- package/docs/screenshot.png +0 -0
- package/docs/triggers.md +120 -26
- package/docs/troubleshooting.md +41 -28
- package/docs/web-ui-parity.md +702 -65
- package/package.json +4 -3
- package/recipes/autoresearch/recipe.toml +1 -0
- package/recipes/changelog-draft/recipe.toml +1 -0
- package/recipes/ci-sweeper/recipe.toml +1 -0
- package/recipes/daily-digest/recipe.toml +1 -0
- package/recipes/deps-sweeper/recipe.toml +1 -0
- package/recipes/ecosystem/recipe.toml +1 -0
- package/recipes/issue-loop/recipe.toml +1 -0
- package/recipes/pr-watch/recipe.toml +1 -0
- package/skills/pi-loops/SKILL.md +6 -4
- package/src/activity.ts +199 -0
- package/src/archive.ts +21 -9
- package/src/args.ts +6 -2
- package/src/browser-open.ts +169 -0
- package/src/cli-entry.mjs +12 -3
- package/src/cli.ts +960 -143
- package/src/config.ts +53 -5
- package/src/conversation-rows.ts +469 -0
- package/src/danger.ts +227 -6
- package/src/engine.ts +1240 -0
- package/src/goal.ts +71 -0
- package/src/held-read.ts +27 -0
- package/src/hooks.ts +49 -18
- package/src/host-control-channel.ts +455 -40
- package/src/host-control.ts +602 -111
- package/src/host-entry.mjs +5 -3
- package/src/host-runtime.ts +455 -42
- package/src/host.ts +659 -123
- package/src/inbox-card.ts +278 -0
- package/src/inbox.ts +139 -62
- package/src/job-edit.ts +7 -1
- package/src/job-health.ts +14 -2
- package/src/job-lines.ts +201 -13
- package/src/job-owner.ts +74 -13
- package/src/job-tune.ts +110 -0
- package/src/lease.ts +537 -0
- package/src/lock.ts +7 -2
- package/src/mcp-pool.ts +66 -14
- package/src/mcp.ts +1 -1
- package/src/open-file.ts +54 -0
- package/src/page-children.ts +1507 -0
- package/src/page-devices.ts +192 -0
- package/src/page-server.ts +3150 -0
- package/src/path-precheck.ts +215 -0
- package/src/paths.ts +24 -0
- package/src/pi-floor.ts +10 -0
- package/src/pi-loops.ts +1370 -473
- package/src/pi-resolver.d.mts +1 -0
- package/src/pi-resolver.mjs +110 -10
- package/src/presence.ts +12 -63
- package/src/protocol.ts +10 -1
- package/src/recipe.ts +43 -1
- package/src/requests.ts +354 -0
- package/src/rule-lines.ts +68 -0
- package/src/run-card.ts +105 -0
- package/src/runner.ts +18 -1
- package/src/schedule.ts +1 -2
- package/src/scheduler.ts +595 -245
- package/src/sdk-runner.ts +321 -27
- package/src/seen.ts +278 -0
- package/src/session-head.ts +21 -0
- package/src/share.ts +12 -2
- package/src/snapshot.ts +3 -2
- package/src/store.ts +81 -8
- package/src/tools.ts +196 -65
- package/src/trigger-runtime.ts +164 -127
- package/src/triggers.ts +39 -7
- package/src/trust.ts +46 -2
- package/src/ui-prefs.ts +22 -7
- package/src/watch-plan.ts +174 -0
- package/src/web.d.mts +3 -0
- package/src/web.mjs +2261 -2340
- package/src/window-executor.ts +459 -0
- package/src/window-guard.ts +158 -0
- package/docs/agents/issue-tracker.md +0 -34
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@alphacoder-v0/pi-loops",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.24.1",
|
|
4
4
|
"description": "An automation layer for pi: cron + stateful loops with a triage inbox, maker/checker verification, dynamic triggers, MCP notifications and tools, lifecycle hooks, session archives. A non-invasive pi extension.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -52,8 +52,9 @@
|
|
|
52
52
|
"check:docs": "node scripts/check-docs.mjs",
|
|
53
53
|
"check:downstream": "sh test/downstream/run.sh",
|
|
54
54
|
"check:contract": "sh test/contract/run.sh",
|
|
55
|
-
"
|
|
56
|
-
"
|
|
55
|
+
"journey": "node test/journey/run.mjs",
|
|
56
|
+
"test": "PI_LOOPS_HOST=off node --import ./src/register-pi.mjs --test test/*.test.ts",
|
|
57
|
+
"check:scripts": "node --check src/web.mjs && node --check src/cli-entry.mjs && node --check src/host-entry.mjs && node --check src/ts-entry.mjs && node --check src/pi-resolver.mjs && node --check src/register-pi.mjs && node --check examples/mcp-notify-server.mjs && for f in test/journey/boot.mjs test/journey/run.mjs test/journey/journeys/*.mjs; do node --check \"$f\" || exit 1; done"
|
|
57
58
|
},
|
|
58
59
|
"peerDependencies": {
|
|
59
60
|
"@earendil-works/pi-ai": "*",
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
# a ledger, a checker re-measures on held-out data before anything is proposed. docs/recipes.md.
|
|
3
3
|
name = "autoresearch"
|
|
4
4
|
summary = "One experiment per run against a research contract you wrote; a ledger of what was tried; promotion only on held-out evidence."
|
|
5
|
+
line = "Runs one experiment an hour and keeps only what held-out data confirms."
|
|
5
6
|
tier = "advanced"
|
|
6
7
|
useful_when = ["You have a metric a script can print and a search space wider than your evenings.", "You want every attempt in a ledger, the failed ones included."]
|
|
7
8
|
needs_tracker = false
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
name = "changelog-draft"
|
|
2
2
|
summary = "When the default branch is ahead of the last tag, draft the changelog entry and propose a version; a person tags."
|
|
3
|
+
line = "Drafts the next changelog entry and version; you tag the release."
|
|
3
4
|
tier = "starter"
|
|
4
5
|
useful_when = ["Releases wait on someone finding an evening to write the entry.", "You want the version and the entry proposed from the commits, and to tag it yourself."]
|
|
5
6
|
needs_tracker = false
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# ci-sweeper: a red default branch gets a finding, or a fix in a worktree and a pull request. docs/recipes.md.
|
|
2
2
|
name = "ci-sweeper"
|
|
3
3
|
summary = "When the default branch's checks go red, says so — or, at propose, reproduces the failure in a worktree, fixes it, and opens the pull request; twice failing to fix the same failure is a stop."
|
|
4
|
+
line = "Tells you when the default branch goes red, or fixes it."
|
|
4
5
|
tier = "advanced"
|
|
5
6
|
useful_when = ["The default branch goes red and every open pull request looks red with it.", "The failures are the boring kind: a flaky assertion, a renamed fixture, a dependency that moved."]
|
|
6
7
|
needs_tracker = false
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# daily-digest: one finding a morning saying what needs a look today, or nothing. docs/recipes.md.
|
|
2
2
|
name = "daily-digest"
|
|
3
3
|
summary = "Every morning, one finding that says what in this project needs a look today — open items, a red CI, a loop that keeps failing — or nothing when nothing does."
|
|
4
|
+
line = "Every morning, one finding on what in this project needs a look."
|
|
4
5
|
tier = "starter"
|
|
5
6
|
useful_when = ["You open the tracker each morning to ask what happened overnight.", "You want the first loop in a project to write nowhere but the inbox."]
|
|
6
7
|
needs_tracker = true
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
# worktree and put up as one pull request. docs/recipes.md.
|
|
3
3
|
name = "deps-sweeper"
|
|
4
4
|
summary = "Once a week, runs the project's audit and outdated commands and reports each new advisory and each dependency a major behind — or, at propose, applies the patch and minor updates in a worktree, runs the checks, and opens one pull request; a major is never applied, and the same package failing twice is a stop."
|
|
5
|
+
line = "Weekly, reports new advisories or opens one pull request of safe updates."
|
|
5
6
|
tier = "advanced"
|
|
6
7
|
useful_when = ["Advisories arrive by email and the patch bumps pile up.", "You want the safe updates put up as one pull request a week and the majors left to you."]
|
|
7
8
|
needs_tracker = false
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# ecosystem: who uses this project, drafted — every outward word is a finding a person claims. docs/recipes.md.
|
|
2
2
|
name = "ecosystem"
|
|
3
3
|
summary = "Once a week, finds projects that use or fork this one and drafts the one reply or upstream invitation worth sending; every draft is a finding, and a person sends it."
|
|
4
|
+
line = "Finds projects that use this one and drafts the reply worth sending."
|
|
4
5
|
tier = "advanced"
|
|
5
6
|
useful_when = ["People use or fork the project and you find out months later.", "You want the reply drafted and to send it yourself."]
|
|
6
7
|
needs_tracker = true
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# The issue loop: the tracker as a state machine that three loops turn. docs/recipes.md explains it.
|
|
2
2
|
name = "issue-loop"
|
|
3
3
|
summary = "Triage new issues into agent briefs, build the accepted ones in a worktree, open pull requests; a person promotes and merges."
|
|
4
|
+
line = "Turns new issues into briefs and accepted briefs into pull requests."
|
|
4
5
|
tier = "advanced"
|
|
5
6
|
useful_when = ["Issues arrive faster than you read them, and most are buildable once someone writes the brief.", "You want the tracker to be the state machine, and your part to be two clicks: accept the brief, merge the pull request."]
|
|
6
7
|
needs_tracker = true
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# pr-watch: the open pull requests, and only what changed about them. docs/recipes.md.
|
|
2
2
|
name = "pr-watch"
|
|
3
3
|
summary = "Watches the open pull requests for what stalls them — a red check, a conflict, a review nobody answered — and reports only what changed since last time."
|
|
4
|
+
line = "Reports what changed on open pull requests: red checks, conflicts, unanswered reviews."
|
|
4
5
|
tier = "starter"
|
|
5
6
|
useful_when = ["Pull requests stall for days before anyone notices the red check or the unanswered review.", "You want to hear what changed about them, not what they are."]
|
|
6
7
|
needs_tracker = true
|
package/skills/pi-loops/SKILL.md
CHANGED
|
@@ -11,8 +11,8 @@ The user's pi has pi-loops installed. Prefer its tools over ad-hoc `sleep` loops
|
|
|
11
11
|
|
|
12
12
|
| The user wants… | Use |
|
|
13
13
|
|---|---|
|
|
14
|
-
| "every day at 9 / hourly / 每小时 …" and the result belongs in this chat (a reminder, a question to answer here) | `cron_create` with `stateful: false` — it belongs to this session
|
|
15
|
-
|
|
|
14
|
+
| "every day at 9 / hourly / 每小时 …" and the result belongs in this chat (a reminder, a question to answer here) | `cron_create` with `stateful: false` — it belongs to this session; while the session is closed, it runs in the background in the conversation, so the answer is there when the user comes back. Each run is a model turn. Its turns run with the chat's built-in tools under the dangerous-command guard: in an open window a dangerous command or a tool outside them asks the user first, and in the background it is refused. In the background they also run without this window's -e extensions or flags |
|
|
15
|
+
| work whose result should not land in a conversation — a nightly digest, a report, a watch | `cron_create` with `stateful: true`, even when the user did not say "loop" |
|
|
16
16
|
| recurring watch/triage: "check X and tell me what changed", "report new issues" | `cron_create` with `stateful: true` — the loop keeps notes between runs and reports findings to `/inbox` instead of interrupting the chat |
|
|
17
17
|
| the findings must be double-checked before the user sees them | `cron_create` with `stateful: true, verify: true` (a second adversarial sub-agent reviews each finding) |
|
|
18
18
|
| "when <condition> happens, do <action>" (a file appears, a PR merges, a build finishes) | `new_trigger` — condition + action, fires once unless the user asks for repeating |
|
|
@@ -49,5 +49,7 @@ reported in your notes; report only new or newly closed issues". Findings should
|
|
|
49
49
|
|
|
50
50
|
## Confirmations
|
|
51
51
|
|
|
52
|
-
|
|
53
|
-
|
|
52
|
+
With the user present, `cron_create` and `new_trigger` ask them first, in the same call, and
|
|
53
|
+
return `created ...` or `cancelled: ...`. Removing a trigger, and re-enabling a job or trigger, asks
|
|
54
|
+
the user to confirm. If the user cancels or declines, stop: do not retry with different arguments,
|
|
55
|
+
do not set it up another way, and do not do the action now.
|
package/src/activity.ts
ADDED
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The activity strip's items and the attention counts, computed when the page asks: runs from
|
|
3
|
+
* `runs.jsonl`, rule results from `triggers-audit.jsonl` joined to their findings in `inbox.jsonl`.
|
|
4
|
+
* There is no store of its own, so nothing here has to be kept in step with the files it reads.
|
|
5
|
+
* Pure: the caller reads the files, and this decides what they say.
|
|
6
|
+
*/
|
|
7
|
+
import type { InboxEntry } from "./inbox.ts";
|
|
8
|
+
import { ruleResultText, splitFinding } from "./inbox-card.ts";
|
|
9
|
+
import { failingJobs } from "./job-health.ts";
|
|
10
|
+
import { redact } from "./redact.ts";
|
|
11
|
+
import { runCardFor } from "./run-card.ts";
|
|
12
|
+
import type { LoopJob, RunRecord } from "./store.ts";
|
|
13
|
+
import type { AuditRecord } from "./triggers.ts";
|
|
14
|
+
|
|
15
|
+
/** Items the strip is given at most; `more` counts the rest of the window. */
|
|
16
|
+
export const ACTIVITY_LIMIT = 100;
|
|
17
|
+
/** How far back the strip looks. */
|
|
18
|
+
export const ACTIVITY_WINDOW_MS = 24 * 60 * 60 * 1000;
|
|
19
|
+
/** Machine-wide new decisions the page is told about, newest first, for its notification. */
|
|
20
|
+
export const NEW_DECISIONS_LIMIT = 20;
|
|
21
|
+
/** A strip row's text, clipped so a row stays one line. */
|
|
22
|
+
const ACTIVITY_TEXT_CHARS = 160;
|
|
23
|
+
|
|
24
|
+
export interface ActivityItem {
|
|
25
|
+
/** The run id, or the rule check's trace id. */
|
|
26
|
+
id: string;
|
|
27
|
+
kind: "run" | "rule";
|
|
28
|
+
/** The loop, or the first matched rule (`name` is its condition); `gone` when it is no longer there. */
|
|
29
|
+
source: { id: string; name?: string; gone: boolean };
|
|
30
|
+
finishedAt: string;
|
|
31
|
+
ok: boolean;
|
|
32
|
+
/** The run was stopped (a person, a quit, a restart): no failure, no card. */
|
|
33
|
+
stopped: boolean;
|
|
34
|
+
/** Set when a person stopped it, which the strip says as "stopped by you". */
|
|
35
|
+
stoppedBy?: "person";
|
|
36
|
+
/** A run: the findings it filed. A rule check: the findings its result is. */
|
|
37
|
+
findings: number;
|
|
38
|
+
seconds: number;
|
|
39
|
+
costUsd?: number;
|
|
40
|
+
/** Redacted and clipped. */
|
|
41
|
+
error?: string;
|
|
42
|
+
/** A rule check: the start of what it said, redacted and clipped. */
|
|
43
|
+
text?: string;
|
|
44
|
+
/** The run wrote a card into its conversation (src/run-card.ts). */
|
|
45
|
+
carded: boolean;
|
|
46
|
+
/** A rule check: its findings, so a row can point at the card. */
|
|
47
|
+
inboxIds: string[];
|
|
48
|
+
/** A rule check: the result also went to a conversation (`promote_to_chat`). */
|
|
49
|
+
inChat: boolean;
|
|
50
|
+
/** The run's loop or the rule was made in the conversation asking. */
|
|
51
|
+
fromThisConversation: boolean;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface Attention {
|
|
55
|
+
/** New decisions and new news in the project asked about (the whole machine when none is). */
|
|
56
|
+
decisions: number;
|
|
57
|
+
news: number;
|
|
58
|
+
/** New decisions on the whole machine, newest first. */
|
|
59
|
+
newDecisions: Array<{ id: string; cwd: string; headline: string }>;
|
|
60
|
+
/** Loops failing three or more times in a row; `key` is the same for the whole streak. */
|
|
61
|
+
failing: Array<{ jobId: string; name?: string; streak: number; key: string }>;
|
|
62
|
+
/** Only when today's spend has reached the cap. */
|
|
63
|
+
budget?: { day: string; spent: number; cap: number };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface ActivityInput {
|
|
67
|
+
runs: readonly RunRecord[];
|
|
68
|
+
audit: readonly AuditRecord[];
|
|
69
|
+
findings: readonly InboxEntry[];
|
|
70
|
+
jobs: ReadonlyMap<string, { name?: string; createdBy?: { sessionId?: string } }>;
|
|
71
|
+
rules: ReadonlyMap<string, { condition: string; createdBy?: { sessionId?: string } }>;
|
|
72
|
+
/** The project asked about, and how a path is matched to it; no cwd is the whole machine. */
|
|
73
|
+
cwd?: string;
|
|
74
|
+
inProject: (entryCwd: string, cwd: string) => boolean;
|
|
75
|
+
sessionId?: string;
|
|
76
|
+
sinceMs: number;
|
|
77
|
+
limit?: number;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Text from a file another process wrote, as one redacted line: a field of the wrong type is no text. */
|
|
81
|
+
const clip = (text: unknown, n = ACTIVITY_TEXT_CHARS) => {
|
|
82
|
+
const one = redact(typeof text === "string" ? text : "").replace(/\s+/g, " ").trim();
|
|
83
|
+
return one.length > n ? `${one.slice(0, n - 1)}…` : one;
|
|
84
|
+
};
|
|
85
|
+
const secondsBetween = (from: string | undefined, to: string) => {
|
|
86
|
+
const s = from ? Math.round((Date.parse(to) - Date.parse(from)) / 1000) : 0;
|
|
87
|
+
return Number.isFinite(s) && s > 0 ? s : 0;
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
/** Whether a path is in `cwd`'s project (always, with no cwd), asked once per distinct path. */
|
|
91
|
+
function projectMatch(cwd: string | undefined, inProject: (entryCwd: string, cwd: string) => boolean): (entryCwd: unknown) => boolean {
|
|
92
|
+
const known = new Map<string, boolean>();
|
|
93
|
+
return (entryCwd) => {
|
|
94
|
+
if (cwd === undefined) return true;
|
|
95
|
+
if (typeof entryCwd !== "string") return false;
|
|
96
|
+
let yes = known.get(entryCwd);
|
|
97
|
+
if (yes === undefined) known.set(entryCwd, (yes = inProject(entryCwd, cwd)));
|
|
98
|
+
return yes;
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** The strip's items, newest first, at most `limit`; `more` counts the rest, `failed` the failures among all. */
|
|
103
|
+
export function activityFrom(input: ActivityInput): { items: ActivityItem[]; more: number; failed: number } {
|
|
104
|
+
// Matching a project resolves paths on disk, and this runs on every page's poll: once per distinct
|
|
105
|
+
// cwd, and only for a record inside the window.
|
|
106
|
+
const here = projectMatch(input.cwd, input.inProject);
|
|
107
|
+
const recent = (at: string | undefined) => typeof at === "string" && Date.parse(at) >= input.sinceMs;
|
|
108
|
+
const items: ActivityItem[] = [];
|
|
109
|
+
for (const r of input.runs) {
|
|
110
|
+
if (!r.stateful || typeof r.runId !== "string" || !recent(r.finishedAt) || !here(r.cwd)) continue;
|
|
111
|
+
const job = input.jobs.get(r.jobId);
|
|
112
|
+
items.push({
|
|
113
|
+
id: r.runId,
|
|
114
|
+
kind: "run",
|
|
115
|
+
source: { id: String(r.jobId), name: job?.name ?? (typeof r.jobName === "string" ? r.jobName : undefined), gone: !job },
|
|
116
|
+
finishedAt: r.finishedAt,
|
|
117
|
+
ok: r.ok === true,
|
|
118
|
+
stopped: !!r.aborted,
|
|
119
|
+
...(r.stoppedBy === "person" ? { stoppedBy: "person" as const } : {}),
|
|
120
|
+
findings: typeof r.findings === "number" ? r.findings : 0,
|
|
121
|
+
seconds: secondsBetween(r.startedAt, r.finishedAt),
|
|
122
|
+
...(typeof r.usage?.cost === "number" && r.usage.cost ? { costUsd: r.usage.cost } : {}),
|
|
123
|
+
...(!r.ok && r.error ? { error: clip(r.error) } : {}),
|
|
124
|
+
carded: runCardFor(r) === "card",
|
|
125
|
+
inboxIds: [],
|
|
126
|
+
inChat: false,
|
|
127
|
+
fromThisConversation: !!input.sessionId && r.forSession === input.sessionId,
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
// A rule check is the `running` row and its result; a result that matched nothing is not news.
|
|
131
|
+
const started = new Map<string, string>();
|
|
132
|
+
const inChat = new Set<string>();
|
|
133
|
+
for (const a of input.audit) {
|
|
134
|
+
if (a.type === "trigger_result" && a.state === "running") started.set(a.traceId, a.ts);
|
|
135
|
+
if (a.type === "trigger_promotion" && (a.state === "promoted" || a.state === "appended")) inChat.add(a.traceId);
|
|
136
|
+
}
|
|
137
|
+
const filed = new Map<string, InboxEntry[]>();
|
|
138
|
+
for (const f of input.findings) if (typeof f.source === "string" && f.source.startsWith("trigger:")) filed.set(f.run_id, [...(filed.get(f.run_id) ?? []), f]);
|
|
139
|
+
for (const a of input.audit) {
|
|
140
|
+
if (a.type !== "trigger_result" || a.sourceLabel !== "local:dynamic" || typeof a.traceId !== "string" || !recent(a.ts) || !here(a.cwd)) continue;
|
|
141
|
+
// This project's rules the check named (src/trigger-runtime.ts), not every id it wrote.
|
|
142
|
+
const matched = Array.isArray(a.details?.matched_rule_ids) ? (a.details.matched_rule_ids as unknown[]).filter((id): id is string => typeof id === "string") : [];
|
|
143
|
+
if (!(a.state === "failed" || (a.state === "completed" && matched.length))) continue;
|
|
144
|
+
const ruleId = matched[0] ?? "";
|
|
145
|
+
const rule = ruleId ? input.rules.get(ruleId) : undefined;
|
|
146
|
+
const findings = filed.get(a.traceId) ?? [];
|
|
147
|
+
const cost = typeof a.details?.cost_usd === "number" ? a.details.cost_usd : 0;
|
|
148
|
+
items.push({
|
|
149
|
+
id: a.traceId,
|
|
150
|
+
kind: "rule",
|
|
151
|
+
source: { id: ruleId, ...(rule ? { name: clip(rule.condition, 80) } : {}), gone: !!ruleId && !rule },
|
|
152
|
+
finishedAt: a.ts,
|
|
153
|
+
ok: a.state === "completed",
|
|
154
|
+
stopped: false,
|
|
155
|
+
findings: findings.length,
|
|
156
|
+
seconds: secondsBetween(started.get(a.traceId), a.ts),
|
|
157
|
+
...(cost ? { costUsd: cost } : {}),
|
|
158
|
+
...(a.state === "failed" ? { error: clip(a.summary) || "the check failed" } : { text: clip(ruleResultText(typeof a.summary === "string" ? a.summary : "")) }),
|
|
159
|
+
carded: false,
|
|
160
|
+
inboxIds: findings.map((f) => f.id),
|
|
161
|
+
inChat: inChat.has(a.traceId),
|
|
162
|
+
fromThisConversation: !!input.sessionId && rule?.createdBy?.sessionId === input.sessionId,
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
items.sort((x, y) => y.finishedAt.localeCompare(x.finishedAt));
|
|
166
|
+
const limit = input.limit ?? ACTIVITY_LIMIT;
|
|
167
|
+
return { items: items.slice(0, limit), more: Math.max(0, items.length - limit), failed: items.filter((i) => !i.ok && !i.stopped).length };
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** The title's counts, the hidden tab's notifications and the budget line, from the same files. */
|
|
171
|
+
export function attentionFrom(input: {
|
|
172
|
+
findings: readonly InboxEntry[];
|
|
173
|
+
jobs: readonly LoopJob[];
|
|
174
|
+
cwd?: string;
|
|
175
|
+
inProject: (entryCwd: string, cwd: string) => boolean;
|
|
176
|
+
budget?: { spent: number; cap: number; over: boolean };
|
|
177
|
+
day: string;
|
|
178
|
+
}): Attention {
|
|
179
|
+
const fresh = input.findings.filter((f) => f.status === "new");
|
|
180
|
+
const inHere = projectMatch(input.cwd, input.inProject);
|
|
181
|
+
// A finding with no cwd is in every project, as `/inbox` lists it (inbox.ts belongsToProject).
|
|
182
|
+
const here = fresh.filter((f) => !f.cwd || inHere(f.cwd));
|
|
183
|
+
const decisions = fresh.filter((f) => f.kind === "checkpoint");
|
|
184
|
+
return {
|
|
185
|
+
decisions: here.filter((f) => f.kind === "checkpoint").length,
|
|
186
|
+
news: here.filter((f) => f.kind !== "checkpoint").length,
|
|
187
|
+
newDecisions: decisions
|
|
188
|
+
.slice(-NEW_DECISIONS_LIMIT)
|
|
189
|
+
.reverse()
|
|
190
|
+
.map((f) => ({ id: f.id, cwd: f.cwd, headline: clip(splitFinding(clip(f.text, 500), "checkpoint").headline, 120) })),
|
|
191
|
+
failing: failingJobs([...input.jobs]).map((j) => ({ jobId: j.id, ...(j.name ? { name: j.name } : {}), streak: j.consecutiveFailures ?? 0, key: `${j.id}:${(j.runCount ?? 0) - (j.consecutiveFailures ?? 0)}` })),
|
|
192
|
+
...(input.budget?.over ? { budget: { day: input.day, spent: input.budget.spent, cap: input.budget.cap } } : {}),
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** The attention block for a conversation in no project known: what is said of the whole machine, and no project's counts. */
|
|
197
|
+
export function machineWide(a: Attention): Pick<Attention, "newDecisions" | "failing" | "budget"> {
|
|
198
|
+
return { newDecisions: a.newDecisions, failing: a.failing, ...(a.budget ? { budget: a.budget } : {}) };
|
|
199
|
+
}
|
package/src/archive.ts
CHANGED
|
@@ -17,8 +17,9 @@ import { createHash, randomUUID } from "node:crypto";
|
|
|
17
17
|
import * as fs from "node:fs";
|
|
18
18
|
import * as os from "node:os";
|
|
19
19
|
import * as path from "node:path";
|
|
20
|
-
import { sameProject } from "./
|
|
20
|
+
import { sameProject } from "./paths.ts";
|
|
21
21
|
import { LOOP_STATE_MAX_CHARS, capChars } from "./protocol.ts";
|
|
22
|
+
import { pathOnlyCondition } from "./path-precheck.ts";
|
|
22
23
|
import { previewRedacted } from "./redact.ts";
|
|
23
24
|
import { computeNext, formatSchedule, isValidSchedule, stamp } from "./schedule.ts";
|
|
24
25
|
import { type JobStore, newId, type LoopJob } from "./store.ts";
|
|
@@ -190,7 +191,7 @@ export function defaultExportPath(cwd: string, sessionId: string): string {
|
|
|
190
191
|
* The store is machine-global, so the project alone would sweep in a colleague's loops — or your
|
|
191
192
|
* own from another window — and the session that created a job or rule is what scopes the archive.
|
|
192
193
|
* A job or rule from before `createdBy` existed has no owner to ask, and counts as this session's
|
|
193
|
-
* when it is in this project. "This project" is `sameProject`, not a string comparison: a worktree
|
|
194
|
+
* when it is in this project; so does a job the project owns (`owner: "project"`). "This project" is `sameProject`, not a string comparison: a worktree
|
|
194
195
|
* or a symlinked path is the same project and its loops belong in the same archive.
|
|
195
196
|
*/
|
|
196
197
|
export function automationOf(jobs: LoopJob[], rules: DynamicTriggerRule[], cwd: string, sessionId?: string): { jobs: LoopJob[]; rules: DynamicTriggerRule[] } {
|
|
@@ -373,7 +374,7 @@ export function importSession(input: ImportInput): ImportSummary {
|
|
|
373
374
|
// hand-made archive would throw there, and the tick has no per-job recovery upstream.
|
|
374
375
|
if (!isValidSchedule(raw.schedule)) throw new Error(`cron sidecar contains an invalid schedule for job ${raw.id}`);
|
|
375
376
|
// And one that parses but can never match ("0 0 30 2 *"): the job would go quiet with nothing
|
|
376
|
-
// to see, and every scan for its next run walks the whole lookahead window on the
|
|
377
|
+
// to see, and every scan for its next run walks the whole lookahead window on the scheduler's
|
|
377
378
|
// tick. `/cron add` and `/cron set` have refused it since they existed; an archive is user
|
|
378
379
|
// input at the same boundary. Only cron is asked — a `once` whose time has passed is due now.
|
|
379
380
|
if (raw.schedule.kind === "cron" && computeNext({ schedule: raw.schedule, createdAt: Date.parse(raw.createdAt) || Date.now() }, Date.now()) === undefined) {
|
|
@@ -385,7 +386,10 @@ export function importSession(input: ImportInput): ImportSummary {
|
|
|
385
386
|
// `createdBy` is left as the archive carries it: it names the session that created the job,
|
|
386
387
|
// which is what lets a second import of the same archive recognise itself. Re-stamping it
|
|
387
388
|
// with the fresh import session would make every import look new and double the automation.
|
|
388
|
-
|
|
389
|
+
// A job the project owns stays the project's; one with no owner at all is from before
|
|
390
|
+
// `createdBy` and counts as the exporting conversation's.
|
|
391
|
+
const { host: _legacyHost, owner: _owner, createdBy: _createdBy, ...rawJob } = raw as typeof raw & { host?: unknown };
|
|
392
|
+
const projectOwned = raw.owner === "project";
|
|
389
393
|
const job: LoopJob = {
|
|
390
394
|
...rawJob,
|
|
391
395
|
cwd: input.targetCwd,
|
|
@@ -395,7 +399,7 @@ export function importSession(input: ImportInput): ImportSummary {
|
|
|
395
399
|
lastError: undefined,
|
|
396
400
|
skippedOverlap: 0,
|
|
397
401
|
sessionId: raw.stateful ? undefined : sessionId,
|
|
398
|
-
createdBy: raw.createdBy ?? { sessionId: parsed.header.id, cwd: manifest.source?.cwd ?? input.targetCwd },
|
|
402
|
+
...(projectOwned ? { owner: "project" as const } : { createdBy: raw.createdBy ?? { sessionId: parsed.header.id, cwd: manifest.source?.cwd ?? input.targetCwd } }),
|
|
399
403
|
};
|
|
400
404
|
if (dedup.hasJob(job)) continue;
|
|
401
405
|
if (input.existingJobIds.has(job.id)) job.id = newId("cron");
|
|
@@ -414,8 +418,11 @@ export function importSession(input: ImportInput): ImportSummary {
|
|
|
414
418
|
for (const raw of file.rules as DynamicTriggerRule[]) {
|
|
415
419
|
if (!raw || typeof raw.id !== "string" || typeof raw.condition !== "string" || typeof raw.action !== "string") throw new Error("trigger sidecar contains an invalid rule");
|
|
416
420
|
if (!SAFE_ID.test(raw.id)) throw new Error("trigger sidecar contains an invalid rule id");
|
|
417
|
-
const { host: _legacyRuleHost, ...rawRule } = raw as typeof raw & { host?: unknown };
|
|
421
|
+
const { host: _legacyRuleHost, precheck: _theirPrecheck, ...rawRule } = raw as typeof raw & { host?: unknown };
|
|
418
422
|
const rule: DynamicTriggerRule = { ...rawRule, cwd: input.targetCwd, enabled: raw.enabled && input.activate, createdBy: raw.createdBy ?? { sessionId: parsed.header.id } };
|
|
423
|
+
// Read again here, against this project and this home: the archive's was resolved on the machine that wrote it.
|
|
424
|
+
const only = pathOnlyCondition(rule.condition, { cwd: input.targetCwd, home: os.homedir() });
|
|
425
|
+
if ("watch" in only) rule.precheck = only.watch;
|
|
419
426
|
if (dedup.hasRule(rule)) continue;
|
|
420
427
|
if (input.existingRuleIds.has(rule.id)) rule.id = newRuleId();
|
|
421
428
|
if (raw.enabled) originallyEnabledRules.push(rule.id);
|
|
@@ -519,7 +526,7 @@ export async function offerToEnable(
|
|
|
519
526
|
if (imp.automationEnabled) return none;
|
|
520
527
|
const pending = imp.originallyEnabledJobs.length + imp.originallyEnabledRules.length;
|
|
521
528
|
if (!pending || !confirm) return none;
|
|
522
|
-
const ok = await confirm("Enable imported automation?", `${imp.originallyEnabledJobs.length
|
|
529
|
+
const ok = await confirm("Enable imported automation?", `${count(imp.originallyEnabledJobs.length, "loop")} and ${count(imp.originallyEnabledRules.length, "rule")} were enabled in the source conversation. Enable them here now?`);
|
|
523
530
|
if (!ok) return none;
|
|
524
531
|
for (const id of imp.originallyEnabledJobs) {
|
|
525
532
|
const before = jobStore.load().find((j) => j.id === id);
|
|
@@ -564,8 +571,13 @@ class Dedup {
|
|
|
564
571
|
|
|
565
572
|
notes(): string[] {
|
|
566
573
|
const out: string[] = [];
|
|
567
|
-
if (this.jobs) out.push(`${this.jobs
|
|
568
|
-
if (this.rules) out.push(`${this.rules
|
|
574
|
+
if (this.jobs) out.push(`${count(this.jobs, "loop")} already imported from this archive ${this.jobs === 1 ? "was" : "were"} skipped`);
|
|
575
|
+
if (this.rules) out.push(`${count(this.rules, "rule")} already imported from this archive ${this.rules === 1 ? "was" : "were"} skipped`);
|
|
569
576
|
return out;
|
|
570
577
|
}
|
|
571
578
|
}
|
|
579
|
+
|
|
580
|
+
/** `1 loop`, `2 loops`: a count a person reads, never `loop(s)`. */
|
|
581
|
+
function count(n: number, one: string): string {
|
|
582
|
+
return `${n} ${n === 1 ? one : `${one}s`}`;
|
|
583
|
+
}
|
package/src/args.ts
CHANGED
|
@@ -144,7 +144,8 @@ export interface SetArgs {
|
|
|
144
144
|
thinking?: string | null;
|
|
145
145
|
timeoutMs?: number | null;
|
|
146
146
|
name?: string | null;
|
|
147
|
-
/**
|
|
147
|
+
/** Jobs only: the tools its turns run with; `null` clears the list. */
|
|
148
|
+
tools?: string[] | null;
|
|
148
149
|
/** Jobs only: the new prompt, verbatim. A loop's notes live under its id, so rewording it here keeps them. */
|
|
149
150
|
prompt?: string;
|
|
150
151
|
/** Jobs only: already parsed, so a typo is rejected before it can be stored. */
|
|
@@ -162,7 +163,7 @@ export interface SetOptions {
|
|
|
162
163
|
* `--model -` (or `current`) removes the pin.
|
|
163
164
|
*/
|
|
164
165
|
export function parseSetArgs(input: string, opts: SetOptions = {}): SetArgs {
|
|
165
|
-
const usage = `usage: /cron|/triggers set <id> [--model <provider/id>|-] [--thinking <level>|-] [--timeout <dur>|-] [--name <n>|-]${opts.job ? ' [--prompt "<text>"] [--schedule "<expr>"]' : ""}`;
|
|
166
|
+
const usage = `usage: /cron|/triggers set <id> [--model <provider/id>|-] [--thinking <level>|-] [--timeout <dur>|-] [--name <n>|-]${opts.job ? ' [--tools a,b|-] [--prompt "<text>"] [--schedule "<expr>"]' : ""}`;
|
|
166
167
|
const tokens = tokenize(input);
|
|
167
168
|
const out: SetArgs = { ref: "" };
|
|
168
169
|
let touched = 0;
|
|
@@ -185,6 +186,9 @@ export function parseSetArgs(input: string, opts: SetOptions = {}): SetArgs {
|
|
|
185
186
|
else if (t === "--thinking") out.thinking = clear ? null : requireThinkingLevel(val);
|
|
186
187
|
else if (t === "--timeout") out.timeoutMs = clear ? null : parseDuration(val);
|
|
187
188
|
else if (t === "--name") out.name = clear ? null : val;
|
|
189
|
+
// A plain job's `--tools` was inert in 0.22 and caps its turns since 0.23: `-` is how such a job
|
|
190
|
+
// is uncapped without remove-and-re-add. A rule has no tools of its own.
|
|
191
|
+
else if (opts.job && t === "--tools") out.tools = clear ? null : val.split(",").map((s) => s.trim()).filter(Boolean);
|
|
188
192
|
// Rewording a loop or moving it to another hour used to mean remove-and-re-add, which mints a
|
|
189
193
|
// new id — and the notes a stateful loop has been accumulating live at `state/<id>.md`.
|
|
190
194
|
else if (opts.job && (t === "--prompt" || t === "--schedule")) {
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which command opens a browser here, and whether one did. The table copies pi's own opener (macOS
|
|
3
|
+
* `open`, Windows `rundll32`) and adds the WSL branch pi lacks: there `xdg-open` finds no browser and
|
|
4
|
+
* exits 3 after five seconds, so the Windows side is asked directly, with pi's Windows opener. "Opened" comes from the exit
|
|
5
|
+
* code, never from the spawn alone: a terminal that says it opened a browser when nothing opened
|
|
6
|
+
* leaves a person waiting for a window that never comes.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { spawn } from "node:child_process";
|
|
10
|
+
import * as os from "node:os";
|
|
11
|
+
import { pathToFileURL } from "node:url";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* One command to try: what to run, from where, which exit codes mean the browser got it, and how
|
|
15
|
+
* long it may run before still running counts as opened.
|
|
16
|
+
*/
|
|
17
|
+
export type Opener = { cmd: string; args: string[]; cwd?: string; okExit: number[]; waitMs: number };
|
|
18
|
+
|
|
19
|
+
/** For an opener that exits as soon as it has handed the address on. */
|
|
20
|
+
const QUICK_MS = 1_500;
|
|
21
|
+
/** xdg-open that finds no browser exits 3 after about five seconds. */
|
|
22
|
+
const XDG_OPEN_MS = 10_000;
|
|
23
|
+
|
|
24
|
+
/** What the choice depends on, passed in so a test can be any machine. */
|
|
25
|
+
export type OpenEnv = { platform: NodeJS.Platform; release: string; env: NodeJS.ProcessEnv };
|
|
26
|
+
|
|
27
|
+
/** ssh and mosh set at least one of these; opening a browser on the far end helps nobody. */
|
|
28
|
+
export function isRemoteTty(env: NodeJS.ProcessEnv = process.env): boolean {
|
|
29
|
+
return ["SSH_CONNECTION", "SSH_CLIENT", "SSH_TTY", "MOSH_CONNECTION"].some((k) => !!env[k]);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function isWsl(e: Pick<OpenEnv, "platform" | "release" | "env">): boolean {
|
|
33
|
+
return e.platform === "linux" && (!!e.env.WSL_DISTRO_NAME || /microsoft/i.test(e.release));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** The commands to try in order, or why none is tried. */
|
|
37
|
+
export function openerFor(e: OpenEnv & { target: string }): Opener[] | "no-display" | "remote" {
|
|
38
|
+
if (isRemoteTty(e.env)) return "remote";
|
|
39
|
+
const t = e.target;
|
|
40
|
+
if (e.platform === "darwin") return [{ cmd: "open", args: [t], okExit: [0], waitMs: QUICK_MS }];
|
|
41
|
+
// No shell: cmd would read `&` in the address as the end of the command.
|
|
42
|
+
if (e.platform === "win32") return [{ cmd: "rundll32", args: ["url.dll,FileProtocolHandler", t], okExit: [0], waitMs: QUICK_MS }];
|
|
43
|
+
// Both start from a Windows drive: a long \\wsl.localhost cwd breaks them. Not wslview: wslu 3.2.3
|
|
44
|
+
// reads a wsl.localhost address as a relative Linux path and exits 0 without opening anything. Not
|
|
45
|
+
// explorer.exe: it exits 1 whether or not anything opened. rundll32 exits 0 even for a missing
|
|
46
|
+
// file, so cmd.exe is reached only when rundll32.exe cannot start.
|
|
47
|
+
if (isWsl(e))
|
|
48
|
+
return [
|
|
49
|
+
{ cmd: "rundll32.exe", args: ["url.dll,FileProtocolHandler", t], cwd: "/mnt/c", okExit: [0], waitMs: QUICK_MS },
|
|
50
|
+
// cmd re-reads its line: `^` escapes the characters it would act on.
|
|
51
|
+
{ cmd: "cmd.exe", args: ["/c", "start", "", t.replace(/[\^&|]/g, "^$&")], cwd: "/mnt/c", okExit: [0], waitMs: QUICK_MS },
|
|
52
|
+
];
|
|
53
|
+
if (!e.env.DISPLAY && !e.env.WAYLAND_DISPLAY) return "no-display";
|
|
54
|
+
return [{ cmd: "xdg-open", args: [t], okExit: [0], waitMs: XDG_OPEN_MS }];
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* The address the opener is given for the launch file, which holds the one-use key so no argv does.
|
|
59
|
+
* On WSL the browser is a Windows one, which reads the file through `\\wsl.localhost`: measured on
|
|
60
|
+
* WSL 2 with rundll32 and `cmd.exe /c start`, each loads the file and follows it to the page.
|
|
61
|
+
* Undefined on WSL without `WSL_DISTRO_NAME`, which that address needs, and for a path holding a
|
|
62
|
+
* character Windows does not allow in a name: `\\wsl.localhost` shows those under other names, so
|
|
63
|
+
* the browser cannot find the file while rundll32 still exits 0.
|
|
64
|
+
*/
|
|
65
|
+
export function openFileTarget(file: string, e: Pick<OpenEnv, "platform" | "release" | "env">): string | undefined {
|
|
66
|
+
const href = pathToFileURL(file).href;
|
|
67
|
+
if (!isWsl(e)) return href;
|
|
68
|
+
const distro = e.env.WSL_DISTRO_NAME;
|
|
69
|
+
return distro && !/[\\:*?"<>|]/.test(file) ? `file://wsl.localhost/${encodeURIComponent(distro)}${href.slice("file://".length)}` : undefined;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** This machine, as `openerFor` reads it. */
|
|
73
|
+
export function thisMachine(): OpenEnv {
|
|
74
|
+
return { platform: process.platform, release: os.release(), env: process.env };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Hand `target` to each opener in turn until one takes it: true when one exits with a code it
|
|
79
|
+
* counts as success, or is still running at its own deadline, which is how a browser started
|
|
80
|
+
* straight from the opener looks. `waitMs` replaces every deadline, for a test.
|
|
81
|
+
*/
|
|
82
|
+
export async function openUrl(target: string, e: OpenEnv = thisMachine(), waitMs?: number): Promise<boolean> {
|
|
83
|
+
const openers = openerFor({ ...e, target });
|
|
84
|
+
if (typeof openers === "string") return false;
|
|
85
|
+
for (const opener of openers) if (await tryOpener(opener, e.env, waitMs ?? opener.waitMs)) return true;
|
|
86
|
+
return false;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function tryOpener(o: Opener, env: NodeJS.ProcessEnv, waitMs: number): Promise<boolean> {
|
|
90
|
+
return new Promise((resolve) => {
|
|
91
|
+
let child: ReturnType<typeof spawn>;
|
|
92
|
+
try {
|
|
93
|
+
child = spawn(o.cmd, o.args, { stdio: "ignore", detached: true, cwd: o.cwd, env });
|
|
94
|
+
} catch {
|
|
95
|
+
resolve(false); // the next opener, or the printed link
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
const timer = setTimeout(() => {
|
|
99
|
+
child.unref();
|
|
100
|
+
resolve(true);
|
|
101
|
+
}, waitMs);
|
|
102
|
+
child.once("error", () => {
|
|
103
|
+
clearTimeout(timer);
|
|
104
|
+
resolve(false);
|
|
105
|
+
});
|
|
106
|
+
child.once("exit", (code) => {
|
|
107
|
+
clearTimeout(timer);
|
|
108
|
+
resolve(code !== null && o.okExit.includes(code));
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** One command that copies its stdin to the clipboard and exits 0 when it has. */
|
|
114
|
+
export type CopyCommand = { cmd: string; args: string[]; cwd?: string };
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* The clipboard commands to try in order, the ones pi's `copyToClipboard` runs. Not OSC 52, which
|
|
118
|
+
* pi also writes and which no terminal confirms: tmux in Windows Terminal drops it, so a terminal
|
|
119
|
+
* that said "copied" after it could be wrong. None over ssh or mosh, where this machine's clipboard
|
|
120
|
+
* is not the one in front of you.
|
|
121
|
+
*/
|
|
122
|
+
export function copyCommandsFor(e: OpenEnv): CopyCommand[] {
|
|
123
|
+
if (isRemoteTty(e.env)) return [];
|
|
124
|
+
if (e.platform === "darwin") return [{ cmd: "pbcopy", args: [] }];
|
|
125
|
+
if (e.platform === "win32") return [{ cmd: "clip", args: [] }];
|
|
126
|
+
if (e.platform !== "linux") return [];
|
|
127
|
+
// The link is ASCII, so clip.exe reading it in the console code page changes nothing.
|
|
128
|
+
const windows = isWsl(e) ? [{ cmd: "clip.exe", args: [], cwd: "/mnt/c" }] : [];
|
|
129
|
+
return [
|
|
130
|
+
...windows,
|
|
131
|
+
...(e.env.WAYLAND_DISPLAY ? [{ cmd: "wl-copy", args: [] }] : []),
|
|
132
|
+
...(e.env.DISPLAY ? [{ cmd: "xclip", args: ["-selection", "clipboard"] }, { cmd: "xsel", args: ["--clipboard", "--input"] }] : []),
|
|
133
|
+
];
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Copy `text` with the first command that takes it: true only when one exited 0. */
|
|
137
|
+
export async function copyText(text: string, e: OpenEnv = thisMachine()): Promise<boolean> {
|
|
138
|
+
for (const c of copyCommandsFor(e)) if (await runCopy(c, text, e.env)) return true;
|
|
139
|
+
return false;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const COPY_WAIT_MS = 5_000;
|
|
143
|
+
|
|
144
|
+
function runCopy(c: CopyCommand, text: string, env: NodeJS.ProcessEnv): Promise<boolean> {
|
|
145
|
+
return new Promise((resolve) => {
|
|
146
|
+
let child: ReturnType<typeof spawn>;
|
|
147
|
+
try {
|
|
148
|
+
// xclip and xsel stay behind to own the selection; "exit" is the command that answered.
|
|
149
|
+
child = spawn(c.cmd, c.args, { stdio: ["pipe", "ignore", "ignore"], cwd: c.cwd, env });
|
|
150
|
+
} catch {
|
|
151
|
+
resolve(false); // the next command
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
const timer = setTimeout(() => {
|
|
155
|
+
child.kill();
|
|
156
|
+
resolve(false);
|
|
157
|
+
}, COPY_WAIT_MS);
|
|
158
|
+
child.once("error", () => {
|
|
159
|
+
clearTimeout(timer);
|
|
160
|
+
resolve(false);
|
|
161
|
+
});
|
|
162
|
+
child.once("exit", (code) => {
|
|
163
|
+
clearTimeout(timer);
|
|
164
|
+
resolve(code === 0);
|
|
165
|
+
});
|
|
166
|
+
child.stdin?.once("error", () => {}); // EPIPE from a command that exited first: its exit code decides
|
|
167
|
+
child.stdin?.end(text);
|
|
168
|
+
});
|
|
169
|
+
}
|
package/src/cli-entry.mjs
CHANGED
|
@@ -7,6 +7,12 @@ import { register } from "node:module";
|
|
|
7
7
|
import { importTs } from "./ts-entry.mjs";
|
|
8
8
|
|
|
9
9
|
register("./pi-resolver.mjs", import.meta.url);
|
|
10
|
+
// `pi-loops status | head` closes the pipe early: the reader has what it wanted, which is not an error.
|
|
11
|
+
process.stdout.on("error", (err) => {
|
|
12
|
+
if (err?.code === "EPIPE") process.exit(process.exitCode ?? 0);
|
|
13
|
+
process.stderr.write(`pi-loops: ${err?.message ?? err}\n`);
|
|
14
|
+
process.exit(1);
|
|
15
|
+
});
|
|
10
16
|
let cli;
|
|
11
17
|
try {
|
|
12
18
|
cli = await importTs(import.meta.url, "./cli.ts");
|
|
@@ -15,9 +21,12 @@ try {
|
|
|
15
21
|
process.stderr.write(`pi-loops: ${err?.message ?? err}\n`);
|
|
16
22
|
process.exit(1);
|
|
17
23
|
}
|
|
24
|
+
const argv = process.argv.slice(2);
|
|
18
25
|
try {
|
|
19
|
-
process.exitCode = await cli.runCli(
|
|
26
|
+
process.exitCode = await cli.runCli(argv);
|
|
20
27
|
} catch (err) {
|
|
21
|
-
|
|
22
|
-
|
|
28
|
+
// Only a command typed wrong is answered with its usage; a missing file or a lock is not fixed by reading one.
|
|
29
|
+
const usage = err instanceof cli.UsageError;
|
|
30
|
+
process.stderr.write(`pi-loops: ${err?.message ?? err}\n${usage ? `\n${cli.usageFor(argv[0])}\n` : ""}`);
|
|
31
|
+
process.exitCode = usage ? 2 : 1;
|
|
23
32
|
}
|