@sweny-ai/core 0.1.150 → 0.1.152

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.
@@ -33,6 +33,7 @@ const LEGACY_WRITE_TOOLS = {
33
33
  comment: ["github_add_comment", "linear_add_comment", "linear_update_issue"],
34
34
  pr: ["github_create_pr"],
35
35
  label: ["github_add_labels"],
36
+ issue_state: ["linear_update_issue", "linear_set_issue_state", "github_set_issue_state"],
36
37
  };
37
38
  const writeToolsBySkill = new Map(builtinSkills.map((s) => [s.id, s.tools.filter((t) => t.access !== "read").map((t) => t.name)]));
38
39
  function skillWriteTools(node) {
@@ -59,7 +60,7 @@ export function readGaps(p) {
59
60
  }
60
61
  /** The skill that applies this output type for these providers, or undefined when none can. */
61
62
  export function outputSkill(type, p) {
62
- if (type === "issue" || type === "comment")
63
+ if (type === "issue" || type === "comment" || type === "issue_state")
63
64
  return TRACKER_SKILL[p.issueTracker];
64
65
  return p.sourceControl === "github" ? "github" : undefined;
65
66
  }
package/dist/cli/main.js CHANGED
@@ -1313,11 +1313,12 @@ workflowCmd
1313
1313
  program
1314
1314
  .command("tool-bridge", { hidden: true })
1315
1315
  .description("Internal: stdio MCP shim for sweny skill tools")
1316
- .requiredOption("--socket <path>", "Per-run bridge socket")
1316
+ .option("--socket <path>", "Per-run bridge socket")
1317
+ .option("--connect <host:port>", "Per-run bridge TCP endpoint (inside the process sandbox, via its proxy)")
1317
1318
  .option("--token <token>", "Per-run token (default: SWENY_TOOL_BRIDGE_TOKEN)")
1318
1319
  .action(async (options) => {
1319
1320
  const { runToolBridgeShim } = await import("../harness/tool-bridge/shim.js");
1320
- await runToolBridgeShim({ socket: options.socket, token: options.token, version });
1321
+ await runToolBridgeShim({ socket: options.socket, connect: options.connect, token: options.token, version });
1321
1322
  });
1322
1323
  // ── sweny upgrade / update ────────────────────────────────────────────
1323
1324
  // Self-update the globally-installed @sweny-ai/core. Mirrors the UX of
@@ -771,15 +771,24 @@ export class PiHarness {
771
771
  tools: req.tools,
772
772
  context: this.defaultContext,
773
773
  logger: this.logger,
774
+ // Wrapped, the shim cannot open the unix socket (srt blocks AF_UNIX on
775
+ // Linux); it tunnels to a loopback port through the sandbox proxy (#439).
776
+ tcp: wrapper !== undefined,
774
777
  ...(this.toolBridgeShim ? { shimCommand: this.toolBridgeShim } : {}),
775
778
  });
776
779
  const shim = bridge.mcpServer;
777
780
  // The token reaches the shim by reference (`${VAR}`), never in a file or argv.
778
781
  env[TOKEN_ENV] = bridge.token;
782
+ const ref = (name) => "${" + name + "}";
779
783
  entries[PI_BRIDGE_SERVER] = {
780
784
  command: shim.command,
781
785
  args: shim.args ?? [],
782
- env: { SWENY_NO_UPDATE_CHECK: "1", [TOKEN_ENV]: "${" + TOKEN_ENV + "}" },
786
+ env: {
787
+ SWENY_NO_UPDATE_CHECK: "1",
788
+ [TOKEN_ENV]: ref(TOKEN_ENV),
789
+ // The proxy srt sets for pi, which pi's MCP client does not pass on by itself.
790
+ ...(bridge.tcp ? { HTTP_PROXY: ref("HTTP_PROXY"), http_proxy: ref("http_proxy") } : {}),
791
+ },
783
792
  exposure: "direct",
784
793
  timeout: 600,
785
794
  };
@@ -802,8 +811,10 @@ export class PiHarness {
802
811
  toolArgs.push("--exclude-tools", [...excluded].join(","));
803
812
  }
804
813
  const args = [...this.baseArgs(model, Object.keys(entries).length > 0), ...toolArgs];
