breakaway 1.0.2-main.7 → 1.0.2-main.9
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/README.md +1 -1
- package/package.json +1 -1
- package/scripts/tasks/message-wait.mjs +3 -3
- package/scripts/tasks/proxy.js +90 -0
- package/scripts/tasks/session-hook.mjs +25 -18
- package/scripts/tasks.mjs +16 -1
- package/src/cli-version.js +2 -2
package/README.md
CHANGED
|
@@ -171,7 +171,7 @@ pnpm interop # checks sync against real Taskwarrior 3
|
|
|
171
171
|
|
|
172
172
|
breakaway publishes releases and never deploys an install. Every install, the owner's included, deploys a release from its own repository, and this repository holds no Cloudflare credentials. Its website follows the latest stable release: the release moves the `site` branch, and Cloudflare deploys it ([`site/README.md`](https://github.com/TheAnarchoX/breakaway/blob/main/site/README.md#deploy-it)).
|
|
173
173
|
|
|
174
|
-
- **Every merge to `main`**, once CI passes, publishes a GitHub pre-release `vX.Y.Z-main.N` on the `main` channel. It carries the bundle (`breakaway-bundle.tar.gz`: the Worker's files and the web app's `dist`), a `manifest.json` (version, channel, commit, `manual`, and the lowest version it updates from), and `SHA256SUMS`. The notes list the merged pull requests by title.
|
|
174
|
+
- **Every merge to `main`**, once CI passes, publishes a GitHub pre-release `vX.Y.Z-main.N` on the `main` channel. It carries the bundle (`breakaway-bundle.tar.gz`: the Worker's files and the web app's `dist`), a `manifest.json` (version, channel, commit, `manual`, and the lowest version it updates from), its signature `manifest.json.sig` (Ed25519, made with a key only the release workflow holds; the public key is `src/release-key.js`), and `SHA256SUMS`. The notes list the merged pull requests by title.
|
|
175
175
|
- **A stable release** `vX.Y.Z` is the owner's: they run the **Release** workflow with the pre-release to promote. The bundle is that pre-release's, unchanged, and the notes cover everything since the last stable.
|
|
176
176
|
- **The CLI** is on npm as [`breakaway`](https://www.npmjs.com/package/breakaway), staged on npm by the same workflow, with provenance, and live once the owner approves it there with 2FA. npm's trusted publishing can't yet read the OIDC identity of a repository as new as this one ([npm/cli#9969](https://github.com/npm/cli/issues/9969)), so until it can, a token that can stage but never publish by itself stands in, in an environment only `main` can use. Every pre-release goes out under the `next` dist-tag, and a stable release as `latest`. `npx breakaway <command>` is `node scripts/tasks.mjs <command>`.
|
|
177
177
|
- **A major release** is one where an install has to do something by hand: a config or binding change, a Durable Object class or migration, a route or cron. Its notes have a **Manual steps** section and its manifest says `manual: true`, which an install's deploy stops on. A change that needs it sets `manual` and `manualSteps` in `release.json`, and the pull request that ships the steps clears them. Data the Durable Object stores changes forward-only and additively, so an install can always go back one release.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "breakaway",
|
|
3
|
-
"version": "1.0.2-main.
|
|
3
|
+
"version": "1.0.2-main.9",
|
|
4
4
|
"description": "The task board for you and your coding agents: a Cloudflare Worker, its web app, Taskwarrior sync, and the CLI (npx breakaway).",
|
|
5
5
|
"license": "FSL-1.1-Apache-2.0",
|
|
6
6
|
"type": "module",
|
|
@@ -17,7 +17,7 @@ import { tmpdir } from 'node:os';
|
|
|
17
17
|
import { join } from 'node:path';
|
|
18
18
|
import { setTimeout as sleep } from 'node:timers/promises';
|
|
19
19
|
import { boardConfig, claimedTask, projectRoot } from './hook-config.js';
|
|
20
|
-
import {
|
|
20
|
+
import { sessionRequest } from './proxy.js';
|
|
21
21
|
import { waitForMessages } from './session-messages.js';
|
|
22
22
|
|
|
23
23
|
async function main() {
|
|
@@ -43,9 +43,9 @@ async function main() {
|
|
|
43
43
|
const { base, headers } = boardConfig(root);
|
|
44
44
|
if (!base) return;
|
|
45
45
|
const url = `${base}/api/tasks/${encodeURIComponent(claim.uuid)}/messages/waiting?agent=${encodeURIComponent(claim.agent)}`;
|
|
46
|
-
|
|
46
|
+
// Through curl in a cloud session, so it works on whichever Node runs hooks there (BRK-86).
|
|
47
47
|
const ask = async () => {
|
|
48
|
-
const res = await
|
|
48
|
+
const res = await sessionRequest(url, { headers, timeoutMs: 10_000 });
|
|
49
49
|
return res.ok ? res.json() : null;
|
|
50
50
|
};
|
|
51
51
|
|
package/scripts/tasks/proxy.js
CHANGED
|
@@ -7,7 +7,11 @@
|
|
|
7
7
|
* routeThroughSessionProxy() sends fetch through the proxy and trusts the system's CA store too, which
|
|
8
8
|
* holds the proxy's certificate. Without a proxy in the environment it does nothing.
|
|
9
9
|
*/
|
|
10
|
+
import { spawn } from 'node:child_process';
|
|
11
|
+
import { readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
10
12
|
import http from 'node:http';
|
|
13
|
+
import { tmpdir } from 'node:os';
|
|
14
|
+
import { join } from 'node:path';
|
|
11
15
|
import tls from 'node:tls';
|
|
12
16
|
|
|
13
17
|
export function sessionProxy(env = process.env) {
|
|
@@ -25,3 +29,89 @@ export function routeThroughSessionProxy(env = process.env) {
|
|
|
25
29
|
}
|
|
26
30
|
return true;
|
|
27
31
|
}
|
|
32
|
+
|
|
33
|
+
/** Runs `command` with `args`, `input` on stdin, and resolves with stdout; rejects with stderr when it fails. */
|
|
34
|
+
function runWithInput(command, args, input) {
|
|
35
|
+
return new Promise((resolve, reject) => {
|
|
36
|
+
const child = spawn(command, args, { stdio: ['pipe', 'pipe', 'pipe'] });
|
|
37
|
+
let out = '';
|
|
38
|
+
let err = '';
|
|
39
|
+
child.stdout.on('data', (d) => {
|
|
40
|
+
out += d;
|
|
41
|
+
});
|
|
42
|
+
child.stderr.on('data', (d) => {
|
|
43
|
+
err += d;
|
|
44
|
+
});
|
|
45
|
+
child.on('error', reject);
|
|
46
|
+
child.on('close', (code) =>
|
|
47
|
+
code === 0 ? resolve(out) : reject(Object.assign(new Error(err.trim() || `${command} exited ${code}`), { code })),
|
|
48
|
+
);
|
|
49
|
+
child.stdin.end(input ?? '');
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* A hook's request to the board (BRK-86). Claude Code runs hooks with the image's default Node, which in a cloud session
|
|
55
|
+
* can be too old to send fetch through the session proxy (Node 20 has neither http.setGlobalProxyFromEnv nor the CA
|
|
56
|
+
* calls above), and the sandbox refuses a request that goes around it. So with a proxy the request goes through curl,
|
|
57
|
+
* which reads HTTPS_PROXY and the system's certificates on any Node; without curl, or without a proxy, through fetch.
|
|
58
|
+
* Answers like fetch's response: { ok, status, json() }.
|
|
59
|
+
* @param {string} url
|
|
60
|
+
* @param {{ method?: string, headers?: Record<string, string>, body?: string, timeoutMs?: number }} [request]
|
|
61
|
+
*/
|
|
62
|
+
export async function sessionRequest(
|
|
63
|
+
url,
|
|
64
|
+
{ method = 'GET', headers = {}, body, timeoutMs = 4000 } = {},
|
|
65
|
+
{ env = process.env, run = runWithInput, fetch = globalThis.fetch } = {},
|
|
66
|
+
) {
|
|
67
|
+
if (sessionProxy(env)) {
|
|
68
|
+
const args = ['-sS', '--max-time', String(Math.max(1, Math.ceil(timeoutMs / 1000))), '-X', method];
|
|
69
|
+
for (const [name, value] of Object.entries(headers)) args.push('-H', `${name}: ${value}`);
|
|
70
|
+
if (body !== undefined) args.push('--data-binary', '@-');
|
|
71
|
+
args.push('-w', '\n%{http_code}', url);
|
|
72
|
+
try {
|
|
73
|
+
const out = await run('curl', args, body);
|
|
74
|
+
const at = out.lastIndexOf('\n');
|
|
75
|
+
const status = Number(out.slice(at + 1));
|
|
76
|
+
const text = out.slice(0, Math.max(0, at));
|
|
77
|
+
return { ok: status >= 200 && status < 300, status, json: async () => (text.trim() ? JSON.parse(text) : null) };
|
|
78
|
+
} catch (error) {
|
|
79
|
+
if (/** @type {any} */ (error)?.code !== 'ENOENT') throw error;
|
|
80
|
+
// No curl here: fetch, through the proxy if this Node can.
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
routeThroughSessionProxy(env);
|
|
84
|
+
return fetch(url, { method, headers, body, signal: AbortSignal.timeout(timeoutMs) });
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Where a hook notes why it couldn't post for a task (BRK-86): the system's temp folder, never the repository, where a
|
|
89
|
+
* cloud session's Stop check would take a new file as work to commit. The CLI shows it on its next command here.
|
|
90
|
+
*/
|
|
91
|
+
const failurePath = (uuid, dir) => join(dir, `breakaway-hook-${String(uuid).replace(/[^\w-]/gu, '')}.json`);
|
|
92
|
+
|
|
93
|
+
/** Records that the hook couldn't post for task `uuid`, and why. */
|
|
94
|
+
export function noteHookFailure(uuid, reason, dir = tmpdir(), at = new Date()) {
|
|
95
|
+
try {
|
|
96
|
+
writeFileSync(
|
|
97
|
+
failurePath(uuid, dir),
|
|
98
|
+
`${JSON.stringify({ at: at.toISOString(), reason: String(reason).slice(0, 300) })}\n`,
|
|
99
|
+
);
|
|
100
|
+
} catch {
|
|
101
|
+
/* nowhere to note it */
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Clears that note, after a post that went through. */
|
|
106
|
+
export function clearHookFailure(uuid, dir = tmpdir()) {
|
|
107
|
+
rmSync(failurePath(uuid, dir), { force: true });
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** The hook's last failure for task `uuid`, or null. */
|
|
111
|
+
export function hookFailure(uuid, dir = tmpdir()) {
|
|
112
|
+
try {
|
|
113
|
+
return JSON.parse(readFileSync(failurePath(uuid, dir), 'utf8'));
|
|
114
|
+
} catch {
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
@@ -7,10 +7,11 @@
|
|
|
7
7
|
* additionalContext so Claude gets them on its next turn (docs/specs/IDEA-15-message-a-running-agent.md).
|
|
8
8
|
*
|
|
9
9
|
* Quiet by design: without a claimed task (.task-session, written by `tasks claim`), with
|
|
10
|
-
* BREAKAWAY_SESSION_LOG=off (or SAMEWAVE_TASKS_SESSION_LOG=off), or on any error, it does nothing and exits 0.
|
|
10
|
+
* BREAKAWAY_SESSION_LOG=off (or SAMEWAVE_TASKS_SESSION_LOG=off), or on any error, it does nothing and exits 0. A post
|
|
11
|
+
* that fails leaves its reason in the temp folder, which the CLI's next command here shows (BRK-86).
|
|
11
12
|
*/
|
|
12
13
|
import { boardConfig, claimedTask, projectRoot } from './hook-config.js';
|
|
13
|
-
import {
|
|
14
|
+
import { clearHookFailure, noteHookFailure, sessionRequest } from './proxy.js';
|
|
14
15
|
import { entryFor } from './session-log.js';
|
|
15
16
|
import { CONTEXT_EVENTS, messageOutput } from './session-messages.js';
|
|
16
17
|
|
|
@@ -26,22 +27,28 @@ async function main() {
|
|
|
26
27
|
if (!entry) return;
|
|
27
28
|
|
|
28
29
|
const { base, headers } = boardConfig(root);
|
|
29
|
-
if (!base) return;
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
30
|
+
if (!base) return noteHookFailure(claim.uuid, 'no board address: set BREAKAWAY_URL');
|
|
31
|
+
let res;
|
|
32
|
+
try {
|
|
33
|
+
// Through curl in a cloud session, so it works on whichever Node runs hooks there (BRK-86).
|
|
34
|
+
res = await sessionRequest(`${base}/api/tasks/${encodeURIComponent(claim.uuid)}/session`, {
|
|
35
|
+
method: 'POST',
|
|
36
|
+
headers: { 'Content-Type': 'application/json', ...headers },
|
|
37
|
+
body: JSON.stringify({
|
|
38
|
+
agent: claim.agent,
|
|
39
|
+
session: hook.session_id ?? null,
|
|
40
|
+
remote: process.env.CLAUDE_CODE_REMOTE === 'true',
|
|
41
|
+
entries: [entry],
|
|
42
|
+
// A Stop hook's output can't reach Claude, so it leaves the messages for the next event.
|
|
43
|
+
messages: CONTEXT_EVENTS.has(hook.hook_event_name),
|
|
44
|
+
}),
|
|
45
|
+
timeoutMs: 4000,
|
|
46
|
+
});
|
|
47
|
+
} catch (error) {
|
|
48
|
+
return noteHookFailure(claim.uuid, /** @type {Error} */ (error)?.message ?? String(error));
|
|
49
|
+
}
|
|
50
|
+
if (!res.ok) return noteHookFailure(claim.uuid, `HTTP ${res.status}`);
|
|
51
|
+
clearHookFailure(claim.uuid);
|
|
45
52
|
const output = messageOutput(await res.json(), hook.hook_event_name);
|
|
46
53
|
if (output) process.stdout.write(`${JSON.stringify(output)}\n`);
|
|
47
54
|
}
|
package/scripts/tasks.mjs
CHANGED
|
@@ -40,7 +40,7 @@ import {
|
|
|
40
40
|
} from './tasks/structure.js';
|
|
41
41
|
import { looksLikeSecret } from '../src/ping.js';
|
|
42
42
|
import { promptPathOf } from '../src/repos.js';
|
|
43
|
-
import { sessionProxy, routeThroughSessionProxy } from './tasks/proxy.js';
|
|
43
|
+
import { hookFailure, sessionProxy, routeThroughSessionProxy } from './tasks/proxy.js';
|
|
44
44
|
import { githubFromRemote, inRepo, pickRepo } from './tasks/repo.js';
|
|
45
45
|
import { NO_TERMINAL, ask as askIn } from './tasks/ask.js';
|
|
46
46
|
import { CLI_PACKAGE, PROMPT_SECTIONS, initPlan, machineTaskrc, promptSections } from './tasks/init.js';
|
|
@@ -526,6 +526,20 @@ async function startSessionLog(t) {
|
|
|
526
526
|
);
|
|
527
527
|
}
|
|
528
528
|
|
|
529
|
+
/** The session hook couldn't post this checkout's live output: say so where the agent and the owner see it (BRK-86). */
|
|
530
|
+
function warnHookFailure() {
|
|
531
|
+
try {
|
|
532
|
+
const claim = JSON.parse(readFileSync(join(REPO, '.task-session'), 'utf8'));
|
|
533
|
+
const failed = claim?.uuid && hookFailure(claim.uuid);
|
|
534
|
+
if (failed)
|
|
535
|
+
console.error(
|
|
536
|
+
`tasks: the session hook couldn't post ${claim.wid ?? 'this task'}'s live output (${failed.reason}, ${failed.at.slice(0, 16).replace('T', ' ')} UTC), so the board shows none. docs/tasks.md#cloud-agents says what to check.`,
|
|
537
|
+
);
|
|
538
|
+
} catch {
|
|
539
|
+
/* no claim here */
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
|
|
529
543
|
function unmarkSession(t) {
|
|
530
544
|
try {
|
|
531
545
|
const path = join(REPO, '.task-session');
|
|
@@ -1959,5 +1973,6 @@ if (opts.help || command === 'help') {
|
|
|
1959
1973
|
} else if (unknownSubcommand(command, args[0])) {
|
|
1960
1974
|
fail(unknownSubcommand(command, args[0]));
|
|
1961
1975
|
} else {
|
|
1976
|
+
if (command !== 'hook') warnHookFailure();
|
|
1962
1977
|
await commands[command]();
|
|
1963
1978
|
}
|
package/src/cli-version.js
CHANGED
|
@@ -5,5 +5,5 @@
|
|
|
5
5
|
* bump CLI_VERSION and set CLI_FINGERPRINT to the value it prints. Constants only: the board's Worker imports it,
|
|
6
6
|
* so it lives in the board's package (CLD-135) and the CLI imports it from here.
|
|
7
7
|
*/
|
|
8
|
-
export const CLI_VERSION =
|
|
9
|
-
export const CLI_FINGERPRINT = '
|
|
8
|
+
export const CLI_VERSION = 40;
|
|
9
|
+
export const CLI_FINGERPRINT = '37624c98bbeb5108';
|