flyspace-cli 0.1.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/LICENSE +8 -0
- package/README.md +71 -0
- package/dist/chunk-DCCECKHH.js +8126 -0
- package/dist/chunk-QZIN7MWV.js +431 -0
- package/dist/chunk-X7SJ653V.js +74 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +16690 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +8 -0
- package/dist/threads-EQGJZF44.js +237 -0
- package/hooks/bash-guard.mjs +269 -0
- package/package.json +93 -0
- package/slack-app-manifest.yml +50 -0
- package/system.capabilities.md +156 -0
- package/system.default.md +5 -0
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import {
|
|
3
|
+
listChannels,
|
|
4
|
+
moveThread
|
|
5
|
+
} from "./chunk-X7SJ653V.js";
|
|
6
|
+
import {
|
|
7
|
+
getSupabaseClient,
|
|
8
|
+
postToDashboardChannel
|
|
9
|
+
} from "./chunk-QZIN7MWV.js";
|
|
10
|
+
|
|
11
|
+
// src/mcp/servers/threads.ts
|
|
12
|
+
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
13
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
14
|
+
import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
|
|
15
|
+
var DEFAULT_SEARCH_LIMIT = 10;
|
|
16
|
+
var MAX_SEARCH_LIMIT = 30;
|
|
17
|
+
var SNIPPET_MAX = 300;
|
|
18
|
+
var MAX_REPLIES = 100;
|
|
19
|
+
function truncate(text, max) {
|
|
20
|
+
const flat = text.replaceAll(/\s+/g, " ").trim();
|
|
21
|
+
return flat.length > max ? `${flat.slice(0, max)}\u2026` : flat;
|
|
22
|
+
}
|
|
23
|
+
function formatHits(hits, query) {
|
|
24
|
+
if (!hits.length) return `No threads matching "${query}".`;
|
|
25
|
+
return [
|
|
26
|
+
`${hits.length} thread${hits.length === 1 ? "" : "s"} matching "${query}":`,
|
|
27
|
+
"",
|
|
28
|
+
...hits.map((hit) => {
|
|
29
|
+
const when = hit.createdAt.slice(0, 10);
|
|
30
|
+
const title = hit.title?.trim() || "(untitled)";
|
|
31
|
+
const snippet = truncate(hit.snippet, SNIPPET_MAX);
|
|
32
|
+
const body = snippet && snippet !== truncate(title, SNIPPET_MAX) ? `
|
|
33
|
+
${snippet}` : "";
|
|
34
|
+
return `- **${title}** \u2014 ${hit.author}, ${when}${body}
|
|
35
|
+
id: \`${hit.id}\``;
|
|
36
|
+
})
|
|
37
|
+
].join("\n");
|
|
38
|
+
}
|
|
39
|
+
function clampLimit(raw) {
|
|
40
|
+
if (typeof raw !== "number" || !Number.isFinite(raw)) return DEFAULT_SEARCH_LIMIT;
|
|
41
|
+
return Math.min(Math.max(Math.trunc(raw), 1), MAX_SEARCH_LIMIT);
|
|
42
|
+
}
|
|
43
|
+
function rowsToHits(rows, channelId) {
|
|
44
|
+
return rows.filter((row) => !channelId || row.channel_id === channelId).map((row) => ({
|
|
45
|
+
id: row.id,
|
|
46
|
+
channelId: row.channel_id,
|
|
47
|
+
title: row.title,
|
|
48
|
+
snippet: row.snippet ?? "",
|
|
49
|
+
author: row.author ?? "unknown",
|
|
50
|
+
createdAt: row.created_at
|
|
51
|
+
}));
|
|
52
|
+
}
|
|
53
|
+
async function searchThreads(query, limit, channelId) {
|
|
54
|
+
const matchLimit = channelId ? Math.min(limit * 5, 100) : limit;
|
|
55
|
+
const { data, error } = await getSupabaseClient().rpc("search_threads", {
|
|
56
|
+
q: query,
|
|
57
|
+
match_limit: matchLimit
|
|
58
|
+
});
|
|
59
|
+
if (error) throw new Error(`search failed: ${error.message}`);
|
|
60
|
+
return formatHits(rowsToHits(data ?? [], channelId).slice(0, limit), query);
|
|
61
|
+
}
|
|
62
|
+
function formatThread(thread, replies) {
|
|
63
|
+
const lines = [
|
|
64
|
+
`# ${thread.title?.trim() || "(untitled)"}`,
|
|
65
|
+
"",
|
|
66
|
+
`**${thread.author ?? "unknown"}** \xB7 ${thread.created_at.slice(0, 16).replace("T", " ")}`,
|
|
67
|
+
"",
|
|
68
|
+
thread.body?.trim() || "(no body)"
|
|
69
|
+
];
|
|
70
|
+
if (replies.length) {
|
|
71
|
+
lines.push("", `## ${replies.length} repl${replies.length === 1 ? "y" : "ies"}`, "");
|
|
72
|
+
for (const reply of replies) {
|
|
73
|
+
lines.push(
|
|
74
|
+
`**${reply.author ?? "unknown"}** \xB7 ${reply.created_at.slice(0, 16).replace("T", " ")}`,
|
|
75
|
+
"",
|
|
76
|
+
reply.body?.trim() || "(empty)",
|
|
77
|
+
""
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return lines.join("\n");
|
|
82
|
+
}
|
|
83
|
+
async function readThread(threadId) {
|
|
84
|
+
const supabase = getSupabaseClient();
|
|
85
|
+
const { data: thread, error } = await supabase.from("threads").select("title, author, body, created_at").eq("id", threadId).maybeSingle();
|
|
86
|
+
if (error) throw new Error(`read failed: ${error.message}`);
|
|
87
|
+
if (!thread) return `No thread with id ${threadId}.`;
|
|
88
|
+
const { data: replies, error: repliesError } = await supabase.from("thread_replies").select("author, body, created_at").eq("thread_id", threadId).order("created_at", { ascending: true }).limit(MAX_REPLIES);
|
|
89
|
+
if (repliesError) throw new Error(`read failed: ${repliesError.message}`);
|
|
90
|
+
return formatThread(
|
|
91
|
+
thread,
|
|
92
|
+
replies ?? []
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
var TOOLS = [
|
|
96
|
+
{
|
|
97
|
+
name: "search_threads",
|
|
98
|
+
description: "Full-text search across this workspace's chat threads and replies. Use it to recall earlier decisions and discussions that are not in the current thread. Returns thread titles with a matching snippet and an id for read_thread.",
|
|
99
|
+
inputSchema: {
|
|
100
|
+
type: "object",
|
|
101
|
+
properties: {
|
|
102
|
+
query: { type: "string", description: "Words to search for." },
|
|
103
|
+
channel_id: {
|
|
104
|
+
type: "string",
|
|
105
|
+
description: "Optional: restrict results to one channel id."
|
|
106
|
+
},
|
|
107
|
+
limit: {
|
|
108
|
+
type: "number",
|
|
109
|
+
description: `Maximum results (default ${DEFAULT_SEARCH_LIMIT}, max ${MAX_SEARCH_LIMIT}).`
|
|
110
|
+
}
|
|
111
|
+
},
|
|
112
|
+
required: ["query"]
|
|
113
|
+
}
|
|
114
|
+
},
|
|
115
|
+
{
|
|
116
|
+
name: "read_thread",
|
|
117
|
+
description: "Read one thread in full: the opening message and its replies in order. Takes an id from search_threads.",
|
|
118
|
+
inputSchema: {
|
|
119
|
+
type: "object",
|
|
120
|
+
properties: {
|
|
121
|
+
thread_id: { type: "string", description: "The thread id." }
|
|
122
|
+
},
|
|
123
|
+
required: ["thread_id"]
|
|
124
|
+
}
|
|
125
|
+
},
|
|
126
|
+
{
|
|
127
|
+
name: "list_channels",
|
|
128
|
+
description: "List this workspace's dashboard channels (id, name, working directory). Use it to find a channel id for create_thread or move_thread when you only have a name.",
|
|
129
|
+
inputSchema: { type: "object", properties: {} }
|
|
130
|
+
},
|
|
131
|
+
{
|
|
132
|
+
name: "create_thread",
|
|
133
|
+
description: "Start a new thread in a dashboard channel. Works in any channel, whether or not it has a Slack binding \u2014 use this instead of posting to Slack when you just need a Workspace thread. Returns the new thread id.",
|
|
134
|
+
inputSchema: {
|
|
135
|
+
type: "object",
|
|
136
|
+
properties: {
|
|
137
|
+
channel_id: { type: "string", description: "Target channel id (see list_channels)." },
|
|
138
|
+
text: { type: "string", description: "The thread's opening message." },
|
|
139
|
+
title: {
|
|
140
|
+
type: "string",
|
|
141
|
+
description: "Optional thread title. Without one, the title is derived from `text`."
|
|
142
|
+
}
|
|
143
|
+
},
|
|
144
|
+
required: ["channel_id", "text"]
|
|
145
|
+
}
|
|
146
|
+
},
|
|
147
|
+
{
|
|
148
|
+
name: "post_reply",
|
|
149
|
+
description: "Reply to an existing thread. Takes an id from search_threads or create_thread.",
|
|
150
|
+
inputSchema: {
|
|
151
|
+
type: "object",
|
|
152
|
+
properties: {
|
|
153
|
+
thread_id: { type: "string", description: "The thread id to reply in." },
|
|
154
|
+
text: { type: "string", description: "The reply body." }
|
|
155
|
+
},
|
|
156
|
+
required: ["thread_id", "text"]
|
|
157
|
+
}
|
|
158
|
+
},
|
|
159
|
+
{
|
|
160
|
+
name: "move_thread",
|
|
161
|
+
description: "Move a thread to a different channel.",
|
|
162
|
+
inputSchema: {
|
|
163
|
+
type: "object",
|
|
164
|
+
properties: {
|
|
165
|
+
thread_id: { type: "string", description: "The thread to move." },
|
|
166
|
+
to_channel_id: { type: "string", description: "Destination channel id." }
|
|
167
|
+
},
|
|
168
|
+
required: ["thread_id", "to_channel_id"]
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
];
|
|
172
|
+
async function startThreadsServer() {
|
|
173
|
+
const server = new Server(
|
|
174
|
+
{ name: "flyspace-threads", version: "1.0.0" },
|
|
175
|
+
{ capabilities: { tools: {} } }
|
|
176
|
+
);
|
|
177
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOLS }));
|
|
178
|
+
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
179
|
+
const args = request.params.arguments ?? {};
|
|
180
|
+
try {
|
|
181
|
+
if (request.params.name === "search_threads") {
|
|
182
|
+
const query = typeof args["query"] === "string" ? args["query"].trim() : "";
|
|
183
|
+
if (!query) throw new Error("query is required");
|
|
184
|
+
const channelId = typeof args["channel_id"] === "string" ? args["channel_id"] : void 0;
|
|
185
|
+
const text = await searchThreads(query, clampLimit(args["limit"]), channelId);
|
|
186
|
+
return { content: [{ type: "text", text }] };
|
|
187
|
+
}
|
|
188
|
+
if (request.params.name === "read_thread") {
|
|
189
|
+
const threadId = typeof args["thread_id"] === "string" ? args["thread_id"].trim() : "";
|
|
190
|
+
if (!threadId) throw new Error("thread_id is required");
|
|
191
|
+
return { content: [{ type: "text", text: await readThread(threadId) }] };
|
|
192
|
+
}
|
|
193
|
+
if (request.params.name === "list_channels") {
|
|
194
|
+
const channels = await listChannels();
|
|
195
|
+
const text = channels.length ? channels.map((c) => `- **${c.name}** (${c.profile}) id: \`${c.id}\``).join("\n") : "No channels found.";
|
|
196
|
+
return { content: [{ type: "text", text }] };
|
|
197
|
+
}
|
|
198
|
+
if (request.params.name === "create_thread") {
|
|
199
|
+
const channelId = typeof args["channel_id"] === "string" ? args["channel_id"].trim() : "";
|
|
200
|
+
const text = typeof args["text"] === "string" ? args["text"] : "";
|
|
201
|
+
if (!channelId || !text) throw new Error("channel_id and text are required");
|
|
202
|
+
const title = typeof args["title"] === "string" ? args["title"] : void 0;
|
|
203
|
+
const threadId = await postToDashboardChannel({ channelId, text, title });
|
|
204
|
+
return { content: [{ type: "text", text: `Created thread ${threadId}.` }] };
|
|
205
|
+
}
|
|
206
|
+
if (request.params.name === "post_reply") {
|
|
207
|
+
const threadId = typeof args["thread_id"] === "string" ? args["thread_id"].trim() : "";
|
|
208
|
+
const text = typeof args["text"] === "string" ? args["text"] : "";
|
|
209
|
+
if (!threadId || !text) throw new Error("thread_id and text are required");
|
|
210
|
+
await postToDashboardChannel({ threadId, text });
|
|
211
|
+
return { content: [{ type: "text", text: `Replied in thread ${threadId}.` }] };
|
|
212
|
+
}
|
|
213
|
+
if (request.params.name === "move_thread") {
|
|
214
|
+
const threadId = typeof args["thread_id"] === "string" ? args["thread_id"].trim() : "";
|
|
215
|
+
const toChannelId = typeof args["to_channel_id"] === "string" ? args["to_channel_id"].trim() : "";
|
|
216
|
+
if (!threadId || !toChannelId) throw new Error("thread_id and to_channel_id are required");
|
|
217
|
+
await moveThread(threadId, toChannelId);
|
|
218
|
+
return { content: [{ type: "text", text: `Moved thread ${threadId} to ${toChannelId}.` }] };
|
|
219
|
+
}
|
|
220
|
+
throw new Error(`unknown tool: ${request.params.name}`);
|
|
221
|
+
} catch (err) {
|
|
222
|
+
return {
|
|
223
|
+
content: [{ type: "text", text: `Error: ${err.message}` }],
|
|
224
|
+
isError: true
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
});
|
|
228
|
+
await server.connect(new StdioServerTransport());
|
|
229
|
+
console.error("[flyspace-threads] ready on stdio");
|
|
230
|
+
}
|
|
231
|
+
export {
|
|
232
|
+
clampLimit,
|
|
233
|
+
formatHits,
|
|
234
|
+
formatThread,
|
|
235
|
+
rowsToHits,
|
|
236
|
+
startThreadsServer
|
|
237
|
+
};
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// PreToolUse(Bash) guard for Custie. Three jobs, all aimed at the same class of
|
|
3
|
+
// failure: a single Bash tool call that kills the turn before it can answer.
|
|
4
|
+
//
|
|
5
|
+
// 0. BLOCK a synchronous restart/stop of the service HOSTING this run. The
|
|
6
|
+
// agent lives inside `ai.flyspace.dashboard` (or `ai.flyspace*`), so a
|
|
7
|
+
// `launchctl kickstart -k <own label>` is suicide: the process dies
|
|
8
|
+
// mid-tool-call and the reply is never written. Seen in the wild on
|
|
9
|
+
// 2026-08-09 — a `pnpm build && launchctl kickstart -k … && curl …` chain
|
|
10
|
+
// ate a four-minute run without a trace in the thread.
|
|
11
|
+
// 1. BLOCK foreground-forever commands (`pnpm dev`, `tail -f`, a watcher, an
|
|
12
|
+
// undetached `docker run`). These never exit by design — tell the model to
|
|
13
|
+
// background them instead.
|
|
14
|
+
// 2. WRAP everything else in `timeout` so a command that is SUPPOSED to exit
|
|
15
|
+
// but unexpectedly hangs (a browser-automation / CDP / network script like
|
|
16
|
+
// `node scripts/cia-query.mjs`) gets killed after a bounded time instead of
|
|
17
|
+
// eating the turn. Only simple commands are wrapped — see below.
|
|
18
|
+
//
|
|
19
|
+
// Wired in via `agent.ts` (`--settings` with a PreToolUse hook). Applies ONLY to
|
|
20
|
+
// Custie's CLI invocations, never to the user's own Claude Code.
|
|
21
|
+
//
|
|
22
|
+
// Protocol: read the hook payload as JSON on stdin.
|
|
23
|
+
// - exit 0 (no output) -> allow unchanged
|
|
24
|
+
// - exit 2 (+ stderr) -> block; stderr is the reason shown to the model
|
|
25
|
+
// - exit 0 + JSON updatedInput -> allow, but run the rewritten command
|
|
26
|
+
|
|
27
|
+
import { readFileSync, existsSync } from 'node:fs';
|
|
28
|
+
|
|
29
|
+
function allow() {
|
|
30
|
+
process.exit(0);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function readStdin() {
|
|
34
|
+
try {
|
|
35
|
+
return readFileSync(0, 'utf-8');
|
|
36
|
+
} catch {
|
|
37
|
+
return '';
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
let toolInput = {};
|
|
42
|
+
let command = '';
|
|
43
|
+
try {
|
|
44
|
+
const payload = JSON.parse(readStdin() || '{}');
|
|
45
|
+
toolInput = payload?.tool_input ?? {};
|
|
46
|
+
command = String(toolInput.command ?? '');
|
|
47
|
+
} catch {
|
|
48
|
+
// Unparseable payload: fail open so we never wedge a turn over a guard bug.
|
|
49
|
+
allow();
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (!command.trim()) allow();
|
|
53
|
+
|
|
54
|
+
// --- 0. Self-restart guard: never synchronously stop the service hosting us ---
|
|
55
|
+
// Runs BEFORE the already-safe check below, because `timeout 60 launchctl
|
|
56
|
+
// kickstart -k <self>` is exactly as fatal as the unwrapped version.
|
|
57
|
+
//
|
|
58
|
+
// The owning label comes from `FLYSPACE_SELF_SERVICE_LABEL`, injected by
|
|
59
|
+
// agent.ts from launchd's `XPC_SERVICE_NAME` (which launchd exports into every
|
|
60
|
+
// process a job starts). `XPC_SERVICE_NAME` is read as a fallback for a
|
|
61
|
+
// hand-run CLI. launchd sets it to `0` for processes it did not start, and a
|
|
62
|
+
// dev run from a terminal has neither — both mean "nothing to protect".
|
|
63
|
+
const escapeRegex = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
64
|
+
|
|
65
|
+
const selfLabel = (
|
|
66
|
+
process.env.FLYSPACE_SELF_SERVICE_LABEL ||
|
|
67
|
+
process.env.XPC_SERVICE_NAME ||
|
|
68
|
+
''
|
|
69
|
+
).trim();
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* The one accepted escape hatch: hand the restart to `restart-when-idle.sh`,
|
|
73
|
+
* detached.
|
|
74
|
+
*
|
|
75
|
+
* A detached-but-fixed-delay restart (`nohup bash -c 'sleep 20; launchctl …'`)
|
|
76
|
+
* is NOT accepted, though it was at first. It protects the run that scheduled
|
|
77
|
+
* it and nothing else: on 2026-08-09 one such restart killed a run that started
|
|
78
|
+
* in a *different* thread 5 seconds after it was scheduled. The restarter polls
|
|
79
|
+
* `/api/busy` instead, so it waits on every conversation, not just its own.
|
|
80
|
+
*
|
|
81
|
+
* Detachment is still required — attached, the caller's own run keeps the
|
|
82
|
+
* in-flight count above zero, and the script would wait out its whole timeout.
|
|
83
|
+
*/
|
|
84
|
+
// Matches the script being RUN, not merely named: at the start of a line or a
|
|
85
|
+
// command, or as an argument to a shell. Without that anchoring, a heredoc or
|
|
86
|
+
// comment that mentions the script (writing a test for it, say) reads as an
|
|
87
|
+
// invocation and gets caught by the attached-restarter branch below.
|
|
88
|
+
const INVOKES_RESTARTER = /(?:^|[;&|]\s*|\b(?:bash|sh|zsh)\s+)(?:\S*\/)?restart-when-idle\.sh\b/m;
|
|
89
|
+
|
|
90
|
+
function usesIdleRestarter(cmd) {
|
|
91
|
+
if (!INVOKES_RESTARTER.test(cmd)) return false;
|
|
92
|
+
return /\bnohup\b/.test(cmd) && (/\bdisown\b/.test(cmd) || /(^|[^&|>])&\s*$/.test(cmd));
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (selfLabel && selfLabel !== '0' && !usesIdleRestarter(command)) {
|
|
96
|
+
// The restarter itself, run attached: it would poll for an idle workspace
|
|
97
|
+
// that cannot arrive, because the run waiting on it is the one keeping the
|
|
98
|
+
// count above zero. Worth its own message — the generic one below would send
|
|
99
|
+
// the model straight back to the command it just wrote.
|
|
100
|
+
if (INVOKES_RESTARTER.test(command)) {
|
|
101
|
+
process.stderr.write(
|
|
102
|
+
`Blocked: \`restart-when-idle.sh\` has to be detached, and this invocation is not.\n\n` +
|
|
103
|
+
`It waits for zero in-flight agent runs before restarting. Your own run counts, ` +
|
|
104
|
+
`so run attached it can never see zero — it would just block this turn until it ` +
|
|
105
|
+
`times out.\n\n` +
|
|
106
|
+
` nohup bash scripts/restart-when-idle.sh > /dev/null 2>&1 & disown\n\n` +
|
|
107
|
+
`Then finish your turn. Progress is logged to /tmp/flyspace-restart.log.`,
|
|
108
|
+
);
|
|
109
|
+
process.exit(2);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const label = escapeRegex(selfLabel);
|
|
113
|
+
// The label also appears inside the plist path (`~/Library/LaunchAgents/
|
|
114
|
+
// ai.flyspace.dashboard.plist`), so a plain substring match covers
|
|
115
|
+
// `launchctl unload <plist>` as well as the `gui/<uid>/<label>` forms.
|
|
116
|
+
const SELF_LIFECYCLE = [
|
|
117
|
+
{
|
|
118
|
+
re: new RegExp(`\\blaunchctl\\s+(kickstart|bootout|unload|stop|kill|remove)\\b[^\\n]*${label}`),
|
|
119
|
+
hint: `launchctl on ${selfLabel}`,
|
|
120
|
+
},
|
|
121
|
+
{
|
|
122
|
+
re: new RegExp(`\\bsystemctl\\b[^\\n]*\\b(restart|stop|kill)\\b[^\\n]*${label}`),
|
|
123
|
+
hint: `systemctl on ${selfLabel}`,
|
|
124
|
+
},
|
|
125
|
+
{
|
|
126
|
+
re: new RegExp(`\\bsystemctl\\b[^\\n]*${label}[^\\n]*\\b(restart|stop|kill)\\b`),
|
|
127
|
+
hint: `systemctl on ${selfLabel}`,
|
|
128
|
+
},
|
|
129
|
+
// `pkill -f flyspace` / `killall node` sweep up this very process too.
|
|
130
|
+
{ re: /\b(pkill|killall)\b[^\n]*\b(flyspace|custie)\b/, hint: 'pkill/killall matching this process' },
|
|
131
|
+
{ re: /\b(pkill|killall)\s+(-\S+\s+)*node\b/, hint: 'killall node (this process is node)' },
|
|
132
|
+
];
|
|
133
|
+
|
|
134
|
+
const selfKill = SELF_LIFECYCLE.find(({ re }) => re.test(command));
|
|
135
|
+
if (selfKill) {
|
|
136
|
+
process.stderr.write(
|
|
137
|
+
`Blocked: this would stop the service that is RUNNING YOU (${selfKill.hint}).\n\n` +
|
|
138
|
+
`You are executing inside \`${selfLabel}\`, and so is every other agent run ` +
|
|
139
|
+
`happening right now. Restarting it SIGKILLs all of them mid-tool-call: the ` +
|
|
140
|
+
`rest of your chain is skipped, no reply is written, and other people's ` +
|
|
141
|
+
`threads go silent too. A fixed \`sleep N\` does not fix this — it only covers ` +
|
|
142
|
+
`your own run, and a message landing in another thread inside that window ` +
|
|
143
|
+
`still dies.\n\n` +
|
|
144
|
+
`Use the idle-aware restarter, which waits for zero in-flight runs:\n` +
|
|
145
|
+
` nohup bash scripts/restart-when-idle.sh > /dev/null 2>&1 & disown\n` +
|
|
146
|
+
`It must be detached (attached, your own run never ends and it waits forever). ` +
|
|
147
|
+
`Pass a follow-up command as an argument to verify after the restart. Then ` +
|
|
148
|
+
`finish your turn and tell the user the UI will blink and to refresh.\n\n` +
|
|
149
|
+
`If the restart was only to VERIFY a change, do not touch the live service at ` +
|
|
150
|
+
`all — build, then run a throwaway instance on a spare port ` +
|
|
151
|
+
`(e.g. \`PORT=4848 node dist/index.js dashboard --no-open\` backgrounded) and ` +
|
|
152
|
+
`curl that.`,
|
|
153
|
+
);
|
|
154
|
+
process.exit(2);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// --- 1. Already safe: backgrounded, detached, or already bounded by timeout ---
|
|
159
|
+
// `&&`/`||`/`&>`/`2>&1` are NOT backgrounding, so match a real trailing `&`.
|
|
160
|
+
const ALREADY_SAFE = [
|
|
161
|
+
/\bnohup\b/,
|
|
162
|
+
/\bdisown\b/,
|
|
163
|
+
/(^|[^&|>])&\s*$/, // trailing single & (job control), not &&/&>/2>&1
|
|
164
|
+
/\btimeout\s+/, // already wrapped (incl. our own rewrite)
|
|
165
|
+
/\bgtimeout\s+/,
|
|
166
|
+
];
|
|
167
|
+
if (ALREADY_SAFE.some((re) => re.test(command))) allow();
|
|
168
|
+
|
|
169
|
+
// --- 2. Foreground-forever runners: block with guidance ---
|
|
170
|
+
const LONG_RUNNING = [
|
|
171
|
+
{ re: /\btail\s+-[a-zA-Z]*f/, hint: 'tail -f follows forever' },
|
|
172
|
+
{ re: /\b(pnpm|npm|yarn|bun)\s+(run\s+)?(dev|start|serve|watch|preview)\b/, hint: 'package dev/start/serve/watch script' },
|
|
173
|
+
{ re: /\bnext\s+dev\b/, hint: 'next dev server' },
|
|
174
|
+
{ re: /\bvite\b(?!\s+build)/, hint: 'vite dev server (without build)' },
|
|
175
|
+
{ re: /\bnodemon\b/, hint: 'nodemon watcher' },
|
|
176
|
+
{ re: /\btsx\s+watch\b/, hint: 'tsx watch' },
|
|
177
|
+
{ re: /\b(jest|vitest|playwright)\b.*--watch\b/, hint: 'test watcher' },
|
|
178
|
+
{ re: /\bvitest\b(?!\s+run)/, hint: 'vitest (defaults to watch mode; use `vitest run`)' },
|
|
179
|
+
{ re: /\b--watch(=true)?\b/, hint: 'a --watch flag' },
|
|
180
|
+
{ re: /\bdocker\s+run\b(?![^\n]*\s-d\b)(?![^\n]*--detach\b)/, hint: 'docker run without -d/--detach' },
|
|
181
|
+
{ re: /\bdocker[\s-]+compose\s+up\b(?![^\n]*\s-d\b)(?![^\n]*--detach\b)/, hint: 'docker compose up without -d' },
|
|
182
|
+
{ re: /\bpython\d?\s+-m\s+http\.server\b/, hint: 'python http.server' },
|
|
183
|
+
{ re: /\bserve\b(?!\s)/, hint: 'static `serve`' },
|
|
184
|
+
{ re: /\binotifywait\b.*\s-m\b/, hint: 'inotifywait -m (monitor mode)' },
|
|
185
|
+
];
|
|
186
|
+
|
|
187
|
+
const blocked = LONG_RUNNING.find(({ re }) => re.test(command));
|
|
188
|
+
if (blocked) {
|
|
189
|
+
process.stderr.write(
|
|
190
|
+
`Blocked: this looks like a long-running foreground process (${blocked.hint}). ` +
|
|
191
|
+
`In Custie this would hang the whole turn until it is force-killed, which also ` +
|
|
192
|
+
`drops the conversation session. Do NOT run it in the foreground.\n\n` +
|
|
193
|
+
`If you need it running, background it and return the PID:\n` +
|
|
194
|
+
` nohup <cmd> > /tmp/<name>.log 2>&1 & disown; echo $!\n` +
|
|
195
|
+
`then poll the log / port to confirm it started.\n\n` +
|
|
196
|
+
`If you only need a bounded run (build, one-shot check), wrap it: ` +
|
|
197
|
+
`\`timeout 120 <cmd>\`.`,
|
|
198
|
+
);
|
|
199
|
+
process.exit(2);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// --- 3. Wrap simple commands in `timeout` (the should-exit-but-might-hang case) ---
|
|
203
|
+
|
|
204
|
+
// Locate a real `timeout` binary. launchd's PATH may omit homebrew, so probe
|
|
205
|
+
// absolute paths and fall open (no wrap) if none is found.
|
|
206
|
+
function findTimeoutBin() {
|
|
207
|
+
const candidates = [
|
|
208
|
+
'/opt/homebrew/bin/timeout',
|
|
209
|
+
'/opt/homebrew/bin/gtimeout',
|
|
210
|
+
'/usr/local/bin/timeout',
|
|
211
|
+
'/usr/local/bin/gtimeout',
|
|
212
|
+
'/usr/bin/timeout',
|
|
213
|
+
];
|
|
214
|
+
return candidates.find((p) => existsSync(p)) ?? null;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// Skip wrapping anything that isn't a single simple command — prefixing
|
|
218
|
+
// `timeout N` only governs the FIRST simple command, so pipes/chains/subshells
|
|
219
|
+
// and shell builtins (cd must run in the tool's own shell) would break or be
|
|
220
|
+
// only partially covered. Leave those to the wall-clock backstop.
|
|
221
|
+
function isSimpleCommand(cmd) {
|
|
222
|
+
const c = cmd.trim();
|
|
223
|
+
if (/[\n;`]|\|\||&&|(^|[^>&])\|[^|]|\$\(|<\(|>\(/.test(c)) return false; // chains/pipes/subshells
|
|
224
|
+
if (/(^|[^&|>])&(\s|$)/.test(c)) return false; // backgrounding
|
|
225
|
+
if (/^\w+=/.test(c)) return false; // leading VAR=... assignment
|
|
226
|
+
const first = c.split(/\s+/)[0];
|
|
227
|
+
const BUILTINS = new Set([
|
|
228
|
+
'cd', 'export', 'source', '.', 'eval', 'exec', ':', 'set', 'unset',
|
|
229
|
+
'alias', 'pushd', 'popd', 'pwd', 'echo', 'true', 'false', 'wait', 'kill',
|
|
230
|
+
]);
|
|
231
|
+
return !BUILTINS.has(first);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// Known legitimately-long single commands: don't false-kill these at the default
|
|
235
|
+
// cap. The wall-clock still backstops them.
|
|
236
|
+
const SKIP_WRAP = [
|
|
237
|
+
/\b(npm|pnpm|yarn|bun)\s+(install|ci|add|i|up|update|upgrade|dedupe)\b/,
|
|
238
|
+
/\bgit\s+(clone|fetch|pull|push)\b/,
|
|
239
|
+
/\b(docker|podman)\s+(build|pull|push)\b/,
|
|
240
|
+
/\bbrew\s+(install|upgrade|update|reinstall)\b/,
|
|
241
|
+
/\bpip3?\s+install\b/,
|
|
242
|
+
];
|
|
243
|
+
|
|
244
|
+
function timeoutSeconds() {
|
|
245
|
+
const raw = process.env.CUSTIE_BASH_TIMEOUT_SECONDS;
|
|
246
|
+
const n = raw ? Number.parseInt(raw, 10) : NaN;
|
|
247
|
+
return Number.isFinite(n) && n > 0 ? n : 300;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const timeoutBin = findTimeoutBin();
|
|
251
|
+
if (!timeoutBin || !isSimpleCommand(command) || SKIP_WRAP.some((re) => re.test(command))) {
|
|
252
|
+
allow();
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const secs = timeoutSeconds();
|
|
256
|
+
// -v: print a clear "timeout: sending signal..." line to stderr so the model
|
|
257
|
+
// knows WHY the command died (vs. a mysterious exit 124).
|
|
258
|
+
// -k 5: SIGKILL 5s after SIGTERM if the command ignores TERM.
|
|
259
|
+
const wrapped = `${timeoutBin} -v -k 5 ${secs} ${command}`;
|
|
260
|
+
process.stdout.write(
|
|
261
|
+
JSON.stringify({
|
|
262
|
+
hookSpecificOutput: {
|
|
263
|
+
hookEventName: 'PreToolUse',
|
|
264
|
+
permissionDecision: 'allow',
|
|
265
|
+
updatedInput: { ...toolInput, command: wrapped },
|
|
266
|
+
},
|
|
267
|
+
}),
|
|
268
|
+
);
|
|
269
|
+
process.exit(0);
|
package/package.json
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "flyspace-cli",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "AI-native headless community OS",
|
|
5
|
+
"license": "SEE LICENSE IN LICENSE",
|
|
6
|
+
"private": false,
|
|
7
|
+
"type": "module",
|
|
8
|
+
"main": "dist/index.js",
|
|
9
|
+
"bin": {
|
|
10
|
+
"flyspace": "dist/cli.js"
|
|
11
|
+
},
|
|
12
|
+
"files": [
|
|
13
|
+
"dist",
|
|
14
|
+
"hooks",
|
|
15
|
+
"system.default.md",
|
|
16
|
+
"system.capabilities.md",
|
|
17
|
+
"slack-app-manifest.yml"
|
|
18
|
+
],
|
|
19
|
+
"scripts": {
|
|
20
|
+
"dev": "tsx watch --clear-screen=false src/cli.ts dashboard --mode all --port 4848 --no-open",
|
|
21
|
+
"dev:slack": "tsx src/cli.ts start",
|
|
22
|
+
"dev:slack:debug": "DEBUG=true tsx src/cli.ts start",
|
|
23
|
+
"dev:data": "tsx src/dashboard/data-server.ts",
|
|
24
|
+
"dev:host": "tsx src/dashboard/host-server.ts",
|
|
25
|
+
"generate-host-key": "tsx scripts/generate-host-key.ts",
|
|
26
|
+
"import:channels": "tsx scripts/import-channels-to-supabase.ts",
|
|
27
|
+
"seed:test-user": "tsx scripts/seed-test-user.ts",
|
|
28
|
+
"build": "tsup && pnpm run build:web",
|
|
29
|
+
"build:web": "cd web && pnpm run build",
|
|
30
|
+
"build:cli": "FLYSPACE_PUBLIC_BUILD=1 tsup",
|
|
31
|
+
"start": "node dist/cli.js start",
|
|
32
|
+
"test": "vitest run",
|
|
33
|
+
"test:watch": "vitest",
|
|
34
|
+
"reload": "pnpm run build && launchctl kickstart -k \"gui/$(id -u)/ai.flyspace\"",
|
|
35
|
+
"reload:all": "pnpm run build && launchctl kickstart -k \"gui/$(id -u)/ai.flyspace\" && launchctl kickstart -k \"gui/$(id -u)/ai.flyspace.flycoder\"",
|
|
36
|
+
"lint": "oxlint src/",
|
|
37
|
+
"format": "prettier --write src/",
|
|
38
|
+
"format:check": "prettier --check src/",
|
|
39
|
+
"prepublishOnly": "pnpm run build:cli",
|
|
40
|
+
"release": "pnpm version patch && npm publish && git push --follow-tags"
|
|
41
|
+
},
|
|
42
|
+
"dependencies": {
|
|
43
|
+
"@anthropic-ai/sandbox-runtime": "0.0.77",
|
|
44
|
+
"@aws-sdk/client-s3": "^3.1085.0",
|
|
45
|
+
"@aws-sdk/s3-request-presigner": "^3.1085.0",
|
|
46
|
+
"@hono/node-server": "^2.0.6",
|
|
47
|
+
"@inquirer/prompts": "^8.3.2",
|
|
48
|
+
"@modelcontextprotocol/sdk": "^1.30.0",
|
|
49
|
+
"@slack/bolt": "^4.6.0",
|
|
50
|
+
"@slack/web-api": "^7.15.0",
|
|
51
|
+
"@supabase/ssr": "^0.12.1",
|
|
52
|
+
"@supabase/supabase-js": "^2.110.1",
|
|
53
|
+
"better-sqlite3": "^12.6.2",
|
|
54
|
+
"cron-parser": "^5.5.0",
|
|
55
|
+
"dotenv": "^17.2.4",
|
|
56
|
+
"gemoji": "^8.1.0",
|
|
57
|
+
"hono": "^4.12.27",
|
|
58
|
+
"js-yaml": "^4.1.1",
|
|
59
|
+
"marked": "^18.0.2",
|
|
60
|
+
"msedge-tts": "^2.0.7",
|
|
61
|
+
"node-cron": "^4.2.1",
|
|
62
|
+
"sharp": "^0.35.3",
|
|
63
|
+
"stripe": "^22.6.2",
|
|
64
|
+
"web-push": "^3.6.7",
|
|
65
|
+
"ws": "^8.21.3"
|
|
66
|
+
},
|
|
67
|
+
"devDependencies": {
|
|
68
|
+
"@types/better-sqlite3": "^7.6.13",
|
|
69
|
+
"@types/js-yaml": "^4.0.9",
|
|
70
|
+
"@types/node": "^25.2.3",
|
|
71
|
+
"@types/web-push": "^3.6.4",
|
|
72
|
+
"@types/ws": "^8.18.1",
|
|
73
|
+
"@vitest/coverage-v8": "^3.2.6",
|
|
74
|
+
"jsdom": "^30.0.0",
|
|
75
|
+
"oxlint": "^1.46.0",
|
|
76
|
+
"playwright": "^1.58.2",
|
|
77
|
+
"prettier": "^3.8.1",
|
|
78
|
+
"tsup": "^8.5.1",
|
|
79
|
+
"tsx": "^4.21.0",
|
|
80
|
+
"typescript": "^5.9.3",
|
|
81
|
+
"vitest": "^3.2.6"
|
|
82
|
+
},
|
|
83
|
+
"pnpm": {
|
|
84
|
+
"onlyBuiltDependencies": [
|
|
85
|
+
"better-sqlite3",
|
|
86
|
+
"esbuild"
|
|
87
|
+
]
|
|
88
|
+
},
|
|
89
|
+
"packageManager": "pnpm@10.24.0+sha512.01ff8ae71b4419903b65c60fb2dc9d34cf8bb6e06d03bde112ef38f7a34d6904c424ba66bea5cdcf12890230bf39f9580473140ed9c946fef328b6e5238a345a",
|
|
90
|
+
"publishConfig": {
|
|
91
|
+
"access": "public"
|
|
92
|
+
}
|
|
93
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
display_information:
|
|
2
|
+
name: Custie
|
|
3
|
+
description: Claude Code powered Slack bot
|
|
4
|
+
background_color: "#1e293b"
|
|
5
|
+
|
|
6
|
+
features:
|
|
7
|
+
app_home:
|
|
8
|
+
home_tab_enabled: false
|
|
9
|
+
messages_tab_enabled: true
|
|
10
|
+
messages_tab_read_only_enabled: false
|
|
11
|
+
bot_user:
|
|
12
|
+
display_name: Custie
|
|
13
|
+
always_online: true
|
|
14
|
+
slash_commands:
|
|
15
|
+
- command: /custie
|
|
16
|
+
description: Browse skills and what Custie can do
|
|
17
|
+
usage_hint: skills | help
|
|
18
|
+
should_escape: false
|
|
19
|
+
|
|
20
|
+
oauth_config:
|
|
21
|
+
scopes:
|
|
22
|
+
bot:
|
|
23
|
+
- app_mentions:read
|
|
24
|
+
- channels:history
|
|
25
|
+
- channels:read
|
|
26
|
+
- chat:write
|
|
27
|
+
- commands
|
|
28
|
+
- files:read
|
|
29
|
+
- groups:history
|
|
30
|
+
- groups:read
|
|
31
|
+
- im:history
|
|
32
|
+
- im:read
|
|
33
|
+
- im:write
|
|
34
|
+
- mpim:read
|
|
35
|
+
- reactions:write
|
|
36
|
+
- users:read
|
|
37
|
+
- usergroups:read
|
|
38
|
+
|
|
39
|
+
settings:
|
|
40
|
+
event_subscriptions:
|
|
41
|
+
bot_events:
|
|
42
|
+
- app_mention
|
|
43
|
+
- message.channels
|
|
44
|
+
- message.groups
|
|
45
|
+
- message.im
|
|
46
|
+
interactivity:
|
|
47
|
+
is_enabled: true
|
|
48
|
+
org_deploy_enabled: false
|
|
49
|
+
socket_mode_enabled: true
|
|
50
|
+
token_rotation_enabled: false
|