805
- const harnessEgress = piBackendHosts(process.env, model);
806
- if (mode !== "off" && harnessEgress.length === 0 && !this.egressWarned) {
814
+ const backendHosts = piBackendHosts(process.env, model);
815
+ // The bridge's loopback endpoint (wrapped runs only): the one extra host the shim needs.
816
+ const harnessEgress = [...backendHosts, ...(bridge?.egress ?? [])];
817
+ if (mode !== "off" && backendHosts.length === 0 && !this.egressWarned) {
807
818
  this.egressWarned = true;
808
819
  this.logger.warn("pi runs inside the sandbox wrapper with no known model API host; add it with SWENY_SANDBOX_ALLOWED_DOMAINS.");
809
820
  }
@@ -9,6 +9,11 @@
9
9
  * Security properties (this is an injection boundary):
10
10
  * - The socket lives in a fresh `mkdtemp` directory (mode 0700) and is itself
11
11
  * chmod 0600, so other users cannot connect.
12
+ * - With `tcp` (only for agents inside the process sandbox wrapper, #439) the
13
+ * same handler also listens on 127.0.0.1:<random port>. Other local users
14
+ * can open that port, so there the token is the only gate; the sandboxed
15
+ * agent reaches it through srt's proxy only because the adapter allowlists
16
+ * exactly that host:port for the run.
12
17
  * - A random 256-bit token is required on every frame. A wrong or missing
13
18
  * token gets an `unauthorized` error and the connection is dropped.
14
19
  * - Only the tools passed in are listed or callable. The executor has already
@@ -37,12 +42,27 @@ export interface ToolBridgeOptions {
37
42
  };
38
43
  /** Parent directory for the private socket dir. Default: `os.tmpdir()`. */
39
44
  tmpDir?: string;
45
+ /**
46
+ * Also serve on a loopback TCP port, and point the shim there (#439). For an
47
+ * agent inside the process sandbox wrapper: srt blocks `socket(AF_UNIX)` on
48
+ * Linux, so the shim cannot reach the unix socket, but it can reach an
49
+ * allowlisted host through srt's egress proxy. The adapter adds
50
+ * {@link ToolBridge.egress} to the node's egress; nothing else is opened.
51
+ */
52
+ tcp?: boolean;
40
53
  }
41
54
  export interface ToolBridge {
42
55
  /** Absolute path of the unix socket. */
43
56
  readonly socketPath: string;
44
57
  /** Per-run token. Never log it. */
45
58
  readonly token: string;
59
+ /** Loopback TCP endpoint, when started with `tcp: true`. */
60
+ readonly tcp?: {
61
+ host: string;
62
+ port: number;
63
+ };
64
+ /** Hosts a sandboxed shim must reach (`127.0.0.1:<port>` with `tcp`, else none). */
65
+ readonly egress: string[];
46
66
  /** stdio MCP server config a harness uses to start the shim. The token rides in `env`, not argv. */
47
67
  readonly mcpServer: McpServerConfig;
48
68
  /** Stop serving, drop connections, remove the socket and its directory. Idempotent. */
@@ -9,6 +9,11 @@
9
9
  * Security properties (this is an injection boundary):
10
10
  * - The socket lives in a fresh `mkdtemp` directory (mode 0700) and is itself
11
11
  * chmod 0600, so other users cannot connect.
12
+ * - With `tcp` (only for agents inside the process sandbox wrapper, #439) the
13
+ * same handler also listens on 127.0.0.1:<random port>. Other local users
14
+ * can open that port, so there the token is the only gate; the sandboxed
15
+ * agent reaches it through srt's proxy only because the adapter allowlists
16
+ * exactly that host:port for the run.
12
17
  * - A random 256-bit token is required on every frame. A wrong or missing
13
18
  * token gets an `unauthorized` error and the connection is dropped.
14
19
  * - Only the tools passed in are listed or callable. The executor has already
@@ -98,7 +103,7 @@ export async function startToolBridge(opts) {
98
103
  liveDirs.add(dir);
99
104
  installCrashHooks();
100
105
  const sockets = new Set();
101
- const server = net.createServer((sock) => {
106
+ const onConnection = (sock) => {
102
107
  sockets.add(sock);
103
108
  sock.on("close", () => sockets.delete(sock));
104
109
  sock.on("error", () => sock.destroy());
@@ -143,19 +148,29 @@ export async function startToolBridge(opts) {
143
148
  sock.destroy();
144
149
  });
145
150
  sock.on("data", read);
151
+ };
152
+ const server = net.createServer(onConnection);
153
+ // Same handler and token check; only started for sandboxed agents.
154
+ const tcpServer = opts.tcp ? net.createServer(onConnection) : undefined;
155
+ const listen = (s, options) => new Promise((resolve, reject) => {
156
+ s.once("error", reject);
157
+ s.listen(options, () => {
158
+ s.removeListener("error", reject);
159
+ resolve();
160
+ });
146
161
  });
162
+ let tcp;
147
163
  try {
148
- await new Promise((resolve, reject) => {
149
- server.once("error", reject);
150
- server.listen(socketPath, () => {
151
- server.removeListener("error", reject);
152
- resolve();
153
- });
154
- });
164
+ await listen(server, { path: socketPath });
155
165
  fs.chmodSync(socketPath, 0o600);
166
+ if (tcpServer) {
167
+ await listen(tcpServer, { port: 0, host: "127.0.0.1" });
168
+ tcp = { host: "127.0.0.1", port: tcpServer.address().port };
169
+ }
156
170
  }
157
171
  catch (err) {
158
172
  server.close();
173
+ tcpServer?.close();
159
174
  liveDirs.delete(dir);
160
175
  removeDirSync(dir);
161
176
  uninstallCrashHooksIfIdle();
@@ -167,10 +182,12 @@ export async function startToolBridge(opts) {
167
182
  return {
168
183
  socketPath,
169
184
  token,
185
+ tcp,
186
+ egress: tcp ? [`${tcp.host}:${tcp.port}`] : [],
170
187
  mcpServer: {
171
188
  type: "stdio",
172
189
  command: shim.command,
173
- args: [...shim.args, "--socket", socketPath],
190
+ args: [...shim.args, ...(tcp ? ["--connect", `${tcp.host}:${tcp.port}`] : ["--socket", socketPath])],
174
191
  // No update-check nudge in the shim: its stdio belongs to MCP.
175
192
  env: { [TOKEN_ENV]: token, SWENY_NO_UPDATE_CHECK: "1" },
176
193
  },
@@ -180,7 +197,7 @@ export async function startToolBridge(opts) {
180
197
  closed = true;
181
198
  for (const s of sockets)
182
199
  s.destroy();
183
- await new Promise((resolve) => server.close(() => resolve()));
200
+ await Promise.all([server, tcpServer].map((s) => (s ? new Promise((resolve) => s.close(() => resolve())) : undefined)));
184
201
  liveDirs.delete(dir);
185
202
  removeDirSync(dir);
186
203
  uninstallCrashHooksIfIdle();
@@ -1,5 +1,7 @@
1
1
  /**
2
- * SwenyToolBridge shim (#414): `sweny tool-bridge --socket <path>`.
2
+ * SwenyToolBridge shim (#414): `sweny tool-bridge --socket <path>`, or
3
+ * `--connect 127.0.0.1:<port>` inside the process sandbox (#439), where it
4
+ * tunnels through the sandbox's HTTP proxy.
3
5
  *
4
6
  * A stdio MCP server a harness starts as a child process. It holds no tools
5
7
  * of its own: `tools/list` and `tools/call` are forwarded over the per-run
@@ -9,17 +11,49 @@
9
11
  * `--token` or, by default, the `SWENY_TOOL_BRIDGE_TOKEN` env var the server
10
12
  * puts in the MCP server config, so it never shows up in `ps` output.
11
13
  */
14
+ import * as net from "node:net";
12
15
  import { type BridgeMethod, type BridgeResponse } from "./protocol.js";
16
+ /** Where the bridge listens: the per-run unix socket, or its loopback TCP port (#439). */
17
+ export type BridgeEndpoint = {
18
+ socketPath: string;
19
+ } | {
20
+ host: string;
21
+ port: number;
22
+ };
23
+ /** Parse `--connect host:port` (IPv4 literal or `localhost`, or `[v6]:port`). */
24
+ export declare function parseConnectTarget(value: string): {
25
+ host: string;
26
+ port: number;
27
+ } | undefined;
28
+ /**
29
+ * The HTTP proxy a sandboxed shim must tunnel through, from its env. srt sets
30
+ * HTTP_PROXY / HTTPS_PROXY (with its per-run credentials) for everything it
31
+ * wraps; inside the sandbox nothing else reaches the host. NO_PROXY is
32
+ * ignored on purpose: srt lists 127.0.0.1 there, but inside the sandbox's own
33
+ * network namespace 127.0.0.1 is not the host.
34
+ */
35
+ export declare function bridgeProxyFromEnv(env: NodeJS.ProcessEnv): URL | undefined;
36
+ /**
37
+ * Open a byte stream to the bridge. A TCP endpoint goes through an HTTP
38
+ * CONNECT tunnel when the env names a proxy (the sandboxed case), else
39
+ * straight to the port. `leftover` holds any bytes read past the proxy's
40
+ * response header.
41
+ */
42
+ export declare function openBridgeStream(endpoint: BridgeEndpoint, env?: NodeJS.ProcessEnv): Promise<{
43
+ sock: net.Socket;
44
+ leftover: Buffer;
45
+ }>;
13
46
  /** Socket client that multiplexes requests by id over one connection. */
14
47
  export declare class BridgeClient {
15
- private readonly socketPath;
16
48
  private readonly token;
49
+ private readonly env;
17
50
  private sock;
18
51
  private connecting;
19
52
  private nextId;
20
53
  private pending;
21
54
  private closedError;
22
- constructor(socketPath: string, token: string);
55
+ private readonly endpoint;
56
+ constructor(endpoint: string | BridgeEndpoint, token: string, env?: NodeJS.ProcessEnv);
23
57
  private connect;
24
58
  private failAll;
25
59
  request(method: BridgeMethod, params?: {
@@ -29,7 +63,10 @@ export declare class BridgeClient {
29
63
  close(): void;
30
64
  }
31
65
  export interface ShimOptions {
32
- socket: string;
66
+ /** The per-run unix socket. */
67
+ socket?: string;
68
+ /** Or the bridge's loopback TCP endpoint, `host:port` (sandboxed agents, #439). */
69
+ connect?: string;
33
70
  /** Falls back to `SWENY_TOOL_BRIDGE_TOKEN`. */
34
71
  token?: string;
35
72
  version?: string;
@@ -1,5 +1,7 @@
1
1
  /**
2
- * SwenyToolBridge shim (#414): `sweny tool-bridge --socket <path>`.
2
+ * SwenyToolBridge shim (#414): `sweny tool-bridge --socket <path>`, or
3
+ * `--connect 127.0.0.1:<port>` inside the process sandbox (#439), where it
4
+ * tunnels through the sandbox's HTTP proxy.
3
5
  *
4
6
  * A stdio MCP server a harness starts as a child process. It holds no tools
5
7
  * of its own: `tools/list` and `tools/call` are forwarded over the per-run
@@ -14,18 +16,116 @@ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
14
16
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
15
17
  import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
16
18
  import { BRIDGE_PROTOCOL_VERSION, TOKEN_ENV, createFrameReader, encodeFrame, } from "./protocol.js";
19
+ /** Parse `--connect host:port` (IPv4 literal or `localhost`, or `[v6]:port`). */
20
+ export function parseConnectTarget(value) {
21
+ const m = /^(?:\[([0-9a-fA-F:.]+)\]|([A-Za-z0-9.-]+)):([1-9][0-9]{0,4})$/.exec(value);
22
+ if (!m)
23
+ return undefined;
24
+ const port = Number(m[3]);
25
+ return port <= 65535 ? { host: m[1] ?? m[2], port } : undefined;
26
+ }
27
+ /**
28
+ * The HTTP proxy a sandboxed shim must tunnel through, from its env. srt sets
29
+ * HTTP_PROXY / HTTPS_PROXY (with its per-run credentials) for everything it
30
+ * wraps; inside the sandbox nothing else reaches the host. NO_PROXY is
31
+ * ignored on purpose: srt lists 127.0.0.1 there, but inside the sandbox's own
32
+ * network namespace 127.0.0.1 is not the host.
33
+ */
34
+ export function bridgeProxyFromEnv(env) {
35
+ for (const k of ["HTTP_PROXY", "http_proxy", "HTTPS_PROXY", "https_proxy", "ALL_PROXY", "all_proxy"]) {
36
+ const v = env[k];
37
+ if (!v)
38
+ continue;
39
+ try {
40
+ const u = new URL(v);
41
+ if (u.protocol === "http:")
42
+ return u;
43
+ }
44
+ catch {
45
+ // not a URL: try the next variable
46
+ }
47
+ }
48
+ return undefined;
49
+ }
50
+ const MAX_PROXY_RESPONSE_BYTES = 16 * 1024;
51
+ /**
52
+ * Open a byte stream to the bridge. A TCP endpoint goes through an HTTP
53
+ * CONNECT tunnel when the env names a proxy (the sandboxed case), else
54
+ * straight to the port. `leftover` holds any bytes read past the proxy's
55
+ * response header.
56
+ */
57
+ export function openBridgeStream(endpoint, env = process.env) {
58
+ const direct = (sock) => new Promise((resolve, reject) => {
59
+ sock.once("error", reject);
60
+ sock.once("connect", () => {
61
+ sock.removeListener("error", reject);
62
+ resolve({ sock, leftover: Buffer.alloc(0) });
63
+ });
64
+ });
65
+ if ("socketPath" in endpoint)
66
+ return direct(net.createConnection(endpoint.socketPath));
67
+ const proxy = bridgeProxyFromEnv(env);
68
+ if (!proxy)
69
+ return direct(net.createConnection(endpoint.port, endpoint.host));
70
+ return new Promise((resolve, reject) => {
71
+ const target = endpoint.host.includes(":")
72
+ ? `[${endpoint.host}]:${endpoint.port}`
73
+ : `${endpoint.host}:${endpoint.port}`;
74
+ const sock = net.createConnection(Number(proxy.port || 80), proxy.hostname.replace(/^\[|\]$/g, ""));
75
+ let buf = Buffer.alloc(0);
76
+ let done = false;
77
+ const fail = (err) => {
78
+ if (done)
79
+ return;
80
+ done = true;
81
+ sock.destroy();
82
+ reject(err);
83
+ };
84
+ const onData = (chunk) => {
85
+ buf = Buffer.concat([buf, chunk]);
86
+ const end = buf.indexOf("\r\n\r\n");
87
+ if (end === -1) {
88
+ if (buf.length > MAX_PROXY_RESPONSE_BYTES)
89
+ fail(new Error("tool bridge: malformed proxy response"));
90
+ return;
91
+ }
92
+ const status = buf.subarray(0, buf.indexOf("\r\n")).toString("latin1");
93
+ if (!/^HTTP\/1\.[01] 200(\s|$)/.test(status)) {
94
+ fail(new Error(`tool bridge: the sandbox proxy refused ${target} (${status})`));
95
+ return;
96
+ }
97
+ done = true;
98
+ sock.removeListener("data", onData);
99
+ sock.removeListener("error", fail);
100
+ sock.removeListener("close", onClose);
101
+ resolve({ sock, leftover: buf.subarray(end + 4) });
102
+ };
103
+ const onClose = () => fail(new Error("tool bridge: the sandbox proxy closed the connection"));
104
+ sock.on("data", onData);
105
+ sock.once("error", fail);
106
+ sock.once("close", onClose);
107
+ sock.once("connect", () => {
108
+ const user = decodeURIComponent(proxy.username);
109
+ const pass = decodeURIComponent(proxy.password);
110
+ const auth = user || pass ? `Proxy-Authorization: Basic ${Buffer.from(`${user}:${pass}`).toString("base64")}\r\n` : "";
111
+ sock.write(`CONNECT ${target} HTTP/1.1\r\nHost: ${target}\r\n${auth}\r\n`);
112
+ });
113
+ });
114
+ }
17
115
  /** Socket client that multiplexes requests by id over one connection. */
18
116
  export class BridgeClient {
19
- socketPath;
20
117
  token;
118
+ env;
21
119
  sock;
22
120
  connecting;
23
121
  nextId = 1;
24
122
  pending = new Map();
25
123
  closedError;
26
- constructor(socketPath, token) {
27
- this.socketPath = socketPath;
124
+ endpoint;
125
+ constructor(endpoint, token, env = process.env) {
28
126
  this.token = token;
127
+ this.env = env;
128
+ this.endpoint = typeof endpoint === "string" ? { socketPath: endpoint } : endpoint;
29
129
  }
30
130
  connect() {
31
131
  // Once the connection is gone (bad token, server closed) every later call fails fast.
@@ -33,8 +133,7 @@ export class BridgeClient {
33
133
  return Promise.reject(this.closedError);
34
134
  if (this.sock)
35
135
  return Promise.resolve(this.sock);
36
- this.connecting ??= new Promise((resolve, reject) => {
37
- const sock = net.createConnection(this.socketPath);
136
+ this.connecting ??= openBridgeStream(this.endpoint, this.env).then(({ sock, leftover }) => {
38
137
  const read = createFrameReader((value) => {
39
138
  const res = value;
40
139
  if (!res || typeof res.id !== "number")
@@ -50,15 +149,15 @@ export class BridgeClient {
50
149
  }
51
150
  }, () => this.failAll(new Error("tool bridge: response frame too large")));
52
151
  sock.on("data", read);
53
- sock.once("connect", () => {
54
- this.sock = sock;
55
- resolve(sock);
56
- });
57
- sock.once("error", (err) => {
58
- reject(err);
59
- this.failAll(err);
60
- });
152
+ sock.once("error", (err) => this.failAll(err));
61
153
  sock.once("close", () => this.failAll(new Error("tool bridge: connection closed")));
154
+ this.sock = sock;
155
+ if (leftover.length > 0)
156
+ read(leftover);
157
+ return sock;
158
+ }, (err) => {
159
+ this.failAll(err);
160
+ throw err;
62
161
  });
63
162
  return this.connecting;
64
163
  }
@@ -89,13 +188,15 @@ export class BridgeClient {
89
188
  /** Run the stdio MCP shim until stdin closes or the bridge goes away. */
90
189
  export async function runToolBridgeShim(opts) {
91
190
  const token = opts.token ?? process.env[TOKEN_ENV];
92
- if (!opts.socket || !token) {
93
- process.stderr.write(`sweny tool-bridge: --socket and a token (--token or ${TOKEN_ENV}) are required\n`);
191
+ const tcp = opts.connect ? parseConnectTarget(opts.connect) : undefined;
192
+ const endpoint = opts.socket ? { socketPath: opts.socket } : tcp;
193
+ if (!endpoint || (opts.socket && opts.connect) || (opts.connect && !tcp) || !token) {
194
+ process.stderr.write(`sweny tool-bridge: one of --socket <path> or --connect <host:port>, and a token (--token or ${TOKEN_ENV}), are required\n`);
94
195
  process.exit(2);
95
196
  }
96
197
  // Keep the token out of anything the shim might spawn or report.
97
198
  delete process.env[TOKEN_ENV];
98
- const client = new BridgeClient(opts.socket, token);
199
+ const client = new BridgeClient(endpoint, token);
99
200
  const server = new Server({ name: "sweny-core", version: opts.version ?? "0.0.0" }, { capabilities: { tools: {} } });
100
201
  server.setRequestHandler(ListToolsRequestSchema, async () => {
101
202
  const res = await client.request("tools/list");
@@ -9,7 +9,7 @@
9
9
  * the write, so this holds on every harness.
10
10
  *
11
11
  * Order of checks per intent (all deterministic): declared type, workflow
12
- * ceiling, actor trust, expiry, skill, target pin, issue pin, shape and size,
12
+ * ceiling, actor trust, expiry, skill, target pin, issue pin, state, shape and size,
13
13
  * labels, title prefix, dedupe, node cap, run cap. An optional model screen runs last
14
14
  * over the intents that passed and can only veto them.
15
15
  */
@@ -31,6 +31,8 @@ export interface SafeOutputIntent {
31
31
  labels?: string[];
32
32
  head?: string;
33
33
  base?: string;
34
+ /** issue_state: `reopen` or `close`. */
35
+ state?: string;
34
36
  dedupe_key?: string;
35
37
  /** ms since epoch when the intent was recorded. */
36
38
  recordedAt: number;
@@ -9,7 +9,7 @@
9
9
  * the write, so this holds on every harness.
10
10
  *
11
11
  * Order of checks per intent (all deterministic): declared type, workflow
12
- * ceiling, actor trust, expiry, skill, target pin, issue pin, shape and size,
12
+ * ceiling, actor trust, expiry, skill, target pin, issue pin, state, shape and size,
13
13
  * labels, title prefix, dedupe, node cap, run cap. An optional model screen runs last
14
14
  * over the intents that passed and can only veto them.
15
15
  */
@@ -49,7 +49,7 @@ export function createEmitOutputTool(declarations, buffer, now = Date.now) {
49
49
  return {
50
50
  name: EMIT_OUTPUT_TOOL,
51
51
  access: "read",
52
- description: "Request a write (comment, issue, pr or label). This does NOT write anything now: sweny checks the request " +
52
+ description: "Request a write (comment, issue, pr, label or issue_state). This does NOT write anything now: sweny checks the request " +
53
53
  "against this step's declared outputs and limits, and applies it after the step finishes. Call once per write. " +
54
54
  `Allowed types here: ${types.join(", ")}.`,
55
55
  input_schema: {
@@ -64,8 +64,9 @@ export function createEmitOutputTool(declarations, buffer, now = Date.now) {
64
64
  },
65
65
  number: {
66
66
  type: "string",
67
- description: "Issue or PR number (GitHub) or issue id (Linear), for comment and label",
67
+ description: "Issue or PR number (GitHub) or issue id (Linear), for comment, label and issue_state",
68
68
  },
69
+ state: { type: "string", enum: ["reopen", "close"], description: "issue_state only: reopen or close" },
69
70
  labels: { type: "array", items: { type: "string" }, description: "Labels to add" },
70
71
  head: { type: "string", description: "pr only: branch with the changes" },
71
72
  base: { type: "string", description: "pr only: target branch (default main)" },
@@ -88,7 +89,7 @@ export function createEmitOutputTool(declarations, buffer, now = Date.now) {
88
89
  return { recorded: false, error: `limit reached: at most ${max} ${type} write(s) from this step` };
89
90
  }
90
91
  const intent = { type, recordedAt: now() };
91
- const fields = ["title", "body", "target", "number", "head", "base", "dedupe_key"];
92
+ const fields = ["title", "body", "target", "number", "head", "base", "state", "dedupe_key"];
92
93
  for (const f of fields) {
93
94
  const v = str(input?.[f]);
94
95
  if (v !== undefined)
@@ -117,6 +118,8 @@ export function safeOutputsInstruction(declarations, input) {
117
118
  const pin = resolvePin(d.number, input);
118
119
  parts.push(pin ? `only issue or PR ${pin} (the number may be omitted)` : "pinned issue not set for this run");
119
120
  }
121
+ if (d.type === "issue_state")
122
+ parts.push(d.state ? `only to ${d.state}` : "reopen or close");
120
123
  if (d.title_prefix)
121
124
  parts.push(`title prefix "${d.title_prefix}"`);
122
125
  if (d.type === "label" && d.labels?.length)
@@ -247,6 +250,11 @@ const APPLIERS = {
247
250
  needsTarget: true,
248
251
  build: (i) => ({ repo: i.target, issue_number: Number(i.number), labels: i.labels }),
249
252
  },
253
+ issue_state: {
254
+ tool: "github_set_issue_state",
255
+ needsTarget: true,
256
+ build: (i) => ({ repo: i.target, issue_number: Number(i.number), state: i.state }),
257
+ },
250
258
  },
251
259
  linear: {
252
260
  comment: {
@@ -264,6 +272,11 @@ const APPLIERS = {
264
272
  ...(i.labels ? { labelIds: i.labels } : {}),
265
273
  }),
266
274
  },
275
+ issue_state: {
276
+ tool: "linear_set_issue_state",
277
+ needsTarget: false,
278
+ build: (i) => ({ issueId: i.number, state: i.state }),
279
+ },
267
280
  },
268
281
  };
269
282
  function dedupeKey(w, explicit) {
@@ -279,6 +292,7 @@ function dedupeKey(w, explicit) {
279
292
  [...(w.labels ?? [])].sort().join(","),
280
293
  w.head ?? "",
281
294
  w.base ?? "",
295
+ ...(w.state ? [w.state] : []),
282
296
  ];
283
297
  return createHash("sha256").update(JSON.stringify(parts)).digest("hex");
284
298
  }
@@ -377,9 +391,11 @@ export async function applySafeOutputs(o) {
377
391
  refuse(type === "comment" && !body.trim() ? "missing body" : "body too long", at);
378
392
  continue;
379
393
  }
380
- // Issue pin: a pinned comment or label may only land on that issue or PR.
394
+ // Issue pin: a pinned comment, label or state change may only land on that issue or PR.
395
+ // issue_state never guesses an issue: it needs the pin or a number on the intent.
396
+ const onIssue = type === "comment" || type === "label" || type === "issue_state";
381
397
  let issueRef = intent.number?.trim().replace(/^#/, "") || undefined;
382
- if ((type === "comment" || type === "label") && decl.number !== undefined) {
398
+ if (onIssue && decl.number !== undefined) {
383
399
  const pin = resolvePin(decl.number, o.input);
384
400
  if (!pin) {
385
401
  refuse("pinned issue is not set for this run", at);
@@ -391,10 +407,29 @@ export async function applySafeOutputs(o) {
391
407
  }
392
408
  issueRef = pin;
393
409
  }
394
- if ((type === "comment" || type === "label") && !issueRef) {
410
+ if (onIssue && !issueRef) {
395
411
  refuse("missing number", at);
396
412
  continue;
397
413
  }
414
+ // State change: reopen or close, inside the declared direction.
415
+ let state;
416
+ if (type === "issue_state") {
417
+ const asked = intent.state?.trim().toLowerCase() || decl.state;
418
+ if (asked !== "reopen" && asked !== "close") {
419
+ refuse("state must be reopen or close", at);
420
+ continue;
421
+ }
422
+ if (decl.state && asked !== decl.state) {
423
+ refuse("state outside the declared state", at);
424
+ continue;
425
+ }
426
+ // Closing needs the declaration to pin the issue (a workflow that skipped validation cannot bypass it).
427
+ if (asked === "close" && decl.number === undefined) {
428
+ refuse("close needs a pinned issue", at);
429
+ continue;
430
+ }
431
+ state = asked;
432
+ }
398
433
  if (via === "github" && issueRef !== undefined && !/^[1-9][0-9]*$/.test(issueRef)) {
399
434
  refuse("number must be an issue or PR number", at);
400
435
  continue;
@@ -432,11 +467,11 @@ export async function applySafeOutputs(o) {
432
467
  refuse("title too long", at);
433
468
  continue;
434
469
  }
435
- // A Linear comment's issue ID does not encode or enforce its declared team.
470
+ // A Linear comment's (or state change's) issue ID does not encode or enforce its declared team.
436
471
  // Resolve it through the configured read tool and carry its immutable ID to
437
472
  // the write. Missing lookup support or unverifiable membership fails closed.
438
473
  let number = issueRef;
439
- if (via === "linear" && type === "comment" && target) {
474
+ if (via === "linear" && (type === "comment" || type === "issue_state") && target) {
440
475
  const lookup = o.skills.get(via)?.tools.find((t) => t.name === "linear_get_issue" && t.access === "read");
441
476
  let issue;
442
477
  try {
@@ -464,7 +499,8 @@ export async function applySafeOutputs(o) {
464
499
  ...(target ? { target } : {}),
465
500
  ...(number !== undefined ? { number } : {}),
466
501
  ...(title ? { title } : {}),
467
- ...(type !== "label" ? { body } : {}),
502
+ ...(type === "comment" || type === "issue" || type === "pr" ? { body } : {}),
503
+ ...(state ? { state } : {}),
468
504
  ...(labels ? { labels } : {}),
469
505
  ...(type === "pr" ? { head: intent.head, ...(intent.base ? { base: intent.base } : {}) } : {}),
470
506
  };
@@ -546,6 +582,8 @@ export async function applySafeOutputs(o) {
546
582
  function printPreview(w, logger) {
547
583
  const where = `${w.target ?? ""}${w.number ? `#${w.number}` : ""}`;
548
584
  logger.info(` [staged] ${w.type} via ${w.via}${where ? ` -> ${where}` : ""}${w.title ? `: ${w.title}` : ""}`);
585
+ if (w.state)
586
+ logger.info(` state: ${w.state}`);
549
587
  if (w.labels?.length)
550
588
  logger.info(` labels: ${w.labels.join(", ")}`);
551
589
  if (w.type === "pr")