flowstatemcp 2.5.0__py3-none-any.whl

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.
Files changed (143) hide show
  1. flowstate/__init__.py +34 -0
  2. flowstate/__main__.py +7 -0
  3. flowstate/activity.py +224 -0
  4. flowstate/agent_guide.py +251 -0
  5. flowstate/app_keys.py +141 -0
  6. flowstate/approvals.py +974 -0
  7. flowstate/approve_page.py +565 -0
  8. flowstate/ask.py +107 -0
  9. flowstate/autosession.py +480 -0
  10. flowstate/backup.py +201 -0
  11. flowstate/brief.py +420 -0
  12. flowstate/card_offer.py +208 -0
  13. flowstate/card_taps.py +165 -0
  14. flowstate/card_trust.py +127 -0
  15. flowstate/claims.py +461 -0
  16. flowstate/cleanup.py +619 -0
  17. flowstate/cli.py +728 -0
  18. flowstate/cli_data.py +434 -0
  19. flowstate/cli_more.py +302 -0
  20. flowstate/cli_review.py +459 -0
  21. flowstate/cloudflared_check.py +49 -0
  22. flowstate/conflict_card_ui.py +286 -0
  23. flowstate/conflict_cards.py +1193 -0
  24. flowstate/conflict_reopen.py +320 -0
  25. flowstate/conflicts.py +420 -0
  26. flowstate/connections.py +33 -0
  27. flowstate/database.py +2191 -0
  28. flowstate/dbconn.py +308 -0
  29. flowstate/deletes.py +860 -0
  30. flowstate/embeddings.py +354 -0
  31. flowstate/erase.py +1047 -0
  32. flowstate/extras.py +71 -0
  33. flowstate/git_capture.py +157 -0
  34. flowstate/guard.py +224 -0
  35. flowstate/handoffs.py +168 -0
  36. flowstate/history.py +755 -0
  37. flowstate/history_page.py +276 -0
  38. flowstate/http_app.py +204 -0
  39. flowstate/identity.py +387 -0
  40. flowstate/install_name.py +140 -0
  41. flowstate/ledger.py +272 -0
  42. flowstate/legacy_args.py +65 -0
  43. flowstate/links.py +609 -0
  44. flowstate/markdown_io.py +242 -0
  45. flowstate/meaning.py +398 -0
  46. flowstate/memory_import.py +92 -0
  47. flowstate/migrations/001_auto_sessions.sql +16 -0
  48. flowstate/migrations/002_review_queue.sql +33 -0
  49. flowstate/migrations/003_links.sql +47 -0
  50. flowstate/migrations/004_review_mode.sql +24 -0
  51. flowstate/migrations/005_history.sql +21 -0
  52. flowstate/migrations/006_activity.sql +26 -0
  53. flowstate/migrations/007_meaning_index.sql +16 -0
  54. flowstate/migrations/008_git_capture.sql +12 -0
  55. flowstate/migrations/009_hard_rules.sql +13 -0
  56. flowstate/migrations/010_handoffs.sql +14 -0
  57. flowstate/migrations/011_record_meta.sql +13 -0
  58. flowstate/migrations/012_accountability.sql +71 -0
  59. flowstate/migrations/013_todo_claims.sql +36 -0
  60. flowstate/migrations/014_decision_conflicts.sql +41 -0
  61. flowstate/migrations/015_session_runs.sql +17 -0
  62. flowstate/migrations/016_write_receipts.sql +35 -0
  63. flowstate/migrations/017_run_views.sql +26 -0
  64. flowstate/migrations/018_run_views_upto.sql +4 -0
  65. flowstate/migrations/019_approvals.sql +47 -0
  66. flowstate/migrations/020_source_stamps.sql +10 -0
  67. flowstate/migrations/021_review_links.sql +11 -0
  68. flowstate/migrations/022_review_start_tickets.sql +10 -0
  69. flowstate/migrations/023_conflict_cards.sql +68 -0
  70. flowstate/migrations/024_rule_confirmations.sql +7 -0
  71. flowstate/migrations/025_confirmed_hash.sql +7 -0
  72. flowstate/migrations/__init__.py +149 -0
  73. flowstate/models.py +307 -0
  74. flowstate/names.py +157 -0
  75. flowstate/offsite.py +386 -0
  76. flowstate/offsite_crypto.py +272 -0
  77. flowstate/paths.py +265 -0
  78. flowstate/popup.py +238 -0
  79. flowstate/priority.py +66 -0
  80. flowstate/privatefs.py +476 -0
  81. flowstate/project_history.py +710 -0
  82. flowstate/provenance.py +696 -0
  83. flowstate/receipts.py +377 -0
  84. flowstate/relay/__init__.py +72 -0
  85. flowstate/relay/acme.py +271 -0
  86. flowstate/relay/api.py +164 -0
  87. flowstate/relay/cli.py +344 -0
  88. flowstate/relay/crypto.py +137 -0
  89. flowstate/relay/ct.py +118 -0
  90. flowstate/relay/runner.py +177 -0
  91. flowstate/relay/store.py +191 -0
  92. flowstate/relay/tunnel.py +413 -0
  93. flowstate/release_signers +14 -0
  94. flowstate/remote_auth.py +1219 -0
  95. flowstate/review.py +320 -0
  96. flowstate/review_apply.py +140 -0
  97. flowstate/review_card_ui.py +241 -0
  98. flowstate/review_cards.py +564 -0
  99. flowstate/review_mode.py +274 -0
  100. flowstate/review_page.py +1361 -0
  101. flowstate/review_phone.py +465 -0
  102. flowstate/review_tunnel.py +338 -0
  103. flowstate/routing.py +392 -0
  104. flowstate/rules.py +418 -0
  105. flowstate/safety.py +98 -0
  106. flowstate/saves.py +83 -0
  107. flowstate/scheduler.py +160 -0
  108. flowstate/schema.sql +713 -0
  109. flowstate/search.py +355 -0
  110. flowstate/secret_store.py +271 -0
  111. flowstate/secrets_scan.py +135 -0
  112. flowstate/selfupdate.py +1020 -0
  113. flowstate/server.py +1299 -0
  114. flowstate/session_contents.py +382 -0
  115. flowstate/session_records.py +629 -0
  116. flowstate/site_style.py +46 -0
  117. flowstate/stored.py +158 -0
  118. flowstate/story.py +442 -0
  119. flowstate/surface.py +173 -0
  120. flowstate/tasks.py +231 -0
  121. flowstate/term.py +40 -0
  122. flowstate/tool_labels.py +104 -0
  123. flowstate/tooldefs.py +383 -0
  124. flowstate/tools/__init__.py +118 -0
  125. flowstate/tools/core.py +545 -0
  126. flowstate/tools/files.py +416 -0
  127. flowstate/tools/git_ops.py +1081 -0
  128. flowstate/tools/intelligence.py +376 -0
  129. flowstate/tools/learning.py +282 -0
  130. flowstate/tools/problems.py +159 -0
  131. flowstate/tools/sessions.py +266 -0
  132. flowstate/tools/story.py +48 -0
  133. flowstate/tools/todos.py +79 -0
  134. flowstate/tools/utils.py +71 -0
  135. flowstate/tools/variables.py +345 -0
  136. flowstate/updates.py +482 -0
  137. flowstate/urls.py +7 -0
  138. flowstate/waiting.py +828 -0
  139. flowstate/who.py +302 -0
  140. flowstatemcp-2.5.0.dist-info/METADATA +88 -0
  141. flowstatemcp-2.5.0.dist-info/RECORD +143 -0
  142. flowstatemcp-2.5.0.dist-info/WHEEL +4 -0
  143. flowstatemcp-2.5.0.dist-info/entry_points.txt +3 -0
