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.
@@ -0,0 +1,3 @@
1
+ declare function startServer(): Promise<void>;
2
+
3
+ export { startServer };
package/dist/index.js ADDED
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env node
2
+ import {
3
+ startServer
4
+ } from "./chunk-DCCECKHH.js";
5
+ import "./chunk-QZIN7MWV.js";
6
+ export {
7
+ startServer
8
+ };
@@ -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