@@ -0,0 +1,565 @@
1
+ """The phone approval page (approvals.py, path 2), served by FlowState's HTTP app, which the
2
+ relay agent reaches at https://<handle>.fsmcp.com (TLS ends on this computer).
3
+
4
+ flowstatemcp.com signs the user in, then sends the browser to /approve/<item>#g=<grant>. The
5
+ fragment never reaches a server or a log. This page:
6
+
7
+ GET /approve/<item> an empty shell (no data); its script reads #g=, removes it from
8
+ the address bar at once (history.replaceState) and posts it
9
+ POST /approve/<item>/open checks the grant (approvals.verify_grant). Each grant opens
10
+ the page once. Answers with the item from the local database and
11
+ a page token, also set as a SameSite=Strict, Secure, HttpOnly
12
+ cookie, bound to that grant
13
+ POST /approve/<item>/decide the tap: the same grant, checked again, plus the page token in
14
+ the body and the cookie. Approve or Reject uses the grant and the
15
+ item's one-use code
16
+ POST /approve/<item>/record a session summary's per-record Keep / Remove / Bring back
17
+ (session_records.py): the same grant and page token, not used
18
+ up, so the page's final Keep all / Don't keep still works
19
+
20
+ Strict CSP (no inline script or style: both are files under /approve/assets/), frame-ancestors
21
+ 'none' + X-Frame-Options DENY, no-store, no referrer. POSTs must be JSON, from this origin.
22
+ """
23
+
24
+ import asyncio
25
+ import hashlib
26
+ import json
27
+ import secrets
28
+ from datetime import datetime, timezone
29
+
30
+ from . import approvals, install_name, site_style
31
+
32
+ HEADERS = {
33
+ "Content-Security-Policy": "default-src 'none'; script-src 'self'; style-src 'self'; "
34
+ "connect-src 'self'; img-src 'self'; base-uri 'none'; "
35
+ "form-action 'none'; frame-ancestors 'none'",
36
+ "X-Frame-Options": "DENY", "Cache-Control": "no-store", "Referrer-Policy": "no-referrer",
37
+ "X-Content-Type-Options": "nosniff",
38
+ }
39
+ COOKIE = "fsa_page"
40
+
41
+ SHELL = """<!doctype html>
42
+ <html lang="en"><head><meta charset="utf-8">
43
+ <meta name="viewport" content="width=device-width,initial-scale=1">
44
+ <meta name="referrer" content="no-referrer">
45
+ <title>FlowState approval</title>
46
+ <link rel="stylesheet" href="/approve/assets/approve.css">
47
+ <script src="/approve/assets/approve.js" defer></script>
48
+ </head><body><main class="wrap">
49
+ <p class="brand">FlowState</p>
50
+ <h1 id="title">Checking your approval link...</h1>
51
+ <p id="status" class="muted"></p>
52
+ <noscript><p class="muted">This page needs JavaScript to read your approval link.</p></noscript>
53
+ <section id="item" hidden>
54
+ <p class="eyebrow" id="project"></p>
55
+ <p id="summary"></p>
56
+ <p id="details" class="muted"></p>
57
+ <div id="saved"></div>
58
+ <p id="who" class="faint"></p>
59
+ <label id="name-row" hidden>Type the project's exact name to delete it
60
+ <input id="confirm-name" autocomplete="off" autocapitalize="off" spellcheck="false"></label>
61
+ <div class="actions">
62
+ <button id="approve" class="btn" type="button">Approve</button>
63
+ <button id="reject" class="btn ghost" type="button">Reject</button>
64
+ </div>
65
+ </section>
66
+ <p class="faint small" id="foot">Your FlowState, on your own computer, shows this page. flowstatemcp.com
67
+ only confirmed it's you; it never sees what's in it.</p>
68
+ </main></body></html>
69
+ """
70
+
71
+ CSS = """:root{--bg:#fbfaf7;--ink:#1c1d1f;--muted:#62656b;--faint:#9a9ca1;--line:#e7e4dd;
72
+ --accent:#1f5f5b;--accent-soft:#e6efed;--danger:#8a2d22;
73
+ --sans:ui-sans-serif,-apple-system,"SF Pro Text","Segoe UI",Inter,Roboto,"Helvetica Neue",Arial,sans-serif;
74
+ --serif:ui-serif,"New York","Iowan Old Style",Georgia,"Times New Roman",serif}
75
+ *{box-sizing:border-box}html{-webkit-text-size-adjust:100%}
76
+ body{margin:0;background:var(--bg);color:var(--ink);font:17px/1.6 var(--sans);-webkit-font-smoothing:antialiased}
77
+ .wrap{max-width:560px;margin:0 auto;padding:28px 22px 48px}
78
+ .brand{font-weight:600;letter-spacing:-.01em;margin:0 0 32px}
79
+ h1{font-family:var(--serif);font-weight:500;font-size:28px;line-height:1.2;letter-spacing:-.01em;margin:0 0 14px}
80
+ .eyebrow{font-size:13px;letter-spacing:.12em;text-transform:uppercase;color:var(--accent);margin:24px 0 8px}
81
+ .muted{color:var(--muted)}.faint{color:var(--faint)}.small{font-size:14px;margin-top:40px}
82
+ #summary{font-size:19px;margin:0 0 10px;overflow-wrap:anywhere}
83
+ #details{overflow-wrap:anywhere;white-space:pre-wrap}
84
+ label{display:block;margin:20px 0 0;font-size:15px;color:var(--muted)}
85
+ input{display:block;width:100%;margin-top:6px;font:inherit;padding:12px 14px;border:1px solid var(--line);border-radius:12px;background:#fff;color:var(--ink)}
86
+ .actions{display:flex;gap:14px;margin-top:28px}
87
+ .btn{flex:1;font:inherit;font-size:16px;font-weight:500;padding:15px 22px;border-radius:999px;border:0;cursor:pointer;background:var(--ink);color:var(--bg)}
88
+ .btn:hover{background:var(--accent)}.btn:disabled{opacity:.5;cursor:default}
89
+ .btn.ghost{background:none;color:var(--ink);border:1px solid var(--line)}
90
+ .btn.ghost:hover{border-color:var(--ink);background:none}
91
+ .err{color:var(--danger)}
92
+ .saved{margin:8px 0 0;padding-left:18px}.saved li{margin:8px 0;overflow-wrap:anywhere}
93
+ .saved .ex{color:var(--muted);font-size:14px;margin:2px 0 0}
94
+ .btn.more{flex:none;font-size:15px;padding:10px 18px;margin:4px 0 8px}
95
+ .state{display:inline-block;margin-left:8px;font-size:13px;border:1px solid var(--line);border-radius:999px;padding:0 8px;color:var(--accent)}
96
+ .state.st-removed,.state.st-gone{color:var(--danger)}.state.st-changed{color:var(--muted)}
97
+ .recrow{display:flex;gap:10px;margin:6px 0 2px}
98
+ .btn.rec{flex:none;font-size:15px;padding:8px 16px;min-height:44px}
99
+ @media (prefers-reduced-motion:reduce){*{transition:none!important}}
100
+ .wrap{max-width:600px}
101
+ @media (min-width:640px){.wrap{background:#fff;border:1px solid var(--line);border-radius:10px;margin:32px auto;padding:28px 32px 36px}}
102
+ .brand{font-weight:500;font-size:17px;margin:0 0 24px}.brand b{color:var(--accent);font-weight:700}
103
+ h1{font-size:24px;line-height:1.25}
104
+ """
105
+
106
+ CSS = site_style.restyle(CSS)
107
+
108
+ # What a session saved, drawn on the phone pages (this one and review_phone.py): the first
109
+ # `shown` records, then a "Show N more" button that opens the rest in place. Every record sent
110
+ # is already in the page's data (session_contents.public, send=PHONE_MAX), so opening them asks
111
+ # nothing of the server. Text only (textContent), never markup. Spliced into each page's own
112
+ # function at /*SAVED_JS*/ (defines mk, localTime, endTime, savedItem and saved there).
113
+ SAVED_JS = r""" function mk(tag, cls, text) {
114
+ var e = document.createElement(tag);
115
+ if (cls) { e.className = cls; }
116
+ if (text) { e.textContent = text; }
117
+ return e;
118
+ }
119
+ function localTime(utc) { // the reader's own time; UTC, labelled, if it can't
120
+ var d = utc ? new Date(String(utc).replace(" ", "T") + "Z") : null;
121
+ if (!d || isNaN(d.getTime())) { return utc ? utc + " UTC" : ""; }
122
+ return d.toLocaleString([], {dateStyle: "medium", timeStyle: "short"});
123
+ }
124
+ function endTime(a, b) { // the end alone when the session ends the day it began
125
+ var x = new Date(String(a).replace(" ", "T") + "Z"), y = new Date(String(b).replace(" ", "T") + "Z");
126
+ if (isNaN(x.getTime()) || isNaN(y.getTime()) || x.toDateString() !== y.toDateString()) {
127
+ return localTime(b);
128
+ }
129
+ return y.toLocaleTimeString([], {timeStyle: "short"});
130
+ }
131
+ var ACT = {keep: "Keep", remove: "Remove", restore: "Bring back"};
132
+ function savedItem(e, act) { // act(entry, action, buttons): a record's own Keep / Remove
133
+ var li = mk("li", "");
134
+ li.appendChild(mk("strong", "", e.head || ""));
135
+ if (e.state) { li.appendChild(mk("span", "state st-" + e.state, e.state_label || e.state)); }
136
+ if (e.excerpt) { li.appendChild(mk("p", "ex", e.excerpt)); }
137
+ if (e.solution) { li.appendChild(mk("p", "ex", "Solution: " + e.solution)); }
138
+ if (act && e.actions && e.actions.length) {
139
+ var row = mk("div", "recrow"), btns = [];
140
+ e.actions.forEach(function (a) {
141
+ var b = mk("button", "btn ghost rec", ACT[a] || a);
142
+ b.type = "button";
143
+ b.addEventListener("click", function () {
144
+ btns.forEach(function (x) { x.disabled = true; });
145
+ act(e, a, btns);
146
+ });
147
+ btns.push(b);
148
+ row.appendChild(b);
149
+ });
150
+ li.appendChild(row);
151
+ }
152
+ return li;
153
+ }
154
+ function saved(c, act) { // what a session saved: each record, written as text
155
+ var box = mk("div", "");
156
+ if (c.started_utc) {
157
+ box.appendChild(mk("p", "faint", "Session " + localTime(c.started_utc) +
158
+ (c.ended_utc ? " to " + endTime(c.started_utc, c.ended_utc) : "") + (c.app ? ", by " + c.app : "")));
159
+ }
160
+ var entries = c.entries || [];
161
+ if (!entries.length) {
162
+ box.appendChild(mk("p", "muted", c.note || ""));
163
+ return box;
164
+ }
165
+ var shown = c.shown || entries.length;
166
+ box.appendChild(mk("p", "muted", "What this session saved (" + c.total + "):"));
167
+ if (act && c.explain) { box.appendChild(mk("p", "faint", c.explain)); }
168
+ var ul = mk("ul", "saved");
169
+ entries.slice(0, shown).forEach(function (e) { ul.appendChild(savedItem(e, act)); });
170
+ box.appendChild(ul);
171
+ var rest = entries.slice(shown);
172
+ if (rest.length) { // the rest is already here: open it in place
173
+ var hid = mk("ul", "saved");
174
+ hid.hidden = true;
175
+ rest.forEach(function (e) { hid.appendChild(savedItem(e, act)); });
176
+ var more = "Show " + rest.length + " more", less = "Show fewer";
177
+ var btn = mk("button", "btn ghost more", more);
178
+ btn.type = "button";
179
+ btn.setAttribute("aria-expanded", "false");
180
+ btn.addEventListener("click", function () {
181
+ hid.hidden = !hid.hidden;
182
+ btn.textContent = hid.hidden ? more : less;
183
+ btn.setAttribute("aria-expanded", hid.hidden ? "false" : "true");
184
+ });
185
+ box.appendChild(hid);
186
+ box.appendChild(btn);
187
+ }
188
+ if (c.more) {
189
+ box.appendChild(mk("p", "faint", c.more + " more not sent to this page: " +
190
+ "the review page on your computer lists all."));
191
+ } else if (rest.length) {
192
+ box.appendChild(mk("p", "faint", "The review page on your computer lists them too."));
193
+ }
194
+ return box;
195
+ }
196
+ """
197
+
198
+ JS = r"""(function () {
199
+ "use strict";
200
+ var m = /(?:^#|&)g=([A-Za-z0-9_.-]+)/.exec(location.hash || "");
201
+ var grant = m ? m[1] : null;
202
+ history.replaceState(null, "", location.pathname); // the grant leaves the address bar now
203
+ var base = location.pathname.replace(/\/+$/, "");
204
+ var token = null, needsName = false;
205
+ function $(id) { return document.getElementById(id); }
206
+ function say(title, text, bad) {
207
+ $("title").textContent = title;
208
+ $("status").textContent = text || "";
209
+ $("status").className = bad ? "err" : "muted";
210
+ }
211
+ function post(path, body) {
212
+ return fetch(base + path, {
213
+ method: "POST", credentials: "same-origin", cache: "no-store",
214
+ headers: {"Content-Type": "application/json"}, body: JSON.stringify(body)
215
+ }).then(function (r) {
216
+ return r.json().then(function (j) { return {ok: r.ok, j: j}; });
217
+ });
218
+ }
219
+ function decide(decision) {
220
+ $("approve").disabled = $("reject").disabled = true;
221
+ var body = {grant: grant, token: token, decision: decision};
222
+ if (needsName) { body.confirm_name = $("confirm-name").value; }
223
+ post("/decide", body).then(function (r) {
224
+ $("item").hidden = true;
225
+ if (r.ok) { say(decision === "approve" ? "Approved" : "Rejected", r.j.message); }
226
+ else { say("Not done", r.j.message || "Something went wrong.", true); }
227
+ }, function () { say("Not done", "Your FlowState didn't answer.", true); });
228
+ }
229
+ /*SAVED_JS*/
230
+ if (!grant) { say("This link is incomplete", "Open it again from the chat.", true); return; }
231
+ post("/open", {grant: grant}).then(function (r) {
232
+ if (!r.ok) { say("Can't show this request", r.j.message || "", true); return; }
233
+ var it = r.j.item;
234
+ token = r.j.token;
235
+ needsName = !!it.needs_name;
236
+ if (typeof r.j.name === "string" && r.j.name) { // this FlowState's name, as text (textContent)
237
+ $("foot").textContent = r.j.name + ", your FlowState on your own computer, shows this page. " +
238
+ "flowstatemcp.com only confirmed it's you; it never sees what's in it.";
239
+ }
240
+ say(it.question, "");
241
+ $("project").textContent = it.project + (r.j.name ? " · " + r.j.name : "");
242
+ $("summary").textContent = it.summary;
243
+ $("details").textContent = it.details || "";
244
+ function drawSaved(c) {
245
+ var box = $("saved");
246
+ while (box.firstChild) { box.removeChild(box.firstChild); }
247
+ box.appendChild(saved(c, function (e, action, btns) {
248
+ post("/record", {grant: grant, token: token, record: e.key, action: action}).then(function (r) {
249
+ if (r.ok && r.j.item && r.j.item.contents) { drawSaved(r.j.item.contents); $("status").textContent = r.j.message; }
250
+ else { btns.forEach(function (x) { x.disabled = false; }); $("status").textContent = r.j.message || "Not done."; $("status").className = "err"; }
251
+ }, function () { btns.forEach(function (x) { x.disabled = false; }); say("Not done", "Your FlowState didn't answer.", true); });
252
+ }));
253
+ }
254
+ if (it.contents) { drawSaved(it.contents); }
255
+ $("approve").textContent = it.yes || "Approve";
256
+ $("reject").textContent = it.no || "Reject";
257
+ $("who").textContent = "Asked by " + it.queued_by + ", " + localTime(it.queued_at);
258
+ $("name-row").hidden = !needsName;
259
+ $("item").hidden = false;
260
+ if (it.card && it.card.sides) {
261
+ var box = document.querySelector(".actions");
262
+ $("approve").hidden = $("reject").hidden = true;
263
+ $("summary").textContent = "";
264
+ [["keep_a", "Keep A", "btn"], ["keep_b", "Keep B", "btn ghost"],
265
+ ["keep_both", "Keep both", "btn ghost"], ["dismiss", "Dismiss", "btn ghost"]].forEach(function (b) {
266
+ var btn = document.createElement("button");
267
+ btn.className = b[2];
268
+ btn.type = "button";
269
+ btn.textContent = b[1];
270
+ btn.addEventListener("click", function () { decide(b[0]); });
271
+ box.appendChild(btn);
272
+ });
273
+ var sides = document.createElement("div");
274
+ it.card.sides.forEach(function (s) {
275
+ var node = document.createElement("p");
276
+ var ev = (s.evidence || []).map(function (e) {
277
+ return (e.label || "") + (e.excerpt ? " — " + e.excerpt : "");
278
+ }).filter(Boolean).join("; ");
279
+ node.textContent = "Claim " + (s.mark || "") + " (" + (s.who || "Unknown") +
280
+ (s.when ? ", " + s.when : "") + "): " + (s.claim || "") +
281
+ " Evidence: " + (ev || "none");
282
+ sides.appendChild(node);
283
+ });
284
+ $("details").textContent = "";
285
+ $("details").appendChild(sides);
286
+ }
287
+ $("approve").addEventListener("click", function () { decide("approve"); });
288
+ $("reject").addEventListener("click", function () { decide("reject"); });
289
+ }, function () { say("Can't show this request", "Your FlowState didn't answer.", true); });
290
+ })();
291
+ """.replace("/*SAVED_JS*/\n", SAVED_JS)
292
+
293
+
294
+ def _sha(s: str) -> str:
295
+ return hashlib.sha256((s or "").encode()).hexdigest()
296
+
297
+
298
+ def _now() -> str:
299
+ return datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S")
300
+
301
+
302
+ def view(db_path, item_id: int) -> dict:
303
+ """What the page shows, from the local database."""
304
+ from . import review
305
+ item = review.get_item(db_path, item_id)
306
+ conn = approvals._conn(db_path)
307
+ try:
308
+ row = conn.execute("SELECT name FROM projects WHERE id = ?", (item["project_id"],)).fetchone()
309
+ finally:
310
+ conn.close()
311
+ project = row[0] if row else f"project #{item['project_id']}"
312
+ p = json.loads(item.get("payload") or "{}")
313
+ from .identity import shown_row
314
+ app = shown_row(item, fallback=item.get("source") or "An app")
315
+ ask = approvals.describe(item, project, db_path)
316
+ card = None
317
+ summary = review.strip_tag(item.get("summary") or "")
318
+ details = item.get("details") or ""
319
+ if item["kind"] == "cleanup": # FlowState's own idea: one plain question and its reason
320
+ why = p.get("reason") or ""
321
+ question = f"Should FlowState {ask}?" + (f" {why[:1].upper()}{why[1:]}." if why else "")
322
+ from . import cleanup # the exact list, flagged if it changed (task 74)
323
+ listed = cleanup.preview(db_path, item)
324
+ if listed:
325
+ details = (details + "\n" if details else "") + listed["text"]
326
+ elif item["kind"] == "session_summary": # what Keep / Don't keep really do
327
+ question = f"{review.KEEP_QUESTION} {review.KEEP_HOW}" # Keep all: below, with records
328
+ elif item["kind"] == "delete": # the rows the database says will go, not the agent's words
329
+ from . import deletes
330
+ got = deletes.preview(db_path, item)
331
+ question, summary, details = got["headline"], got["root_line"], got["details"]
332
+ elif item["kind"] == "disagreement":
333
+ from . import conflict_cards
334
+ card = conflict_cards.phone_card(db_path, item)
335
+ question = (card or {}).get("headline") or "Two AIs disagree."
336
+ summary, details = "", ""
337
+ elif item["kind"] == "card_offer": # FlowState's own offer (card_offer.py), not an app's ask
338
+ question = item["summary"] + " Approve allows it: its taps on the cards count as yours. " \
339
+ "Reject keeps the text, and it isn't asked again."
340
+ elif item["kind"] == "reopen_conflict": # FlowState's question after an undo (conflict_reopen.py)
341
+ question = item["summary"] + " Approve reopens them: back in the brief, and you are " \
342
+ "asked again whether they conflict. Reject leaves them closed."
343
+ else:
344
+ question = f"{app} wants to {ask}."
345
+ keep = item["kind"] == "session_summary"
346
+ contents = None
347
+ if keep: # what the session saved: titles and excerpts, not just counts
348
+ from . import session_contents
349
+ c = session_contents.for_item(db_path, {**item, "project": project})
350
+ contents = session_contents.public(c, send=session_contents.PHONE_MAX)
351
+ if contents and contents["entries"]: # Keep all: the summary and every record not removed
352
+ question = f"{review.KEEP_QUESTION} {review.KEEP_ALL_HOW}"
353
+ out = {"id": item["id"], "project": project, "kind": item["kind"], "question": question,
354
+ "summary": summary, "details": details,
355
+ "yes": (review.KEEP_ALL if contents and contents["entries"] else review.KEEP)
356
+ if keep else "Approve", "no": review.DONT_KEEP if keep else "Reject",
357
+ "queued_by": shown_row(item, on_machine=True, fallback=item.get("source") or "An app"),
358
+ "queued_at": item.get("created_at") or "",
359
+ "needs_name": item["kind"] == "delete" and p.get("record_type") == "project"}
360
+ if card:
361
+ out["card"] = card
362
+ if contents is not None:
363
+ from . import session_contents
364
+ out["contents"] = contents
365
+ out["contents_text"] = "\n".join(
366
+ [session_contents.when_line(c)] + session_contents.lines(c)).strip()
367
+ out["queued_at_utc"] = out["queued_at"]
368
+ return out
369
+
370
+
371
+ def _same_origin(request) -> bool:
372
+ """A JSON POST from this page's own origin (no form posts, no other sites)."""
373
+ if (request.headers.get("content-type") or "").split(";")[0].strip() != "application/json":
374
+ return False
375
+ site = request.headers.get("sec-fetch-site")
376
+ if site and site not in ("same-origin", "none"):
377
+ return False
378
+ origin = request.headers.get("origin")
379
+ if origin:
380
+ from urllib.parse import urlparse
381
+ if (urlparse(origin).netloc or "").lower() != (request.headers.get("host") or "").lower():
382
+ return False
383
+ return True
384
+
385
+
386
+ def routes(db_path_fn):
387
+ """Starlette routes; `db_path_fn()` gives the database in use."""
388
+ from starlette.responses import HTMLResponse, JSONResponse, Response
389
+ from starlette.routing import Route
390
+
391
+ def answer(body: dict, status: int = 200, cookie: str = None, path: str = "/approve"):
392
+ r = JSONResponse(body, status_code=status, headers=HEADERS)
393
+ if cookie is not None:
394
+ r.set_cookie(COOKIE, cookie, max_age=approvals.GRANT_SECONDS, path=path, secure=True,
395
+ httponly=True, samesite="strict")
396
+ return r
397
+
398
+ def refuse(e: approvals.ApprovalError, status: int = 403):
399
+ return answer({"error": e.code, "message": str(e)}, status)
400
+
401
+ async def shell(request):
402
+ return HTMLResponse(SHELL, headers=HEADERS)
403
+
404
+ async def asset(request):
405
+ name = request.path_params["name"]
406
+ if name == "approve.css":
407
+ return Response(CSS, media_type="text/css", headers=HEADERS)
408
+ if name == "approve.js":
409
+ return Response(JS, media_type="text/javascript", headers=HEADERS)
410
+ return answer({"error": "not found"}, 404)
411
+
412
+ async def _body(request):
413
+ if not _same_origin(request):
414
+ raise approvals.ApprovalError("bad_request", "This request didn't come from the page.")
415
+ try:
416
+ body = json.loads(await request.body() or b"{}")
417
+ except ValueError:
418
+ body = None
419
+ if not isinstance(body, dict) or not isinstance(body.get("grant"), str):
420
+ raise approvals.ApprovalError("bad_request", "The approval link is incomplete.")
421
+ return body
422
+
423
+ async def open_(request):
424
+ item_id = request.path_params["item"]
425
+ db = db_path_fn()
426
+ try:
427
+ body = await _body(request)
428
+ claims = await asyncio.to_thread(approvals.verify_grant, db, body["grant"], item_id)
429
+ token = secrets.token_urlsafe(32)
430
+ fresh = await asyncio.to_thread(_remember, db, body["grant"], item_id, token,
431
+ claims["exp"])
432
+ if not fresh:
433
+ raise approvals.ApprovalError("grant_used", "This approval link was already "
434
+ "opened. Open it again from the chat.")
435
+ item = await asyncio.to_thread(view, db, item_id)
436
+ except approvals.ApprovalError as e:
437
+ return refuse(e)
438
+ return answer({"item": item, "token": token, "name": install_name.get()}, cookie=token,
439
+ path=f"/approve/{item_id}")
440
+
441
+ async def decide(request):
442
+ item_id = request.path_params["item"]
443
+ db = db_path_fn()
444
+ try:
445
+ body = await _body(request)
446
+ if body.get("decision") not in ("approve", "reject", "keep_a", "keep_b", "keep_both",
447
+ "dismiss"):
448
+ raise approvals.ApprovalError("bad_request", "Choose Approve or Reject.")
449
+ claims = await asyncio.to_thread(approvals.verify_grant, db, body["grant"], item_id)
450
+ token = body.get("token")
451
+ if not isinstance(token, str) or request.cookies.get(COOKIE) != token:
452
+ raise approvals.ApprovalError("bad_token", "This page's session doesn't match. "
453
+ "Open the link again from the chat.")
454
+ if not await asyncio.to_thread(_use, db, body["grant"], item_id, token):
455
+ raise approvals.ApprovalError("grant_used", "This approval link was already "
456
+ "used. Open it again from the chat.")
457
+ except approvals.ApprovalError as e:
458
+ return refuse(e)
459
+ from .popup import as_user
460
+ label = "phone" # never the relay handle: apps read decision labels (audit D19)
461
+ keep = {"keep_a": "a", "keep_b": "b", "keep_both": "both"}.get(body["decision"])
462
+ if body["decision"] == "approve" or keep:
463
+ args = {"item_id": item_id, "confirm_name": body.get("confirm_name"),
464
+ "user_request": "The user approved this on their phone (flowstatemcp.com)."}
465
+ if keep:
466
+ args["keep"] = keep
467
+ args["user_request"] = "The user tapped a button on their phone (flowstatemcp.com)."
468
+ res = await asyncio.to_thread(as_user, "review_approve", args, label)
469
+ else:
470
+ args = {"item_id": item_id, "reason": "rejected on the user's phone",
471
+ "user_request": "The user rejected this on their phone (flowstatemcp.com)."}
472
+ res = await asyncio.to_thread(as_user, "review_reject", args, label)
473
+ if isinstance(res, dict) and "error" in res:
474
+ return answer({"error": res["error"], "message": res.get("message") or res["error"]}, 409)
475
+ done = {"approve": "Approved", "keep_a": "Kept the older entry",
476
+ "keep_b": "Kept the newer entry", "keep_both": "Kept both",
477
+ "dismiss": "Dismissed"}.get(body["decision"], "Rejected")
478
+ extra = " It can be undone from the chat (manage_history undo)." \
479
+ if body["decision"] == "approve" and isinstance(res, dict) and res.get("undo") else ""
480
+ return answer({"ok": True, "message": f"{done}. You can close this page.{extra}"})
481
+
482
+ async def record(request):
483
+ """Keep / Remove / Bring back on one record of the session summary this page shows:
484
+ the same grant and page token as the tap (the grant stays usable for Keep all)."""
485
+ item_id = request.path_params["item"]
486
+ db = db_path_fn()
487
+ try:
488
+ body = await _body(request)
489
+ claims = await asyncio.to_thread(approvals.verify_grant, db, body["grant"], item_id)
490
+ token = body.get("token")
491
+ await asyncio.to_thread(approvals.require_phone_on, db) # off closes open pages
492
+ if not isinstance(token, str) or request.cookies.get(COOKIE) != token \
493
+ or not await asyncio.to_thread(_open_page, db, body["grant"], item_id, token):
494
+ raise approvals.ApprovalError("bad_token", "This page's session doesn't match. "
495
+ "Open the link again from the chat.")
496
+ if not isinstance(body.get("record"), str) or not isinstance(body.get("action"), str):
497
+ raise approvals.ApprovalError("bad_request", "Choose Keep or Remove for a record.")
498
+ except approvals.ApprovalError as e:
499
+ return refuse(e)
500
+ res = await asyncio.to_thread(record_as_user, db, item_id, body["record"], body["action"],
501
+ "phone")
502
+ item = await asyncio.to_thread(view, db, item_id)
503
+ if "error" in res:
504
+ return answer({"error": res["error"], "message": res["message"], "item": item}, 409)
505
+ return answer({"ok": True, "message": res["message"], "item": item})
506
+
507
+ return [Route("/approve/assets/{name}", asset, methods=["GET"]),
508
+ Route("/approve/{item:int}", shell, methods=["GET"]),
509
+ Route("/approve/{item:int}/record", record, methods=["POST"]),
510
+ Route("/approve/{item:int}/open", open_, methods=["POST"]),
511
+ Route("/approve/{item:int}/decide", decide, methods=["POST"])]
512
+
513
+
514
+ def _remember(db_path, grant: str, item_id: int, token: str, exp: int) -> bool:
515
+ """First page load for this grant: store its hash and the page token's. False if seen."""
516
+ conn = approvals._conn(db_path)
517
+ try:
518
+ cur = conn.execute(
519
+ """INSERT OR IGNORE INTO approval_grants (grant_hash, item_id, page_token_hash, seen_at,
520
+ expires_at) VALUES (?, ?, ?, ?, ?)""",
521
+ (approvals.grant_hash(grant), item_id, _sha(token), _now(),
522
+ datetime.fromtimestamp(exp, timezone.utc).strftime("%Y-%m-%d %H:%M:%S")))
523
+ conn.commit()
524
+ return cur.rowcount == 1
525
+ finally:
526
+ conn.close()
527
+
528
+
529
+ def record_as_user(db_path, item_id: int, record: str, action: str, label: str) -> dict:
530
+ """A record decision from a phone page (session_records.decide) as the user: no agent
531
+ identity, labelled `label`. Errors come back as their payload."""
532
+ from . import identity, session_records
533
+ tok = identity.set_current(None)
534
+ try:
535
+ return session_records.decide(db_path, item_id, record, action, label)
536
+ except session_records.RecordError as e:
537
+ return e.payload
538
+ finally:
539
+ identity.reset(tok)
540
+
541
+
542
+ def _open_page(db_path, grant: str, item_id: int, token: str) -> bool:
543
+ """This grant's page is open with this token, not yet used for the final tap, not expired."""
544
+ conn = approvals._conn(db_path)
545
+ try:
546
+ return conn.execute(
547
+ """SELECT 1 FROM approval_grants WHERE grant_hash = ? AND item_id = ?
548
+ AND page_token_hash = ? AND used_at IS NULL AND expires_at > ?""",
549
+ (approvals.grant_hash(grant), item_id, _sha(token), _now())).fetchone() is not None
550
+ finally:
551
+ conn.close()
552
+
553
+
554
+ def _use(db_path, grant: str, item_id: int, token: str) -> bool:
555
+ """The tap: this grant's page token, not used yet and not expired. Marks it used."""
556
+ conn = approvals._conn(db_path)
557
+ try:
558
+ cur = conn.execute(
559
+ """UPDATE approval_grants SET used_at = ? WHERE grant_hash = ? AND item_id = ?
560
+ AND page_token_hash = ? AND used_at IS NULL AND expires_at > ?""",
561
+ (_now(), approvals.grant_hash(grant), item_id, _sha(token), _now()))
562
+ conn.commit()
563
+ return cur.rowcount == 1
564
+ finally:
565
+ conn.close()